package repo import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/lunar" "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) var ErrEventNotFound = errors.New("calendar event not found") // calendarCols 是所有 SELECT 共用的列清单。 // // 抽出来是因为原先有**四处**手抄同一串列名(Get / List / DueEvents 各一处), // 而 Scan 的参数顺序必须与之逐一对应。加一列时漏改任何一处都不会编译报错 —— // 只会在运行时得到 "Scan: expected N destination arguments" 或者更糟: // 列数恰好相同而值错位(曾在 ListSessionsFor 上真的发生过, // 加了预算两列没加进 Scan,整个联系人栏 500)。 const calendarCols = `event_id, title, description, reminder_text, agent_name, to_address, recipients, delivery_mode, event_time, remind_before, recurrence, recurrence_end, status, last_fired_at, fired_for, permission_mode, created_at, updated_at, created_by` // rowScanner 让 QueryRow 与 Rows 共用同一个 scan 实现。 type rowScanner interface { Scan(dest ...any) error } // scanCalendarEvent 按 calendarCols 的顺序读一行。 // // recipients 存的是 JSON 文本,必须先读进 []byte 再 Unmarshal —— // 直接 Scan 进 []string 会静默失败(driver 不知道怎么转)。 func scanCalendarEvent(sc rowScanner) (*models.CalendarEvent, error) { var e models.CalendarEvent var recipientsJSON []byte if err := sc.Scan( &e.EventID, &e.Title, &e.Description, &e.ReminderText, &e.AgentName, &e.ToAddress, &recipientsJSON, &e.DeliveryMode, &e.EventTime, &e.RemindBefore, &e.Recurrence, &e.RecurrenceEnd, &e.Status, &e.LastFiredAt, &e.FiredFor, &e.PermissionMode, &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy, ); err != nil { return nil, err } if len(recipientsJSON) > 0 { // 解析失败不算致命:退回 to_address/agent_name 兜底链, // 事件仍能投递。让一条脏 JSON 把整个列表打成 500 更糟。 _ = json.Unmarshal(recipientsJSON, &e.Recipients) } if e.Recipients == nil { // Go 的 nil slice 序列化成 null,前端 .map 会崩 e.Recipients = []string{} } return &e, nil } // marshalRecipients 把收件人列表序列化成入库的 JSON 文本。 func marshalRecipients(list []string) string { if list == nil { list = []string{} } b, err := json.Marshal(list) if err != nil { return "[]" } return string(b) } // ─── CRUD ─── func CreateCalendarEvent(ctx context.Context, e *models.CalendarEvent) (*models.CalendarEvent, error) { e.EventID = uuid.New().String() e.CreatedAt = time.Now() e.UpdatedAt = e.CreatedAt if e.Status == "" { e.Status = "active" } if e.Recurrence == "" { e.Recurrence = "none" } if e.DeliveryMode == "" { e.DeliveryMode = models.DeliverSeparate } // 档位合法化:脏值 fail-closed 到默认档,不透传成库里的非法值 //(否则后续读路径会拿到一个 ModeNeedsHuman 判定不了的值)。 e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode) if e.Recipients == nil { e.Recipients = []string{} } _, err := db.DB.ExecContext(ctx, ` INSERT INTO calendar_events (event_id, title, description, reminder_text, agent_name, to_address, recipients, delivery_mode, event_time, remind_before, recurrence, recurrence_end, status, permission_mode, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.EventID, e.Title, e.Description, e.ReminderText, e.AgentName, e.ToAddress, marshalRecipients(e.Recipients), e.DeliveryMode, e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd, e.Status, e.PermissionMode, e.CreatedBy, e.CreatedAt, e.UpdatedAt, ) if err != nil { return nil, err } return e, nil } func GetCalendarEvent(ctx context.Context, eventID string) (*models.CalendarEvent, error) { e, err := scanCalendarEvent(db.DB.QueryRowContext(ctx, `SELECT `+calendarCols+` FROM calendar_events WHERE event_id = ?`, eventID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrEventNotFound } return nil, err } return e, nil } func UpdateCalendarEvent(ctx context.Context, eventID string, e *models.CalendarEvent) error { e.UpdatedAt = time.Now() // 档位同样在 update 路径上规范化 e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode) result, err := db.DB.ExecContext(ctx, ` UPDATE calendar_events SET title = ?, description = ?, reminder_text = ?, agent_name = ?, to_address = ?, recipients = ?, delivery_mode = ?, event_time = ?, remind_before = ?, recurrence = ?, recurrence_end = ?, status = ?, permission_mode = ?, updated_at = ? WHERE event_id = ?`, e.Title, e.Description, e.ReminderText, e.AgentName, e.ToAddress, marshalRecipients(e.Recipients), e.EffectiveDeliveryMode(), e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd, e.Status, e.PermissionMode, e.UpdatedAt, eventID, ) if err != nil { return err } n, _ := result.RowsAffected() if n == 0 { return ErrEventNotFound } return nil } func DeleteCalendarEvent(ctx context.Context, eventID string) error { result, err := db.DB.ExecContext(ctx, `DELETE FROM calendar_events WHERE event_id = ?`, eventID) if err != nil { return err } n, _ := result.RowsAffected() if n == 0 { return ErrEventNotFound } return nil } // ─── 查询 ─── // ListCalendarEvents 返回指定时间范围内的事件(日历视图)。 func ListCalendarEvents(ctx context.Context, from, to time.Time, status string) ([]models.CalendarEvent, error) { if status == "" { status = "active" } rows, err := db.DB.QueryContext(ctx, ` SELECT `+calendarCols+` FROM calendar_events WHERE event_time >= ? AND event_time <= ? AND (status = ? OR ? = '') ORDER BY event_time ASC`, from, to, status, status) if err != nil { return nil, err } defer rows.Close() var events []models.CalendarEvent for rows.Next() { e, err := scanCalendarEvent(rows) if err != nil { return nil, err } events = append(events, *e) } return events, rows.Err() } // ─── 调度器 ─── // DueEvents 返回下一分钟内需要触发的事件。 // // 调度器每分钟调用一次:event_time + remind_before <= now+60s 且尚未触发(last_fired_at 为 NULL // 或小于 event_time)的 active 事件。 // DueEvents 取出该触发的事件。 // // 60 秒 lookahead 让提醒宁早不晚:调度周期是 30 秒,不提前看的话 // 一个刚好落在两个 tick 之间的提醒会迟到最多 30 秒。 // // **去重判据是 fired_for(已触发的 occurrence)与 event_time 相等**, // 不是 last_fired_at 与 event_time 比大小 —— 后者在 lookahead 窗口内 // 恒为真(触发时刻早于 event_time),会让同一条提醒每个 tick 重发一次。 func DueEvents(ctx context.Context) ([]models.CalendarEvent, error) { now := time.Now() deadline := now.Add(60 * time.Second) rows, err := db.DB.QueryContext(ctx, ` SELECT `+calendarCols+` FROM calendar_events WHERE status = 'active' AND datetime(event_time, '-' || remind_before || ' minutes') <= ? AND (fired_for IS NULL OR fired_for <> event_time) ORDER BY event_time ASC`, deadline) if err != nil { return nil, err } defer rows.Close() var events []models.CalendarEvent for rows.Next() { e, err := scanCalendarEvent(rows) if err != nil { return nil, err } events = append(events, *e) } return events, rows.Err() } // MarkEventFired 标记事件已触发,防止重复。 func MarkEventFired(ctx context.Context, eventID string) error { // fired_for 直接从 event_time 列复制而不是在 Go 侧格式化再写回: // 两者必须逐字节相同(判据是字符串相等),经过一轮 time.Time 往返 // 有可能改变表示形式。 _, err := db.DB.ExecContext(ctx, `UPDATE calendar_events SET last_fired_at = ?, fired_for = event_time WHERE event_id = ?`, time.Now(), eventID) return err } // AdvanceRecurrence 为重复事件计算下一次触发时间。 // // 返回 false 表示重复已过期(recurrence_end 已过),事件应置为 cancelled。 func AdvanceRecurrence(ctx context.Context, eventID string) (bool, error) { var recurrence string var eventTime time.Time var recurrenceEnd *time.Time err := db.DB.QueryRowContext(ctx, ` SELECT recurrence, event_time, recurrence_end FROM calendar_events WHERE event_id = ?`, eventID).Scan( &recurrence, &eventTime, &recurrenceEnd) if err != nil { return false, err } if recurrence == models.RecurNone { return false, nil } // 农历按用户看到的**本地公历日**换算,不按数据库返回值的 Location 换算。 // // SQLite DSN 用 _timezone=UTC 保证时间比较统一,因此从库里 Scan 出来的 // eventTime 是 UTC。对普通重复规则这只是同一时刻的另一种表示;但农历换算 // 会直接读取 Year/Month/Day:本地 09-12 07:00 入库后是 UTC 09-11 23:00, // 若不转回本地,农历日会从廿一变成二十。凌晨 0–8 点创建的农历提醒都会 // 永久偏一天,而且只有等到下次提醒时才暴露。 if recurrence == models.RecurLunarMonthly || recurrence == models.RecurLunarYearly { eventTime = eventTime.In(time.Local) } // **一路推到未来**,不是只推一步。 // // 只推一步的后果(实测):一条 100 天前设的每日事件,每轮扫描都判定 // 「已过期该触发」→ 发一封 → event_time 只前进一天 → 下一轮又过期。 // 30 轮扫描触发 30 次,而调度周期是 30 秒 —— 人会收到一串垃圾提醒, // 连发 100 封才追上今天。 // // 跳过的那些 occurrence **不补发**:定时提醒的价值在于「按时」, // 三个月前那次站会提醒现在发出去毫无意义,只会淹掉真正该看的那封。 // 本轮仍会发一封(fireEvent 已经在发了),代表「这条规则还活着」。 next, err := advanceToFuture(recurrence, eventTime, time.Now(), recurrenceEnd) if err != nil { // 推不出下一次(例如「每年农历闰六月」而目标年无闰六月): // 置为 cancelled 而不是留在 active 空转。留着会让调度器每 30 秒 // 重试同一个算不出来的规则,日志里刷同一条错误直到有人发现。 _, cErr := db.DB.ExecContext(ctx, `UPDATE calendar_events SET status = 'cancelled' WHERE event_id = ?`, eventID) if cErr != nil { return false, cErr } return false, err } // 零值 = 已越过 recurrence_end("none" 在函数开头就返回了,到不了这里)。 // **必须置 cancelled**:留在 active 会让 DueEvents 每轮都捞到这条 // 早已过期的事件,而 fired_for 已经等于 event_time 所以它又不会被触发 —— // 表现是一条永远排在到期列表里、永远不动的僵尸事件。 if next.IsZero() { _, cErr := db.DB.ExecContext(ctx, `UPDATE calendar_events SET status = 'cancelled', updated_at = ? WHERE event_id = ?`, time.Now(), eventID) return false, cErr } // 走到这里说明 next 既在未来又在终止时间之内。 _, err = db.DB.ExecContext(ctx, `UPDATE calendar_events SET event_time = ?, updated_at = ? WHERE event_id = ?`, next, time.Now(), eventID) return true, err } // advanceToFuture 从 from 起反复按规则推进,直到越过 now。 // // 单独成不碰数据库的函数是为了可测。三个终止条件,缺一不可: // // 1. 越过 now —— 正常出口 // 2. 越过 recurrenceEnd —— 返回零值,调用方据此置 cancelled // 3. maxAdvanceSteps 上限 —— 防御性的。规则算得出但不前进(理论上 // NextOccurrence 不会返回 <= 当前值,但农历那条路径依赖外部库, // 一旦它某年给出反直觉结果,没有上限就是个死循环 goroutine, // 而它跑在调度器里 —— 整个提醒系统会一起卡住) // // 上限取 4000:按每日重复算约 11 年,足够覆盖「很久以前设的提醒」, // 而 4000 次纯内存日期运算在一个 tick 里跑完毫无压力。 const maxAdvanceSteps = 4000 func advanceToFuture(recurrence string, from, now time.Time, recurrenceEnd *time.Time) (time.Time, error) { cur := from for i := 0; i < maxAdvanceSteps; i++ { next, err := NextOccurrence(recurrence, cur) if err != nil { return time.Time{}, err } if next.IsZero() { return time.Time{}, nil // 不重复 } if !next.After(cur) { // 规则不前进 —— 与死循环等价,当作算不出来 return time.Time{}, fmt.Errorf("重复规则 %q 未能前进(停在 %s)", recurrence, cur.Format(time.RFC3339)) } cur = next if recurrenceEnd != nil && cur.After(*recurrenceEnd) { return time.Time{}, nil // 已过终止时间 } if cur.After(now) { return cur, nil } } return time.Time{}, fmt.Errorf("重复规则 %q 推进 %d 次仍未越过当前时刻", recurrence, maxAdvanceSteps) } // NextOccurrence 按重复规则算出下一次触发时刻。 // // 独立成不碰数据库的纯函数是为了可测:农历推进错了不会报错, // 只会让提醒发在错误的日子,而那种错误要等真的过了一个月才看得见。 // // 返回零值 time 且 err == nil 表示「不重复」(规则是 none 或未知值)。 // // **农历规则不能用 AddDate 近似**:农历月 29~30 天不定、农历年 353~385 天 // (闰年多一整月)。用固定天数推进一年能偏半个月 —— 农历生日提醒会 // 逐年漂移到完全不相干的日子上。 func NextOccurrence(recurrence string, from time.Time) (time.Time, error) { switch recurrence { case models.RecurDaily: return from.AddDate(0, 0, 1), nil case models.RecurWeekly: return from.AddDate(0, 0, 7), nil case models.RecurMonthly: // 公历每月:AddDate 在月末会溢出(1 月 31 日 +1 月 = 3 月 3 日)。 // 夹到目标月的最后一天 —— 与农历那边的 clamp 语义一致: // 「每月 31 日」的意思是「月末」,滚到下月初是错的。 return addSolarMonthClamped(from, 1), nil case models.RecurYearly: // 公历每年:2 月 29 日在平年会溢出成 3 月 1 日,同样要夹。 // 闰日生日的约定是「平年过 2 月 28」,不是 3 月 1 日。 return addSolarMonthClamped(from, 12), nil case models.RecurLunarMonthly: d := lunar.FromSolar(from).AddMonths(1) t, _, err := d.ToSolar(from.Location(), from.Hour(), from.Minute(), from.Second(), from.Nanosecond()) return t, err case models.RecurLunarYearly: d := lunar.FromSolar(from).AddYears(1) t, _, err := d.ToSolar(from.Location(), from.Hour(), from.Minute(), from.Second(), from.Nanosecond()) return t, err default: return time.Time{}, nil } } // addSolarMonthClamped 在公历上加月份,日期夹到目标月的实际天数内。 // // time.AddDate 的溢出行为(3 月 31 日 +1 月 = 5 月 1 日)对「每月同一日」 // 的提醒是错的:31 日的事件会在 2 月变成 3 月 3 日,然后从此每月 3 日提醒 // —— 一次溢出永久改变了规则。 func addSolarMonthClamped(t time.Time, n int) time.Time { y, m, d := t.Date() m += time.Month(n) for m > 12 { m -= 12 y++ } // 目标月第 0 天 = 上个月最后一天,用它拿到月长 last := time.Date(y, m+1, 0, 0, 0, 0, 0, t.Location()).Day() if d > last { d = last } return time.Date(y, m, d, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()) } // ─── 附件 ─── func AddCalendarAttachment(ctx context.Context, a *models.CalendarAttachment) error { a.AttachmentID = uuid.New().String() a.CreatedAt = time.Now() _, err := db.DB.ExecContext(ctx, ` INSERT INTO calendar_attachments (attachment_id, event_id, filename, sha256, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?)`, a.AttachmentID, a.EventID, a.Filename, a.SHA256, a.SizeBytes, a.CreatedAt) return err } func ListCalendarAttachments(ctx context.Context, eventID string) ([]models.CalendarAttachment, error) { rows, err := db.DB.QueryContext(ctx, ` SELECT attachment_id, event_id, filename, sha256, size_bytes, created_at FROM calendar_attachments WHERE event_id = ? ORDER BY created_at`, eventID) if err != nil { return nil, err } defer rows.Close() var atts []models.CalendarAttachment for rows.Next() { var a models.CalendarAttachment if err := rows.Scan(&a.AttachmentID, &a.EventID, &a.Filename, &a.SHA256, &a.SizeBytes, &a.CreatedAt); err != nil { return nil, err } atts = append(atts, a) } return atts, rows.Err() } func DeleteCalendarAttachments(ctx context.Context, eventID string) error { _, err := db.DB.ExecContext(ctx, `DELETE FROM calendar_attachments WHERE event_id = ?`, eventID) return err } // DeleteCalendarAttachment 删单条附件。 // // 返回 false 表示这条不存在(而不是报错):调用方据此回 404 而非 500。 // 只删元数据,磁盘 blob 留给 GC —— 内容寻址下同一个 sha256 可能被别的 // 附件引用着,跟着删会让那些引用一起坏掉。 func DeleteCalendarAttachment(ctx context.Context, attachmentID string) (bool, error) { res, err := db.DB.ExecContext(ctx, `DELETE FROM calendar_attachments WHERE attachment_id = ?`, attachmentID) if err != nil { return false, err } n, err := res.RowsAffected() if err != nil { return false, err } return n > 0, nil } // AttachCalendarFilesToMail 把事件的附件复制成邮件附件。 // // 提醒邮件是新建的,附件必须重新挂一份指向同一 sha256 的元数据 —— // 内容寻址下这不拷磁盘文件,只是多一条记录。 // // 缺了这一步的后果:人在事件上传了附件、UI 里看得见、提醒也按时发出, // 但 Agent 收到的那封信里附件清单是空的 —— 事件附件与邮件附件是两张表, // 不复制就永远只存在于日历侧。这是「日历附件只记元数据未接投递」的另一半。 // // uploader 记为 calendarSender("calendar"):附件随提醒邮件重新分发, // 其可见范围由该邮件的参与方决定,而不是沿用事件创建者。 func AttachCalendarFilesToMail(ctx context.Context, eventID string, mailID uuid.UUID, uploader string) (int, error) { atts, err := ListCalendarAttachments(ctx, eventID) if err != nil { return 0, err } n := 0 for _, a := range atts { // sha256 为空说明这条记录没有真实内容(历史脏数据),跳过而不是 // 挂一个下载必然 404 的附件 if a.SHA256 == "" { continue } if _, err := db.DB.ExecContext(ctx, ` INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256) VALUES ($1, $2, $3, $4, $5, $6) `, mailID, uploader, a.Filename, "application/octet-stream", a.SizeBytes, a.SHA256); err != nil { return n, err } n++ } return n, nil } // ListCalendarEventsCreatedBy 只返回某个创建者建的事件。 // // Agent 侧列表用这个而不是 ListCalendarEvents:Agent 不该看到别人(人类或 // 其他 Agent)的日程 —— 那里可能有它无权知道的会议、地址、附件名。 // // 注意**不是**「发给我的事件」:`recipients` 里有我但我没建的,同样不返回。 // 理由是那些事件的编辑权不属于我,列出来只会让模型试图改它然后拿到 403。 // 想知道「谁给我设了提醒」,那条信息在提醒邮件本身里。 func ListCalendarEventsCreatedBy(ctx context.Context, creator string, from, to time.Time, status string) ([]models.CalendarEvent, error) { rows, err := db.DB.QueryContext(ctx, ` SELECT `+calendarCols+` FROM calendar_events WHERE created_by = ? AND event_time >= ? AND event_time <= ? AND (status = ? OR ? = '') ORDER BY event_time ASC`, creator, from, to, status, status) if err != nil { return nil, err } defer rows.Close() events := []models.CalendarEvent{} for rows.Next() { e, err := scanCalendarEvent(rows) if err != nil { return nil, err } events = append(events, *e) } return events, rows.Err() } // CountActiveEventsBy 数某个创建者当前有多少条生效中的事件。 // // 给 Agent 侧的总量上限用。速率限制只压住「短时间内暴建」, // 压不住「每小时建 19 条、连建一周」—— 而日历事件是长效的, // 攒下来的每一条都会持续产生提醒邮件。 func CountActiveEventsBy(ctx context.Context, creator string) (int, error) { var n int err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM calendar_events WHERE created_by = ? AND status = 'active'`, creator).Scan(&n) return n, err }