package quicknote import ( "context" "errors" "fmt" "regexp" "strconv" "strings" "time" "github.com/cloudwego/eino/components/tool" toolutils "github.com/cloudwego/eino/components/tool/utils" "github.com/cloudwego/eino/schema" ) const ( // ToolNameQuickNoteCreateTask 是“AI随口记”写库工具的标准名称。 // 该名称会直接暴露给大模型,因此建议保持稳定,避免后续提示词和历史上下文失配。 ToolNameQuickNoteCreateTask = "quick_note_create_task" // ToolDescQuickNoteCreateTask 是工具的简要职责说明。 ToolDescQuickNoteCreateTask = "把用户随口提到的事项落库为任务,支持可选截止时间与优先级" ) var ( // quickNoteDeadlineLayouts 是“绝对时间”白名单格式。 // 只要命中任意一个 layout,就会被归一化为分钟级时间并进入写库流程。 quickNoteDeadlineLayouts = []string{ time.RFC3339, "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", "2006/01/02 15:04:05", "2006/01/02 15:04", "2006.01.02 15:04:05", "2006.01.02 15:04", "2006-01-02", "2006/01/02", "2006.01.02", } quickNoteDateOnlyLayouts = map[string]struct{}{ "2006-01-02": {}, "2006/01/02": {}, "2006.01.02": {}, } // 正则区: // 1) 用于解析明确时间表达; // 2) 用于“是否存在时间线索”的判定(即使格式错误,也会触发校验失败而非静默忽略)。 quickNoteClockHMRegex = regexp.MustCompile(`(\d{1,2})\s*[::]\s*(\d{1,2})`) quickNoteClockCNRegex = regexp.MustCompile(`(\d{1,2})\s*点\s*(半|(\d{1,2})\s*分?)?`) quickNoteYMDRegex = regexp.MustCompile(`(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) quickNoteMDRegex = regexp.MustCompile(`(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?`) quickNoteDateSepRegex = regexp.MustCompile(`\d{1,4}\s*[-/.]\s*\d{1,2}(\s*[-/.]\s*\d{1,2})?`) quickNoteWeekdayRegex = regexp.MustCompile(`(下周|下星期|下礼拜|本周|这周|本星期|这星期|周|星期|礼拜)([一二三四五六日天])`) quickNoteRelativeTokens = []string{ "今天", "今日", "今晚", "今早", "今晨", "明天", "明日", "后天", "大后天", "昨天", "昨日", "早上", "早晨", "上午", "中午", "下午", "晚上", "傍晚", "夜里", "凌晨", } ) // QuickNoteToolDeps 描述“随口记工具包”需要的外部依赖。 // 这里采用函数注入的方式,避免 agent 包和 service/dao 强耦合,后续更容易演进为 mock 测试或多实现切换。 type QuickNoteToolDeps struct { // ResolveUserID 从上下文中解析当前登录用户 ID。 ResolveUserID func(ctx context.Context) (int, error) // CreateTask 执行真实写库动作。 CreateTask func(ctx context.Context, req QuickNoteCreateTaskRequest) (*QuickNoteCreateTaskResult, error) } func (d QuickNoteToolDeps) validate() error { // 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。 if d.ResolveUserID == nil { return errors.New("quick note tool deps: ResolveUserID is nil") } // 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。 if d.CreateTask == nil { return errors.New("quick note tool deps: CreateTask is nil") } return nil } // QuickNoteToolBundle 是随口记工具集合的打包结果。 // - Tools: 给 ToolsNode 使用 // - ToolInfos: 给 ChatModel 绑定工具 schema 使用 // 两者分开返回,可以适配你后面用 chain、graph、react 的不同挂载姿势。 type QuickNoteToolBundle struct { Tools []tool.BaseTool ToolInfos []*schema.ToolInfo } // QuickNoteCreateTaskRequest 是工具层到业务层的内部请求结构。 // 与模型输入解耦,避免模型字段变化直接影响业务签名。 type QuickNoteCreateTaskRequest struct { UserID int Title string PriorityGroup int DeadlineAt *time.Time // UrgencyThresholdAt 是“进入紧急象限”的分界时间,允许为空。 UrgencyThresholdAt *time.Time } // QuickNoteCreateTaskResult 是业务层返回给工具层的结构化结果。 type QuickNoteCreateTaskResult struct { TaskID int Title string PriorityGroup int DeadlineAt *time.Time UrgencyThresholdAt *time.Time } // QuickNoteCreateTaskToolInput 是提供给大模型的工具参数定义。 // 注意:user_id 不对模型暴露,统一从鉴权上下文提取,避免越权写入。 type QuickNoteCreateTaskToolInput struct { Title string `json:"title" jsonschema:"required,description=任务标题,简洁明确"` // PriorityGroup 使用 1~4,和后端 tasks.priority 保持一致。 PriorityGroup int `json:"priority_group" jsonschema:"required,enum=1,enum=2,enum=3,enum=4,description=优先级分组(1重要且紧急,2重要不紧急,3简单不重要,4不简单不重要)"` // DeadlineAt 支持绝对时间与常见相对时间(如明天/后天/下周一/今晚),内部会归一化为绝对时间。 DeadlineAt string `json:"deadline_at,omitempty" jsonschema:"description=可选截止时间,支持RFC3339、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd HH:mm 以及常见中文相对时间"` // UrgencyThresholdAt 表示“何时从不紧急象限自动平移到紧急象限”。 // 允许为空;非空时会走同样的时间解析与合法性校验。 UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty" jsonschema:"description=可选紧急分界时间,支持与deadline_at相同格式"` } // QuickNoteCreateTaskToolOutput 是返回给大模型的工具结果。 // 该结构可直接给模型用于“向用户解释已记录到哪个优先级”。 type QuickNoteCreateTaskToolOutput struct { TaskID int `json:"task_id"` Title string `json:"title"` PriorityGroup int `json:"priority_group"` PriorityLabel string `json:"priority_label"` DeadlineAt string `json:"deadline_at,omitempty"` Message string `json:"message"` } // BuildQuickNoteToolBundle 构建“AI随口记”工具包。 // 这是 agent 目录给上层编排层(chain/graph/react)提供的统一入口。 func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) { // 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。 if err := deps.validate(); err != nil { return nil, err } // 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。 // 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。 createTaskTool, err := toolutils.InferTool( ToolNameQuickNoteCreateTask, ToolDescQuickNoteCreateTask, func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) { // 2.1 防御式检查:工具调用参数不能为 nil。 if input == nil { return nil, errors.New("工具参数不能为空") } // 2.2 标题与优先级是写库硬条件,必须先校验。 title := strings.TrimSpace(input.Title) if title == "" { return nil, errors.New("title 不能为空") } if !IsValidTaskPriority(input.PriorityGroup) { return nil, fmt.Errorf("priority_group=%d 非法,必须在 1~4", input.PriorityGroup) } // 这里对 deadline_at 做“强校验”: // - 空值允许(代表没有截止时间); // - 非空但无法解析直接报错,避免把有问题的时间静默写成 NULL。 deadline, err := parseOptionalDeadline(input.DeadlineAt) if err != nil { return nil, err } urgencyThresholdAt, err := parseOptionalDeadline(input.UrgencyThresholdAt) if err != nil { return nil, err } // 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。 userID, err := deps.ResolveUserID(ctx) if err != nil { return nil, fmt.Errorf("解析用户身份失败: %w", err) } if userID <= 0 { return nil, fmt.Errorf("非法 user_id=%d", userID) } // 2.4 走业务层写库。 result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{ UserID: userID, Title: title, PriorityGroup: input.PriorityGroup, DeadlineAt: deadline, UrgencyThresholdAt: urgencyThresholdAt, }) if err != nil { return nil, err } if result == nil || result.TaskID <= 0 { return nil, errors.New("写入任务后返回结果异常") } // 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。 finalTitle := title if strings.TrimSpace(result.Title) != "" { finalTitle = strings.TrimSpace(result.Title) } finalPriority := input.PriorityGroup if IsValidTaskPriority(result.PriorityGroup) { finalPriority = result.PriorityGroup } // 2.6 截止时间输出统一为 RFC3339,便于跨系统传输与调试。 deadlineStr := "" if result.DeadlineAt != nil { deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339) } else if deadline != nil { deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339) } // 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。 return &QuickNoteCreateTaskToolOutput{ TaskID: result.TaskID, Title: finalTitle, PriorityGroup: finalPriority, PriorityLabel: PriorityLabelCN(finalPriority), DeadlineAt: deadlineStr, Message: fmt.Sprintf("已记录:%s(%s)", finalTitle, PriorityLabelCN(finalPriority)), }, nil }, ) if err != nil { return nil, fmt.Errorf("构建随口记工具失败: %w", err) } // 3. Tools 给执行节点使用,ToolInfos 给模型注册 schema 使用,二者都要返回。 tools := []tool.BaseTool{createTaskTool} infos, err := collectToolInfos(ctx, tools) if err != nil { return nil, err } return &QuickNoteToolBundle{ Tools: tools, ToolInfos: infos, }, nil } func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) { // 按工具列表顺序提取 ToolInfo,确保“tools[idx] <-> infos[idx]”一一对应。 infos := make([]*schema.ToolInfo, 0, len(tools)) for _, t := range tools { info, err := t.Info(ctx) if err != nil { return nil, fmt.Errorf("读取工具信息失败: %w", err) } infos = append(infos, info) } return infos, nil } // parseOptionalDeadline 解析工具输入中的可选截止时间。 // 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at,就必须能被解析。 func parseOptionalDeadline(raw string) (*time.Time, error) { // 1. 先做标点与空白归一化,避免中文输入噪声影响解析。 value := normalizeDeadlineInput(raw) if value == "" { // 2. 空字符串合法,表示任务无截止时间。 return nil, nil } // 3. 统一按“严格模式”解析:给了时间就必须成功解析。 deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute()) if err != nil { return nil, err } if deadline == nil { // 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。 if !hasHint { return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) } return nil, fmt.Errorf("deadline_at 无法解析: %s", value) } return deadline, nil } // parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。 // 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。 func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) { // 场景:模型已给出 deadline_at,需要基于同一 requestNow 再次硬校验。 value := normalizeDeadlineInput(raw) if value == "" { return nil, nil } deadline, _, err := parseOptionalDeadlineFromText(value, now) if err != nil { return nil, err } if deadline == nil { return nil, fmt.Errorf("deadline_at 格式不支持: %s", value) } return deadline, nil } // parseOptionalDeadlineFromUserInput 是“用户原句解析”的宽松入口。 // 返回值说明: // - deadline != nil:成功解析出时间; // - hasHint=false 且 err=nil:文本里没有明显时间线索,应视为“用户没给时间”; // - hasHint=true 且 err!=nil:用户给了时间但格式非法,应提示用户修正,不应落库。 func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) { // 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。 value := normalizeDeadlineInput(raw) if value == "" { return nil, false, nil } deadline, hasHint, err := parseOptionalDeadlineFromText(value, now) if err != nil { if hasHint { // 有时间线索 + 解析失败:上层应明确提示用户改时间格式。 return nil, true, err } // 无明显时间线索:按“未提供时间”处理。 return nil, false, nil } if deadline == nil { if hasHint { return nil, true, fmt.Errorf("deadline_at 无法解析: %s", value) } return nil, false, nil } return deadline, true, nil } // parseOptionalDeadlineFromText 是内部通用解析器。 // 解析顺序: // 1) 绝对时间(明确年月日时分); // 2) 相对时间(明天/下周一/今晚); // 3) 若识别到时间线索但仍失败,返回 hasHint=true + error,交给上层决定是否拦截。 func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, bool, error) { if strings.TrimSpace(value) == "" { return nil, false, nil } // 1. 统一时区与时间基准,保证相对时间可重复计算。 loc := quickNoteLocation() now = now.In(loc) hasHint := hasDeadlineHint(value) // 2. 先尝试绝对时间(优先级更高,歧义更小)。 if abs, ok := tryParseAbsoluteDeadline(value, loc); ok { return abs, true, nil } // 3. 再尝试相对时间(明天/下周一/今晚)。 if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized { if err != nil { return nil, true, err } return rel, true, nil } // 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。 if hasHint { return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value) } return nil, false, nil } // normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。 func normalizeDeadlineInput(raw string) string { // 先 trim,避免纯空格输入影响后续逻辑。 trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } // 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。 replacer := strings.NewReplacer( ":", ":", ",", ",", "。", ".", " ", " ", ) return strings.TrimSpace(replacer.Replace(trimmed)) } // hasDeadlineHint 判断文本里是否存在“时间相关线索”。 // 该函数的意义是区分两种情况: // 1) 用户根本没给时间(允许 deadline 为空); // 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL)。 func hasDeadlineHint(value string) bool { // 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。 if quickNoteClockHMRegex.MatchString(value) || quickNoteClockCNRegex.MatchString(value) || quickNoteYMDRegex.MatchString(value) || quickNoteMDRegex.MatchString(value) || quickNoteDateSepRegex.MatchString(value) || quickNoteWeekdayRegex.MatchString(value) { return true } // 2. 再用词元判断“明天/今晚”等语义线索。 for _, token := range quickNoteRelativeTokens { if strings.Contains(value, token) { return true } } return false } // tryParseAbsoluteDeadline 尝试按绝对时间格式解析。 // 若只提供日期(无时分),默认归一到当天 23:59,表示“当日截止”。 func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) { // 逐个 layout 尝试,命中即返回。 for _, layout := range quickNoteDeadlineLayouts { var ( t time.Time err error ) if layout == time.RFC3339 { t, err = time.Parse(layout, value) if err == nil { t = t.In(loc) } } else { t, err = time.ParseInLocation(layout, value, loc) } if err != nil { continue } // Date-only 输入(例如 2026-03-20)默认补到 23:59。 if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly { t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc) } else { // 非 date-only 则统一清零秒级,保持分钟粒度一致。 t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc) } return &t, true } return nil, false } // tryParseRelativeDeadline 尝试解析“相对时间 + 可选时刻”。 // 例子: // - 明天交报告(默认 23:59) // - 下周一上午9点开会(解析为下周一 09:00) func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) { // 1. 先确定“哪一天”。 baseDate, recognized := inferBaseDate(value, now, loc) if !recognized { return nil, false, nil } // 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。 hour, minute, hasExplicitClock, err := extractClock(value) if err != nil { return nil, true, err } if !hasExplicitClock { hour, minute = defaultClockByHint(value) } deadline := time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), hour, minute, 0, 0, loc) return &deadline, true, nil } // inferBaseDate 负责先确定“哪一天”。 // 解析优先级: // 1) 明确年月日; // 2) 月日(自动推断年份); // 3) 周几表达(本周/下周); // 4) 明天/后天/今晚等相对词。 func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) { // 1) yyyy年MM月dd日 if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 { year, _ := strconv.Atoi(matched[1]) month, _ := strconv.Atoi(matched[2]) day, _ := strconv.Atoi(matched[3]) if isValidDate(year, month, day) { return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc), true } } // 2) MM月dd日(自动推断年份:若今年已过则滚到明年) if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 { month, _ := strconv.Atoi(matched[1]) day, _ := strconv.Atoi(matched[2]) year := now.Year() if !isValidDate(year, month, day) { return time.Time{}, false } candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) if candidate.Before(startOfDay(now)) { year++ if !isValidDate(year, month, day) { return time.Time{}, false } candidate = time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc) } return candidate, true } // 3) 本周/下周 + 周几 if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 { prefix := matched[1] target, ok := toWeekday(matched[2]) if ok { return resolveWeekdayDate(now, prefix, target), true } } // 4) 今天/明天/后天/大后天/昨天等相对词 today := startOfDay(now) switch { case strings.Contains(value, "大后天"): return today.AddDate(0, 0, 3), true case strings.Contains(value, "后天"): return today.AddDate(0, 0, 2), true case strings.Contains(value, "明天") || strings.Contains(value, "明日"): return today.AddDate(0, 0, 1), true case strings.Contains(value, "今天") || strings.Contains(value, "今日") || strings.Contains(value, "今晚") || strings.Contains(value, "今早") || strings.Contains(value, "今晨"): return today, true case strings.Contains(value, "昨天") || strings.Contains(value, "昨日"): return today.AddDate(0, 0, -1), true default: return time.Time{}, false } } // extractClock 从文本提取时刻(时/分)。 // 支持: // - 24h 表达:18:30 // - 中文表达:3点、3点半、3点20分 func extractClock(value string) (int, int, bool, error) { // hour/minute 最终会用于 time.Date,需要先做范围约束。 hour := 0 minute := 0 hasClock := false // 1) 24 小时制:18:30 if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 { h, errH := strconv.Atoi(matched[1]) m, errM := strconv.Atoi(matched[2]) if errH != nil || errM != nil { return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) } hour = h minute = m hasClock = true } else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 { // 2) 中文时刻:3点 / 3点半 / 3点20分 h, errH := strconv.Atoi(matched[1]) if errH != nil { return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) } hour = h minute = 0 hasClock = true if len(matched) >= 3 { if matched[2] == "半" { minute = 30 } else if len(matched) >= 4 && strings.TrimSpace(matched[3]) != "" { m, errM := strconv.Atoi(strings.TrimSpace(matched[3])) if errM != nil { return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value) } minute = m } } } if !hasClock { // 没有显式时刻并不是错误,交给默认时刻策略处理。 return 0, 0, false, nil } // 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。 if isPMHint(value) && hour < 12 { hour += 12 } if isNoonHint(value) && hour >= 1 && hour <= 10 { hour += 12 } if strings.Contains(value, "凌晨") && hour == 12 { hour = 0 } if hour < 0 || hour > 23 || minute < 0 || minute > 59 { return 0, 0, true, fmt.Errorf("deadline_at 时间超出范围: %s", value) } return hour, minute, true, nil } // defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。 func defaultClockByHint(value string) (int, int) { // 没有明确时刻时按中文语义设置一个“可解释的默认值”。 switch { case strings.Contains(value, "凌晨"): return 1, 0 case strings.Contains(value, "早上") || strings.Contains(value, "早晨") || strings.Contains(value, "上午") || strings.Contains(value, "今早") || strings.Contains(value, "明早"): return 9, 0 case strings.Contains(value, "中午"): return 12, 0 case strings.Contains(value, "下午"): return 15, 0 case strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") || strings.Contains(value, "夜里"): return 20, 0 default: // 只给了日期没有具体时刻时,默认当天结束前。 return 23, 59 } } func isPMHint(value string) bool { // 下午/晚上/傍晚通常应映射到 12:00 之后。 return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚") } func isNoonHint(value string) bool { // “中午 1 点”这类表达通常是 13:00 而非 01:00。 return strings.Contains(value, "中午") } func startOfDay(t time.Time) time.Time { // 保留原时区,只把时分秒归零。 loc := t.Location() return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) } func isValidDate(year, month, day int) bool { // 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。 if month < 1 || month > 12 || day < 1 || day > 31 { return false } candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) return candidate.Year() == year && int(candidate.Month()) == month && candidate.Day() == day } func toWeekday(chinese string) (time.Weekday, bool) { // 把中文周几映射到 Go 的 Weekday 枚举。 switch chinese { case "一": return time.Monday, true case "二": return time.Tuesday, true case "三": return time.Wednesday, true case "四": return time.Thursday, true case "五": return time.Friday, true case "六": return time.Saturday, true case "日", "天": return time.Sunday, true default: return time.Sunday, false } } // resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。 func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time { // 1. 先定位本周周一。 today := startOfDay(now) weekdayOffset := (int(today.Weekday()) + 6) % 7 weekStart := today.AddDate(0, 0, -weekdayOffset) targetOffset := (int(target) + 6) % 7 candidateThisWeek := weekStart.AddDate(0, 0, targetOffset) // 2. 再根据“本周/下周/无前缀”选择最终日期。 switch { case strings.HasPrefix(prefix, "下"): return candidateThisWeek.AddDate(0, 0, 7) case strings.HasPrefix(prefix, "本"), strings.HasPrefix(prefix, "这"): return candidateThisWeek default: if candidateThisWeek.Before(today) { return candidateThisWeek.AddDate(0, 0, 7) } return candidateThisWeek } }