package handler import ( "crypto/sha256" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/agentmail/gateway/internal/blob" "github.com/agentmail/gateway/internal/config" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" ) // defaultReminderTemplate 是提醒正文的默认模板。 // // 存变量而非字面值:{title}/{time}/{description} 在触发时由 // scheduler.RenderReminder 替换。与前端 CalendarEventEditor 的 // DEFAULT_TEMPLATE 必须逐字一致 —— 前端用它作 placeholder 与预览, // 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。 const defaultReminderTemplate = "日程提醒:{title}\n时间:{time}\n{description}" // ─── Calendar Events ─── // POST /api/v1/calendar/events func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } var req struct { Title string `json:"title"` Description string `json:"description"` ReminderText string `json:"reminder_text"` AgentName string `json:"agent_name"` ToAddress string `json:"to_address"` Recipients []string `json:"recipients"` DeliveryMode string `json:"delivery_mode"` EventTime time.Time `json:"event_time"` RemindBefore int `json:"remind_before"` Recurrence string `json:"recurrence"` RecurrenceEnd *time.Time `json:"recurrence_end"` } if !DecodeBody(w, r, &req) { return } if req.Title == "" { Error(w, http.StatusBadRequest, "Missing title") return } if req.EventTime.IsZero() { Error(w, http.StatusBadRequest, "Missing event_time") return } if req.ReminderText == "" { // 默认模板必须存**变量形式**而不是把值烤进去。 // // 原来这里 Sprintf 出一份含字面时间的正文。对重复事件是错的: // AdvanceRecurrence 只推进 event_time,reminder_text 保持不动 —— // 于是「每天 9 点」的提醒从第二天起永远写着第一天的日期, // Agent 收到的信里时间与实际触发时刻越差越远。 // // 变量形式由 scheduler.RenderReminder 在**触发时**替换, // 每一次触发都拿当时的 event_time。前端的 DEFAULT_TEMPLATE // 也是这一份(web/src/components/CalendarEventEditor.tsx), // 两处必须一致,否则预览与实发不符。 req.ReminderText = defaultReminderTemplate } if req.Recurrence == "" { req.Recurrence = models.RecurNone } if !validRecurrence(req.Recurrence) { Error(w, http.StatusBadRequest, "recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一") return } recipients, badAddr := normalizeRecipients(req.Recipients) if badAddr != "" { // 地址在这里就校验而不是等到触发时:建事件时报错人能立刻改, // 而触发时报错只会进 journalctl —— 人以为提醒设好了,实际永远发不出去。 Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr) return } // 收件人一个都没有时事件永远发不出去,这不该静默通过 if len(recipients) == 0 && strings.TrimSpace(req.ToAddress) == "" && strings.TrimSpace(req.AgentName) == "" { Error(w, http.StatusBadRequest, "至少要有一个收件人(recipients / to_address / agent_name)") return } if req.DeliveryMode == "" { req.DeliveryMode = models.DeliverSeparate } e := &models.CalendarEvent{ Title: req.Title, Description: req.Description, ReminderText: req.ReminderText, AgentName: req.AgentName, ToAddress: req.ToAddress, Recipients: recipients, DeliveryMode: req.DeliveryMode, EventTime: req.EventTime, RemindBefore: req.RemindBefore, Recurrence: req.Recurrence, RecurrenceEnd: req.RecurrenceEnd, Status: "active", CreatedBy: user.Username, } if _, err := repo.CreateCalendarEvent(r.Context(), e); err != nil { Error(w, http.StatusInternalServerError, "Failed to create event") return } JSON(w, http.StatusCreated, e) } // GET /api/v1/calendar/events?from=...&to=... func ListCalendarEvents(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } fromStr := r.URL.Query().Get("from") toStr := r.URL.Query().Get("to") status := r.URL.Query().Get("status") var from, to time.Time if fromStr != "" { from, _ = time.Parse(time.RFC3339, fromStr) } if toStr != "" { to, _ = time.Parse(time.RFC3339, toStr) } if to.IsZero() { to = time.Now().AddDate(0, 1, 0) // 默认往后一个月 } if from.IsZero() { from = time.Now().AddDate(0, -1, 0) // 默认往前一个月 } events, err := repo.ListCalendarEvents(r.Context(), from, to, status) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list events") return } if events == nil { events = []models.CalendarEvent{} } JSON(w, http.StatusOK, map[string]interface{}{ "events": events, }) } // GET /api/v1/calendar/events/{id} func GetCalendarEvent(w http.ResponseWriter, r *http.Request) { if _, ok := pathUUID(w, r, "id"); !ok { return } eventID := chi.URLParam(r, "id") e, err := repo.GetCalendarEvent(r.Context(), eventID) if err != nil { if errors.Is(err, repo.ErrEventNotFound) { Error(w, http.StatusNotFound, "Event not found") return } Error(w, http.StatusInternalServerError, "Failed to get event") return } JSON(w, http.StatusOK, e) } // PUT /api/v1/calendar/events/{id} func UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) { eventID := chi.URLParam(r, "id") if _, ok := pathUUID(w, r, "id"); !ok { return } var req struct { Title string `json:"title"` Description string `json:"description"` ReminderText string `json:"reminder_text"` AgentName string `json:"agent_name"` ToAddress string `json:"to_address"` Recipients []string `json:"recipients"` DeliveryMode string `json:"delivery_mode"` EventTime time.Time `json:"event_time"` RemindBefore int `json:"remind_before"` Recurrence string `json:"recurrence"` RecurrenceEnd *time.Time `json:"recurrence_end"` Status string `json:"status"` } if !DecodeBody(w, r, &req) { return } if req.Recurrence != "" && !validRecurrence(req.Recurrence) { Error(w, http.StatusBadRequest, "recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一") return } recipients, badAddr := normalizeRecipients(req.Recipients) if badAddr != "" { Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr) return } e := &models.CalendarEvent{ Title: req.Title, Description: req.Description, ReminderText: req.ReminderText, AgentName: req.AgentName, ToAddress: req.ToAddress, Recipients: recipients, DeliveryMode: req.DeliveryMode, EventTime: req.EventTime, RemindBefore: req.RemindBefore, Recurrence: req.Recurrence, RecurrenceEnd: req.RecurrenceEnd, Status: req.Status, } if err := repo.UpdateCalendarEvent(r.Context(), eventID, e); err != nil { if errors.Is(err, repo.ErrEventNotFound) { Error(w, http.StatusNotFound, "Event not found") return } Error(w, http.StatusInternalServerError, "Failed to update event") return } JSON(w, http.StatusOK, e) } // DELETE /api/v1/calendar/events/{id} func DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) { eventID := chi.URLParam(r, "id") if err := repo.DeleteCalendarEvent(r.Context(), eventID); err != nil { if errors.Is(err, repo.ErrEventNotFound) { Error(w, http.StatusNotFound, "Event not found") return } Error(w, http.StatusInternalServerError, "Failed to delete event") return } JSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ─── Calendar Attachments ─── // POST /api/v1/calendar/events/{id}/attachments func UploadCalendarAttachment(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } if Blobs == nil { Error(w, http.StatusServiceUnavailable, "附件存储未初始化") return } eventID := strings.TrimSpace(chi.URLParam(r, "id")) if eventID == "" { Error(w, http.StatusBadRequest, "Missing event id") return } // 事件必须存在:否则会攒下一堆孤儿附件记录,而 ON DELETE CASCADE // 永远清不掉它们(没有对应的父行可删)。 if _, err := repo.GetCalendarEvent(r.Context(), eventID); err != nil { Error(w, http.StatusNotFound, "事件不存在") return } max := config.C.MaxAttachmentBytes // 与邮件附件同一套双层限制:外层卡整个请求体(含 multipart 边界), // blob.Put 的 max 卡单个文件内容。少了外层,超大 multipart 头能拖死内存。 r.Body = http.MaxBytesReader(w, r.Body, max+1<<20) if err := r.ParseMultipartForm(32 << 20); err != nil { Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)") return } defer func() { if r.MultipartForm != nil { r.MultipartForm.RemoveAll() } }() file, header, err := r.FormFile("file") if err != nil { Error(w, http.StatusBadRequest, "缺少 file 字段") return } defer file.Close() // 原来这里是 `data := make([]byte, header.Size); file.Read(data)` —— // 两处错:单次 Read 不保证填满缓冲(大文件必然短读,sha256 因此算的是 // 半截内容),而且**文件内容从未落盘**,只往库里写了一条元数据。 // 结果是附件"上传成功"、清单里看得见、发提醒时取不到任何字节。 sum, size, err := Blobs.Put(file, max) if errors.Is(err, blob.ErrTooLarge) { Error(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20))) return } if err != nil { Error(w, http.StatusInternalServerError, "保存附件失败") return } att := &models.CalendarAttachment{ EventID: eventID, Filename: sanitizeFilename(header.Filename), SizeBytes: size, SHA256: sum, } if err := repo.AddCalendarAttachment(r.Context(), att); err != nil { // 落盘成功但入库失败:孤立文件由 GC 回收,不影响正确性 Error(w, http.StatusInternalServerError, "登记附件失败") return } JSON(w, http.StatusCreated, att) } // GET /api/v1/calendar/events/{id}/attachments func ListCalendarAttachments(w http.ResponseWriter, r *http.Request) { eventID := chi.URLParam(r, "id") atts, err := repo.ListCalendarAttachments(r.Context(), eventID) if err != nil { Error(w, http.StatusInternalServerError, "Failed to list attachments") return } if atts == nil { atts = []models.CalendarAttachment{} } JSON(w, http.StatusOK, map[string]interface{}{"attachments": atts}) } // DELETE /api/v1/calendar/events/{id}/attachments/{aid} func DeleteCalendarAttachment(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } attID := strings.TrimSpace(chi.URLParam(r, "attachmentID")) if attID == "" { Error(w, http.StatusBadRequest, "Missing attachment id") return } // 原来这里返回 501 并让人「删整个事件来清附件」—— 那要求人为了撤掉 // 一个错传的文件把整条日程连提醒配置一起重建。 // // 磁盘上的 blob 不在这里删:内容寻址下同一个 sha256 可能被别的附件 // (甚至别的邮件)引用着,删文件会让那些引用一起坏掉。孤立 blob 归 GC。 ok, err := repo.DeleteCalendarAttachment(r.Context(), attID) if err != nil { Error(w, http.StatusInternalServerError, "删除附件失败") return } if !ok { Error(w, http.StatusNotFound, "附件不存在") return } JSON(w, http.StatusOK, map[string]any{"status": "deleted", "attachment_id": attID}) } // validRecurrence 白名单校验重复规则。 // // 必须白名单而不是「未知值当 none」:把 `lunar_montly`(拼错)静默当成 // 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。 func validRecurrence(r string) bool { switch r { case models.RecurNone, models.RecurDaily, models.RecurWeekly, models.RecurMonthly, models.RecurYearly, models.RecurLunarMonthly, models.RecurLunarYearly: return true } return false } // normalizeRecipients 清洗收件人列表:去空白、去重、校验地址可解析。 // // 返回第二个值非空表示有地址解析失败(值即那个地址),调用方回 400。 // 在建事件时校验而不是等触发:建事件时报错人能立刻改, // 触发时报错只会进 journalctl —— 人以为设好了,实际永远发不出去。 // // 去重是必要的:together 模式下同一个 Agent 既是主收件人又在抄送里, // 会让它收到两条一模一样的 SSE,插件可能因此起两轮。 func normalizeRecipients(in []string) ([]string, string) { seen := make(map[string]bool, len(in)) out := make([]string, 0, len(in)) for _, raw := range in { raw = strings.TrimSpace(raw) if raw == "" { continue } if _, err := models.ParseAddress(raw); err != nil { return nil, raw } if seen[raw] { continue } seen[raw] = true out = append(out, raw) } return out, "" } // ─── iCal 导入导出 ─── // GET /api/v1/calendar/export.ics func ExportCalendarICS(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } // 区间取自查询参数:前端导出的是「当前正在看的那段」, // 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件。 from := time.Now().AddDate(-1, 0, 0) to := time.Now().AddDate(1, 0, 0) if v := r.URL.Query().Get("from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { from = t } } if v := r.URL.Query().Get("to"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { to = t } } events, err := repo.ListCalendarEvents(r.Context(), from, to, "active") if err != nil { Error(w, http.StatusInternalServerError, "Failed to list events") return } var sb strings.Builder sb.WriteString("BEGIN:VCALENDAR\r\n") sb.WriteString("VERSION:2.0\r\n") sb.WriteString("PRODID:-//AgentMail//Calendar//EN\r\n") for _, e := range events { sb.WriteString("BEGIN:VEVENT\r\n") fmt.Fprintf(&sb, "UID:%s@agentmail\r\n", e.EventID) fmt.Fprintf(&sb, "DTSTAMP:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z")) fmt.Fprintf(&sb, "DTSTART:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z")) // 默认 1 小时持续时间 fmt.Fprintf(&sb, "DTEND:%s\r\n", e.EventTime.Add(time.Hour).UTC().Format("20060102T150405Z")) // 转义换行 summary := strings.ReplaceAll(e.Title, "\n", "\\n") fmt.Fprintf(&sb, "SUMMARY:%s\r\n", summary) if e.Description != "" { desc := strings.ReplaceAll(e.Description, "\n", "\\n") fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", desc) } if e.Recurrence != "none" { var freq string switch e.Recurrence { case "daily": freq = "DAILY" case "weekly": freq = "WEEKLY" case "monthly": freq = "MONTHLY" case "yearly": freq = "YEARLY" } if freq != "" { fmt.Fprintf(&sb, "RRULE:FREQ=%s\r\n", freq) } // 农历规则 RFC 5545 表达不了(RRULE 只有公历频率)。 // // 折中:用 X- 扩展属性记下真实规则,并把它降级成最接近的公历 // 近似(lunar_monthly → MONTHLY、lunar_yearly → YEARLY)。 // 别的客户端至少能看到一个大致对的重复;导回本系统时 // X- 属性会把精确规则还原。 // // 不写近似 RRULE 的后果更糟:外部客户端会把它当一次性事件, // 用户以为导出的日历里有「每年农历生日」,实际只有一条。 if models.IsLunarRecurrence(e.Recurrence) { fmt.Fprintf(&sb, "X-AGENTMAIL-RECURRENCE:%s\r\n", e.Recurrence) if e.Recurrence == models.RecurLunarMonthly { sb.WriteString("RRULE:FREQ=MONTHLY\r\n") } else { sb.WriteString("RRULE:FREQ=YEARLY\r\n") } } } // VALARM 的 TRIGGER 必须写成 `-PTM`。 // // 两个坑:iCal 的 duration 里 `M` **在 T 之前是月、在 T 之后才是分钟** —— // 原来写的 `-P15M` 在任何合规日历客户端里都是「提前 15 个月」。 // 而且原来用 maxInt(RemindBefore, 15) 兜底,把用户明确设的 // 「到点提醒」(0) 悄悄改成提前 15 分钟;导出不该修改语义。 // 收件人与投递模式同样没有标准字段可放。 // 不导出的后果:往返一圈后事件变成「没有收件人」,永远不会提醒。 if rs := e.EffectiveRecipients(); len(rs) > 0 { fmt.Fprintf(&sb, "X-AGENTMAIL-RECIPIENTS:%s\r\n", strings.Join(rs, ",")) fmt.Fprintf(&sb, "X-AGENTMAIL-DELIVERY:%s\r\n", e.EffectiveDeliveryMode()) } fmt.Fprintf(&sb, "BEGIN:VALARM\r\n") fmt.Fprintf(&sb, "TRIGGER:-PT%dM\r\n", e.RemindBefore) fmt.Fprintf(&sb, "ACTION:DISPLAY\r\n") fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", summary) fmt.Fprintf(&sb, "END:VALARM\r\n") sb.WriteString("END:VEVENT\r\n") } sb.WriteString("END:VCALENDAR\r\n") w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="agentmail-calendar.ics"`) w.Write([]byte(sb.String())) } // POST /api/v1/calendar/import.ics func ImportCalendarICS(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { Error(w, http.StatusUnauthorized, "not authenticated") return } // 两种上传形态都接受。 // // multipart 是浏览器 的天然形态;raw text/calendar 是 // 脚本与 Agent 的天然形态(curl --data-binary @x.ics)。只支持前者会让 // 命令行调用者收到含糊的「Missing file field」,只支持后者则要求前端 // 先把文件读成字符串再发 —— 两边各让一步不如两边都收。 var body []byte ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "multipart/") { if err := r.ParseMultipartForm(10 << 20); err != nil { Error(w, http.StatusBadRequest, "Failed to parse multipart: "+err.Error()) return } file, _, err := r.FormFile("file") if err != nil { Error(w, http.StatusBadRequest, "Missing file field") return } defer file.Close() body, err = io.ReadAll(file) if err != nil { Error(w, http.StatusBadRequest, "Failed to read file") return } } else { var err error body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20)) if err != nil { Error(w, http.StatusBadRequest, "Failed to read body") return } } if len(body) == 0 { Error(w, http.StatusBadRequest, "Empty .ics payload") return } events := parseICS(body) imported := 0 for _, e := range events { e.CreatedBy = user.Username e.Status = "active" if _, err := repo.CreateCalendarEvent(r.Context(), &e); err == nil { imported++ } } // skipped 单独给出而不是让前端自己减:insert 失败(撞名、约束冲突) // 与「解析出来但没入库」是同一回事,前端只关心「有几个没进来」。 JSON(w, http.StatusOK, map[string]interface{}{ "imported": imported, "skipped": len(events) - imported, "total": len(events), }) } // ─── iCal 解析 ─── func parseICS(data []byte) []models.CalendarEvent { var events []models.CalendarEvent var current *models.CalendarEvent lines := strings.Split(string(data), "\n") for _, raw := range lines { line := strings.TrimSpace(raw) if line == "" { continue } // 处理折叠行(iCal 的续行以空格开头) if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") { if current != nil && len(events) > 0 { // 简单续行处理:追加到最后一个字段 } continue } colon := strings.Index(line, ":") if colon < 0 { continue } key := line[:colon] value := line[colon+1:] // 去掉参数部分(如 DTSTART;TZID=...:value) if semi := strings.Index(key, ";"); semi >= 0 { key = key[:semi] } // 键名大小写不敏感(RFC 5545 §3.1)。X- 扩展属性尤其容易被 // 其他客户端改写大小写,不归一化会让往返丢掉农历规则。 key = strings.ToUpper(key) switch key { case "BEGIN": if value == "VEVENT" { current = &models.CalendarEvent{Recurrence: "none"} } case "END": if value == "VEVENT" && current != nil { if !current.EventTime.IsZero() { events = append(events, *current) } current = nil } case "SUMMARY": if current != nil { current.Title = strings.ReplaceAll(value, "\\n", "\n") } case "DESCRIPTION": if current != nil { current.Description = strings.ReplaceAll(value, "\\n", "\n") } case "DTSTART": if current != nil { if t, err := time.Parse("20060102T150405Z", value); err == nil { current.EventTime = t } else if t, err := time.ParseInLocation("20060102T150405", value, time.Local); err == nil { current.EventTime = t } else if t, err := time.Parse("20060102", value); err == nil { current.EventTime = t } } case "RRULE": // **不覆盖已经从 X-AGENTMAIL-RECURRENCE 读到的农历规则。** // // 导出时农历事件同时写了 X- 精确值与一条公历近似 RRULE // (给别的客户端看)。X- 出现在 RRULE 之前时, // 若这里无条件赋值就会把精确的 lunar_monthly 打回 monthly // —— 往返一圈农历规则悄悄退化成公历,用户要过一个月才发现 // 提醒日子不对。 if current != nil && !models.IsLunarRecurrence(current.Recurrence) { v := strings.ToUpper(value) switch { case strings.Contains(v, "FREQ=DAILY"): current.Recurrence = models.RecurDaily case strings.Contains(v, "FREQ=WEEKLY"): current.Recurrence = models.RecurWeekly case strings.Contains(v, "FREQ=MONTHLY"): current.Recurrence = models.RecurMonthly case strings.Contains(v, "FREQ=YEARLY"): current.Recurrence = models.RecurYearly } } case "TRIGGER": if current != nil { if mins, ok := parseTriggerMinutes(value); ok { current.RemindBefore = mins } } case "X-AGENTMAIL-RECURRENCE": // 精确规则覆盖上面从 RRULE 猜出来的近似值。 // 顺序无关:X- 属性只在值合法时才生效。 if current != nil && validRecurrence(value) { current.Recurrence = value } case "X-AGENTMAIL-RECIPIENTS": if current != nil { list, bad := normalizeRecipients(strings.Split(value, ",")) // 单个地址坏掉不该让整份导入失败:其余收件人仍有效。 // 全坏时 list 为空,事件会在 Create 时被收件人校验拦下。 if bad == "" { current.Recipients = list } } case "X-AGENTMAIL-DELIVERY": if current != nil && (value == models.DeliverSeparate || value == models.DeliverTogether) { current.DeliveryMode = value } } } return events } // ─── 辅助 ─── func blobSha256(data []byte) string { sum := sha256.Sum256(data) return fmt.Sprintf("%x", sum[:]) } // parseTriggerMinutes 把 VALARM 的 TRIGGER duration 解析成「提前多少分钟」。 // // 接受 `-PT30M` / `-PT1H` / `-PT1H30M` / `-P1D` / `-P1DT2H` 这些形态。 // 关键规则:`M` 出现在 `T` **之后**才是分钟,之前是月 —— 按月的 trigger // 无法映射到 remind_before(那是个分钟数),直接忽略比乱换算好。 // // 正号(事件之后提醒)也忽略:remind_before 语义上只能提前。 // 返回 ok=false 表示「这条 TRIGGER 用不上」,调用方保持原值不动。 func parseTriggerMinutes(v string) (int, bool) { v = strings.TrimSpace(strings.ToUpper(v)) if !strings.HasPrefix(v, "-P") { return 0, false } rest := v[2:] // 切成 T 前后两段:前面是日期部分(Y/M/W/D),后面是时间部分(H/M/S) datePart, timePart := rest, "" if i := strings.Index(rest, "T"); i >= 0 { datePart, timePart = rest[:i], rest[i+1:] } total := 0 // 日期部分只认 W/D。Y/M 是可变长度(月有 28~31 天),换算成分钟只能靠猜。 if n, ok := durationField(datePart, 'W'); ok { total += n * 7 * 24 * 60 } if n, ok := durationField(datePart, 'D'); ok { total += n * 24 * 60 } if n, ok := durationField(timePart, 'H'); ok { total += n * 60 } if n, ok := durationField(timePart, 'M'); ok { total += n } // 秒不进 remind_before:它的粒度是分钟,30 秒会被截成 0 而看不出区别 if total <= 0 { return 0, false } return total, true } // durationField 从 `1H30M` 这样的串里取出紧接在 unit 之前的整数。 func durationField(s string, unit byte) (int, bool) { idx := strings.IndexByte(s, unit) if idx < 0 { return 0, false } start := idx for start > 0 && s[start-1] >= '0' && s[start-1] <= '9' { start-- } if start == idx { return 0, false } n := 0 for i := start; i < idx; i++ { n = n*10 + int(s[i]-'0') } return n, true }