feat(calendar): 日历后端 —— 事件/提醒/重复规则 + iCal + 多收件人

三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`from_name = "calendar"` 刻意既非人类名也非 Agent 名 —— 用创建者的名字
会让 Agent 以为人在实时找它,而人此刻可能在睡觉,模型据此判断
「要不要马上追问」,来源写错会让它问一个不在线的人。

日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」,
而提醒是人预先设定的定时任务,不是模型的自主行为。

**多收件人两种投递模式,都要**:
- separate(默认)= 各发一封、落各自会话、互相看不到
- together        = 首个为主收件人、其余进 cc_list、共享一条线索

「让三个 Agent 各自独立汇报」与「让 pi 主办、dsh 知情」是完全不同的任务
形态。默认 separate 因为失败模式更轻:together 用错会让本该独立判断的
Agent 互相看到回复而趋同,那种上下文污染事后无法分离。

抄送方也推 SSE。漏了这步的后果很隐蔽:cc_list 里有他们、查收件箱看得见,
但没有任何事件推给他们 —— 插件不会唤起会话,Agent 到下次补拉才发现。

recipients 存**原始地址串**而非结构化:session 位的 new/别名三态该在
触发那一刻解析,存结构化会让「.new」这种一次性语义在建事件时就被固化,
而重复事件每次触发都该重新决定落到哪条会话。

---

修掉的七个真问题:

**1. 默认提醒模板把时间烤成字面值。**
原来 Sprintf 出含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,模板不动 —— 「每天 9 点」的提醒
从第二天起永远写着第一天的日期,且不报任何错。改为存变量形式。

**2. `{time}` 渲染成 UTC。**
DSN 带 _timezone=UTC,读回的 EventTime 是 UTC。直接 Format 会把人在
+0800 输入的 14:30 写成 06:30,而前端预览用本地时间 —— 两边差 8 小时
且都不报错。

**3. 同一提醒每 tick 重发一次(生产实测 4 封)。**
DueEvents 有 60 秒 lookahead(周期 30 秒,不提前看会迟到)。去重判据
原本是 `last_fired_at < event_time` —— 触发时刻本来就早于落在窗口内的
event_time,条件恒真。实测一条 12:53:17 的事件在 12:52:30 / 12:53:00 /
12:53:06 / 12:53:36 各发一封。新增 fired_for 列记录**已触发的
occurrence**,判据改为 `fired_for <> event_time`。

**4. 过期重复事件刷屏。**
AdvanceRecurrence 只推一步:一条 100 天前设的每日事件每轮都判定过期 →
发一封 → 只前进一天 → 下轮又过期。实测 30 轮触发 30 次,而周期是
30 秒。改成 advanceToFuture 一路推到越过当前时刻;跳过的 occurrence
不补发(三个月前那次站会提醒现在发出去毫无意义,只会淹掉该看的那封)。
带 maxAdvanceSteps=4000 上限:农历路径依赖外部库,没有上限就是个死循环
goroutine,而它跑在调度器里 —— 整个提醒系统会一起卡住。

**5. 越过 recurrence_end 不置 cancelled。**
留在 active 会变僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
但 fired_for 已等于 event_time 所以又不触发。

**6. 附件从未落盘。**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 算的是半截内容),而且文件内容
压根没写进 blob 存储。结果是附件「上传成功」、清单里看得见、
发提醒时取不到任何字节。改走 Blobs.Put。

**7. iCal TRIGGER 往返是断的。**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。更糟的是
`-P15M` 在任何合规客户端里都是「提前 **15 个月**」—— iCal duration 的 M
在 T 之前是月、之后才是分钟。而且用 maxInt(RemindBefore,15) 兜底,把用户
明确设的「到点提醒」(0) 悄悄改成提前 15 分钟。

---

其他修正:
- **PG schema 整块缺失日历两张表** —— DATABASE_URL 非空时所有 /calendar/*
  在 relation does not exist 上 500,而 SQLite 下一切正常,问题只在切外部库
  时才暴露
- DeleteCalendarAttachment 曾返回 501,让人「删整个事件来清附件」
- 导出忽略 from/to;导入只接受 multipart(命令行调用者收到含糊的
  「Missing file field」)
- 上传附件不校验事件存在 —— 会攒孤儿记录,而 ON DELETE CASCADE 清不掉
  它们(SQLite 的 foreign_keys 默认关)
- 农历规则 RRULE 表达不了,走 X-AGENTMAIL-RECURRENCE 扩展属性 + 公历近似
  兜底。**RRULE 分支不能覆盖已读到的农历值** —— X- 出现在 RRULE 之前时
  无条件赋值会把 lunar_monthly 打回 monthly,往返一圈农历规则悄悄退化
- 抽出 calendarCols 常量:原先四处手抄同一串列名,加一列漏改任何一处
  不会编译报错,只会运行时列错位(ListSessionsFor 上真的发生过)

测试:repo 20+ 例(到期判定/幂等/lookahead 不重发/过期不刷屏/农历推进/
多收件人/兜底链)、handler 20 例 iCal、scheduler 7 例模板渲染。
生产端到端验证并清理了数据。
This commit is contained in:
2026-09-04 06:28:03 +08:00
parent 4d211c84d6
commit 069bf03ae2
10 changed files with 3495 additions and 0 deletions

View File

@ -0,0 +1,534 @@
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, 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.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
}
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, 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.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()
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 = ?, 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.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
}
// **一路推到未来**,不是只推一步。
//
// 只推一步的后果(实测):一条 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
}

View File

@ -0,0 +1,972 @@
package repo
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/lunar"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
func seedEvent(t *testing.T, e *models.CalendarEvent) *models.CalendarEvent {
t.Helper()
if e.Title == "" {
e.Title = "测试事件"
}
if e.EventTime.IsZero() {
e.EventTime = time.Now().Add(time.Hour)
}
out, err := CreateCalendarEvent(context.Background(), e)
if err != nil {
t.Fatalf("建事件: %v", err)
}
return out
}
func TestCalendarEventCRUD(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
at := time.Now().Add(2 * time.Hour).Truncate(time.Second)
e := seedEvent(t, &models.CalendarEvent{
Title: "每日站会",
Description: "同步进展",
ReminderText: "日程提醒:{title}",
AgentName: "dsh",
ToAddress: "dsh@/home",
EventTime: at,
RemindBefore: 15,
Recurrence: "daily",
CreatedBy: "jianf",
})
if e.EventID == "" {
t.Fatal("建完事件必须有 event_id")
}
if e.Status != "active" {
t.Errorf("新事件默认应为 active,得到 %q", e.Status)
}
got, err := GetCalendarEvent(ctx, e.EventID)
if err != nil {
t.Fatalf("读事件: %v", err)
}
if got.Title != "每日站会" || got.RemindBefore != 15 || got.Recurrence != "daily" {
t.Errorf("读回的字段不符:%+v", got)
}
if !got.EventTime.Equal(at) {
t.Errorf("event_time 读回错位:写 %v 读 %v", at, got.EventTime)
}
got.Title = "改名后的站会"
got.Status = "paused"
if err := UpdateCalendarEvent(ctx, e.EventID, got); err != nil {
t.Fatalf("改事件: %v", err)
}
again, _ := GetCalendarEvent(ctx, e.EventID)
if again.Title != "改名后的站会" || again.Status != "paused" {
t.Errorf("改后没生效:%+v", again)
}
if err := DeleteCalendarEvent(ctx, e.EventID); err != nil {
t.Fatalf("删事件: %v", err)
}
if _, err := GetCalendarEvent(ctx, e.EventID); err != ErrEventNotFound {
t.Errorf("删掉后应报 ErrEventNotFound,得到 %v", err)
}
}
func TestCalendarNotFoundIsTyped(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 不存在的 id 要给出可判定的错误,而不是 sql.ErrNoRows ——
// handler 靠它区分 404 与 500。
if _, err := GetCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000"); err != ErrEventNotFound {
t.Errorf("Get 应报 ErrEventNotFound,得到 %v", err)
}
if err := DeleteCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000"); err != ErrEventNotFound {
t.Errorf("Delete 应报 ErrEventNotFound,得到 %v", err)
}
if err := UpdateCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000",
&models.CalendarEvent{Title: "x", EventTime: time.Now()}); err != ErrEventNotFound {
t.Errorf("Update 应报 ErrEventNotFound,得到 %v", err)
}
}
func TestDueEventsOnlyReturnsRipe(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
now := time.Now()
// 已经该响的(事件时间在过去)
ripe := seedEvent(t, &models.CalendarEvent{Title: "该响了", EventTime: now.Add(-time.Minute)})
// 提前 30 分钟提醒、事件在 20 分钟后 —— 提醒点已过
early := seedEvent(t, &models.CalendarEvent{
Title: "提前提醒已到", EventTime: now.Add(20 * time.Minute), RemindBefore: 30,
})
// 还早(1 小时后,无提前提醒)
future := seedEvent(t, &models.CalendarEvent{Title: "还早", EventTime: now.Add(time.Hour)})
// 已暂停的不该响
paused := seedEvent(t, &models.CalendarEvent{Title: "暂停的", EventTime: now.Add(-time.Minute)})
p, _ := GetCalendarEvent(ctx, paused.EventID)
p.Status = "paused"
if err := UpdateCalendarEvent(ctx, paused.EventID, p); err != nil {
t.Fatalf("暂停: %v", err)
}
due, err := DueEvents(ctx)
if err != nil {
t.Fatalf("DueEvents: %v", err)
}
got := map[string]bool{}
for _, e := range due {
got[e.EventID] = true
}
if !got[ripe.EventID] {
t.Error("到期事件没被取出")
}
if !got[early.EventID] {
t.Error("remind_before 已过的事件没被取出")
}
if got[future.EventID] {
t.Error("未到期事件被取出了")
}
if got[paused.EventID] {
t.Error("已暂停的事件被取出了")
}
}
func TestMarkEventFiredStopsRefiring(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 幂等的关键:标记后同一条不该再出现在 DueEvents 里,
// 否则调度器每 30 秒把同一封提醒重发一遍。
e := seedEvent(t, &models.CalendarEvent{Title: "只该响一次", EventTime: time.Now().Add(-time.Minute)})
due, _ := DueEvents(ctx)
if len(due) != 1 {
t.Fatalf("标记前应有 1 条到期,得到 %d", len(due))
}
if err := MarkEventFired(ctx, e.EventID); err != nil {
t.Fatalf("标记: %v", err)
}
due, _ = DueEvents(ctx)
for _, d := range due {
if d.EventID == e.EventID {
t.Error("已标记触发的事件仍出现在 DueEvents 里")
}
}
}
func TestAdvanceRecurrence(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
base := time.Now().Add(-time.Minute).Truncate(time.Second)
t.Run("一次性事件不推进", func(t *testing.T) {
e := seedEvent(t, &models.CalendarEvent{Title: "一次性", EventTime: base, Recurrence: "none"})
advanced, err := AdvanceRecurrence(ctx, e.EventID)
if err != nil {
t.Fatalf("推进: %v", err)
}
if advanced {
t.Error("recurrence=none 不该推进")
}
})
for _, tc := range []struct {
rule string
want time.Time
}{
{"daily", base.AddDate(0, 0, 1)},
{"weekly", base.AddDate(0, 0, 7)},
{"monthly", base.AddDate(0, 1, 0)},
} {
t.Run(tc.rule+" 推进一个周期", func(t *testing.T) {
e := seedEvent(t, &models.CalendarEvent{
Title: tc.rule, EventTime: base, Recurrence: tc.rule,
})
advanced, err := AdvanceRecurrence(ctx, e.EventID)
if err != nil {
t.Fatalf("推进: %v", err)
}
if !advanced {
t.Fatal("应该推进")
}
got, _ := GetCalendarEvent(ctx, e.EventID)
if !got.EventTime.Equal(tc.want) {
t.Errorf("下次时间应为 %v,得到 %v", tc.want, got.EventTime)
}
// 推进后 event_time 已在未来,且 last_fired_at 仍为旧值 →
// 必须重新出现在 DueEvents 里等待下一轮(否则重复事件只响一次)。
if got.Status != "active" {
t.Errorf("推进后应仍为 active,得到 %q", got.Status)
}
})
}
t.Run("超过 recurrence_end 则取消", func(t *testing.T) {
end := base.Add(12 * time.Hour) // 下一次(+1 天)会越过它
e := seedEvent(t, &models.CalendarEvent{
Title: "快结束了", EventTime: base, Recurrence: "daily", RecurrenceEnd: &end,
})
advanced, err := AdvanceRecurrence(ctx, e.EventID)
if err != nil {
t.Fatalf("推进: %v", err)
}
if advanced {
t.Error("越过 recurrence_end 时不该报告推进成功")
}
got, _ := GetCalendarEvent(ctx, e.EventID)
if got.Status != "cancelled" {
t.Errorf("越过结束时间应置为 cancelled,得到 %q", got.Status)
}
})
}
func TestListCalendarEventsRange(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
now := time.Now()
inRange := seedEvent(t, &models.CalendarEvent{Title: "范围内", EventTime: now.Add(time.Hour)})
seedEvent(t, &models.CalendarEvent{Title: "太远", EventTime: now.AddDate(0, 3, 0)})
events, err := ListCalendarEvents(ctx, now, now.Add(24*time.Hour), "active")
if err != nil {
t.Fatalf("列事件: %v", err)
}
if len(events) != 1 || events[0].EventID != inRange.EventID {
t.Errorf("时间范围过滤不对,得到 %d 条", len(events))
}
}
func TestCalendarAttachments(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{Title: "带附件"})
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
EventID: e.EventID, Filename: "报表.xlsx", SHA256: "abc", SizeBytes: 2048,
}); err != nil {
t.Fatalf("加附件: %v", err)
}
atts, err := ListCalendarAttachments(ctx, e.EventID)
if err != nil {
t.Fatalf("列附件: %v", err)
}
if len(atts) != 1 || atts[0].Filename != "报表.xlsx" {
t.Fatalf("附件读回不符:%+v", atts)
}
if atts[0].AttachmentID == "" {
t.Error("附件必须有 attachment_id —— 没有它模型无法在 send_mail 里引用")
}
// 事件没有附件时返回空而不是报错
other := seedEvent(t, &models.CalendarEvent{Title: "没附件"})
if atts, err := ListCalendarAttachments(ctx, other.EventID); err != nil || len(atts) != 0 {
t.Errorf("无附件事件应返回空列表,得到 %d 条 err=%v", len(atts), err)
}
}
func TestDeleteCalendarAttachment(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{Title: "要删附件"})
for _, name := range []string{"甲.pdf", "乙.pdf"} {
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
EventID: e.EventID, Filename: name, SHA256: "sum-" + name, SizeBytes: 10,
}); err != nil {
t.Fatalf("加附件 %s: %v", name, err)
}
}
atts, _ := ListCalendarAttachments(ctx, e.EventID)
if len(atts) != 2 {
t.Fatalf("准备阶段应有 2 个附件,得到 %d", len(atts))
}
ok, err := DeleteCalendarAttachment(ctx, atts[0].AttachmentID)
if err != nil {
t.Fatalf("删附件: %v", err)
}
if !ok {
t.Error("删掉存在的附件应返回 true")
}
left, _ := ListCalendarAttachments(ctx, e.EventID)
if len(left) != 1 {
t.Fatalf("删一个后应剩 1 个,得到 %d", len(left))
}
if left[0].AttachmentID == atts[0].AttachmentID {
t.Error("删错了对象")
}
// 不存在的 id 返回 false 而不是报错 —— 调用方据此回 404 而非 500
ok, err = DeleteCalendarAttachment(ctx, "00000000-0000-0000-0000-000000000000")
if err != nil {
t.Errorf("删不存在的附件不该报错,得到 %v", err)
}
if ok {
t.Error("删不存在的附件应返回 false")
}
}
// 事件附件必须能复制成邮件附件。
//
// 少了这一步,附件只存在于日历侧:UI 里看得见、提醒按时发出、
// 而 Agent 收到的那封信附件清单是空的 —— 两张表互不相通。
func TestAttachCalendarFilesToMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{Title: "带附件的提醒"})
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
EventID: e.EventID, Filename: "周报.md", SHA256: "deadbeef", SizeBytes: 512,
}); err != nil {
t.Fatalf("加附件: %v", err)
}
// sha256 为空的脏数据必须被跳过:挂上去只会得到一个下载必然 404 的附件
if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{
EventID: e.EventID, Filename: "没内容.bin", SHA256: "", SizeBytes: 0,
}); err != nil {
t.Fatalf("加空附件: %v", err)
}
seedAgentForAttach(t, "pi")
sessionID := seedSessionForAttach(t, "pi")
mailID, err := CreateMail(ctx, sessionID, nil, "calendar", "", "pi", "", "日程提醒:带附件的提醒", "正文", nil)
if err != nil {
t.Fatalf("建邮件: %v", err)
}
n, err := AttachCalendarFilesToMail(ctx, e.EventID, mailID, "calendar")
if err != nil {
t.Fatalf("挂附件: %v", err)
}
if n != 1 {
t.Fatalf("应只挂 1 个(空 sha256 那条跳过),得到 %d", n)
}
mailAtts, err := ListAttachmentsFor(ctx, mailID)
if err != nil {
t.Fatalf("列邮件附件: %v", err)
}
if len(mailAtts) != 1 {
t.Fatalf("邮件上应有 1 个附件,得到 %d", len(mailAtts))
}
if mailAtts[0].Filename != "周报.md" || mailAtts[0].SHA256 != "deadbeef" {
t.Errorf("附件内容不符:%+v", mailAtts[0])
}
// 内容寻址:复制不产生新的 sha256,指向同一份磁盘文件
if mailAtts[0].Uploader != "calendar" {
t.Errorf("uploader 应是 calendar,得到 %q", mailAtts[0].Uploader)
}
// 没有附件的事件挂 0 个且不报错
empty := seedEvent(t, &models.CalendarEvent{Title: "无附件"})
if n, err := AttachCalendarFilesToMail(ctx, empty.EventID, mailID, "calendar"); err != nil || n != 0 {
t.Errorf("无附件事件应挂 0 个,得到 %d err=%v", n, err)
}
}
func seedAgentForAttach(t *testing.T, name string) {
t.Helper()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform) VALUES ($1, 'x', 'test')`,
name); err != nil {
t.Fatalf("seed agent: %v", err)
}
}
func seedSessionForAttach(t *testing.T, agentName string) uuid.UUID {
t.Helper()
id := uuid.New()
// 列名是 from_agent 而不是 agent_name(后者是 agents 表的主键名)
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO sessions (session_id, from_agent, subject, session_alias, status)
VALUES ($1, $2, '日程提醒', 'cal-test', 'active')`,
id, agentName); err != nil {
t.Fatalf("seed session: %v", err)
}
return id
}
// 落在 60 秒 lookahead 窗口内的**未来**事件,标记后不得再次到期。
//
// 生产实测的重发现场:一条 event_time=12:53:17 的事件在
// 12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封相同提醒。
// 根因是去重判据写成 `last_fired_at < event_time` —— 触发时刻(now)
// 本来就早于 event_time,条件恒真,于是每个 tick 重发一次,
// 直到 event_time 真正过去才自己停下。
//
// 改成按 occurrence 相等(fired_for = 当时的 event_time)才精确:
// AdvanceRecurrence 改了 event_time 就该再触发,没改就永不重发。
func TestFiredEventInLookaheadWindowDoesNotRefire(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 47 秒后 —— 在 lookahead 窗口内,所以第一次扫描就会入选
e := seedEvent(t, &models.CalendarEvent{
Title: "窗口内的未来事件", EventTime: time.Now().Add(47 * time.Second),
})
due, err := DueEvents(ctx)
if err != nil {
t.Fatalf("首次扫描: %v", err)
}
if len(due) != 1 {
t.Fatalf("lookahead 应让它提前入选,得到 %d 条", len(due))
}
if err := MarkEventFired(ctx, e.EventID); err != nil {
t.Fatalf("标记: %v", err)
}
// 模拟后续几个 tick
for i := 0; i < 3; i++ {
due, err = DueEvents(ctx)
if err != nil {
t.Fatalf("第 %d 次重扫: %v", i+2, err)
}
for _, d := range due {
if d.EventID == e.EventID {
t.Fatalf("第 %d 次扫描仍判定到期 —— 提醒会被重发", i+2)
}
}
}
}
// 重复事件推进 event_time 之后必须重新到期:
// 按 occurrence 去重的另一半,漏了它就变成「每个重复事件只响一次」。
func TestRecurringEventRefiresAfterAdvance(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{
Title: "每天都要响",
EventTime: time.Now().Add(-time.Minute),
Recurrence: "daily",
})
if err := MarkEventFired(ctx, e.EventID); err != nil {
t.Fatalf("标记: %v", err)
}
due, _ := DueEvents(ctx)
for _, d := range due {
if d.EventID == e.EventID {
t.Fatal("标记后不该立刻再次到期")
}
}
// 推进到下一次(+1 天)后,把时间挪到过去模拟「第二天到了」
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
t.Fatalf("推进重复: %v", err)
}
if _, err := db.DB.ExecContext(ctx,
`UPDATE calendar_events SET event_time = ? WHERE event_id = ?`,
time.Now().Add(-30*time.Second), e.EventID); err != nil {
t.Fatalf("模拟次日: %v", err)
}
due, _ = DueEvents(ctx)
found := false
for _, d := range due {
if d.EventID == e.EventID {
found = true
}
}
if !found {
t.Error("event_time 推进后应重新到期,否则重复事件只响一次")
}
}
// ─── 重复规则推进(NextOccurrence 是纯函数,不碰数据库)───
func TestNextOccurrenceSolar(t *testing.T) {
base := time.Date(2026, 9, 3, 9, 30, 0, 0, time.Local)
cases := []struct {
rule string
want string
}{
{models.RecurDaily, "2026-09-04"},
{models.RecurWeekly, "2026-09-10"},
{models.RecurMonthly, "2026-10-03"},
}
for _, c := range cases {
got, err := NextOccurrence(c.rule, base)
if err != nil {
t.Errorf("%s: %v", c.rule, err)
continue
}
if got.Format("2006-01-02") != c.want {
t.Errorf("%s: 得到 %s,期望 %s", c.rule, got.Format("2006-01-02"), c.want)
}
// 时钟必须原样保留
if got.Hour() != 9 || got.Minute() != 30 {
t.Errorf("%s: 时钟被改动 %v", c.rule, got)
}
}
// none 与未知值都返回零值 + nil error
for _, r := range []string{models.RecurNone, "", "每隔一个蓝月亮"} {
got, err := NextOccurrence(r, base)
if err != nil || !got.IsZero() {
t.Errorf("%q 应返回零值无错,得到 %v err=%v", r, got, err)
}
}
}
// time.AddDate 的溢出对「每月同一日」是错的:3 月 31 日 +1 月 = 5 月 1 日。
// 一次溢出会永久改变规则 —— 31 日的事件在 2 月变成 3 月 3 日,
// 然后从此每月 3 日提醒。
func TestNextOccurrenceMonthlyClampsMonthEnd(t *testing.T) {
cases := []struct {
from string
want string
why string
}{
{"2026-01-31", "2026-02-28", "1月31日 +1月 → 2月末(2026 非闰年)"},
{"2026-03-31", "2026-04-30", "3月31日 +1月 → 4月30日"},
{"2026-05-31", "2026-06-30", "5月31日 +1月 → 6月30日"},
{"2028-01-31", "2028-02-29", "闰年 2 月有 29 天"},
{"2026-01-15", "2026-02-15", "月中日期不受影响"},
}
for _, c := range cases {
from, _ := time.ParseInLocation("2006-01-02", c.from, time.Local)
got, err := NextOccurrence(models.RecurMonthly, from)
if err != nil {
t.Errorf("%s: %v", c.why, err)
continue
}
if got.Format("2006-01-02") != c.want {
t.Errorf("%s: 得到 %s,期望 %s", c.why, got.Format("2006-01-02"), c.want)
}
}
}
// 农历月推进:公历间隔在 29~30 天之间浮动,不是固定值。
// 这正是不能用 AddDate 的原因。
func TestNextOccurrenceLunarMonthly(t *testing.T) {
// 2026-09-03 = 农历七月廿二
cur := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local)
gaps := map[int]bool{}
for i := 0; i < 6; i++ {
next, err := NextOccurrence(models.RecurLunarMonthly, cur)
if err != nil {
t.Fatalf("第 %d 次推进: %v", i+1, err)
}
if !next.After(cur) {
t.Fatalf("第 %d 次推进没有前进:%v → %v", i+1, cur, next)
}
gap := int(next.Sub(cur).Hours() / 24)
gaps[gap] = true
// 农历同一日:连续推进后农历「日」应保持
if d := lunar.FromSolar(next); d.Day != 22 {
t.Errorf("第 %d 次推进后农历日变成 %d(期望 22):%s", i+1, d.Day, d.String())
}
cur = next
}
// 间隔必须出现过多种值,证明不是固定天数
if len(gaps) < 2 {
t.Errorf("六次农历月推进的公历间隔只有 %v —— 疑似退化成固定天数", gaps)
}
for g := range gaps {
if g < 28 || g > 31 {
t.Errorf("农历月间隔 %d 天不合理", g)
}
}
}
// 农历年推进:公历日期每年漂移。用公历 yearly 会固定在同一天,
// 与「过农历生日/祭日」的期望不符 —— 这是农历规则存在的理由。
func TestNextOccurrenceLunarYearly(t *testing.T) {
cur := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local)
seen := map[string]bool{}
for i := 0; i < 5; i++ {
next, err := NextOccurrence(models.RecurLunarYearly, cur)
if err != nil {
t.Fatalf("第 %d 次: %v", i+1, err)
}
if !next.After(cur) {
t.Fatalf("第 %d 次没有前进:%v → %v", i+1, cur, next)
}
// 农历月日应保持
d := lunar.FromSolar(next)
if d.Month != 7 || d.Day != 22 {
t.Errorf("第 %d 次推进后农历变成 %d-%d(期望 7-22)", i+1, d.Month, d.Day)
}
seen[next.Format("01-02")] = true
cur = next
}
if len(seen) < 3 {
t.Errorf("五年公历月日只有 %d 种 —— 农历年重复应漂移", len(seen))
}
}
// 农历规则经过数据库这一轮也要正确(AdvanceRecurrence 里调 NextOccurrence)。
func TestAdvanceRecurrenceLunar(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
start := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local)
e := seedEvent(t, &models.CalendarEvent{
Title: "农历每月十五(这里用廿二)",
EventTime: start,
Recurrence: models.RecurLunarMonthly,
})
advanced, err := AdvanceRecurrence(ctx, e.EventID)
if err != nil {
t.Fatalf("推进: %v", err)
}
if !advanced {
t.Fatal("农历重复应能推进")
}
after, err := GetCalendarEvent(ctx, e.EventID)
if err != nil {
t.Fatalf("读回: %v", err)
}
if !after.EventTime.After(start) {
t.Errorf("event_time 未前进:%v", after.EventTime)
}
// 农历日保持
if d := lunar.FromSolar(after.EventTime); d.Day != 22 {
t.Errorf("农历日变成 %d,期望 22(%s)", d.Day, d.String())
}
// 公历间隔应在一个农历月内
gap := int(after.EventTime.Sub(start).Hours() / 24)
if gap < 28 || gap > 31 {
t.Errorf("间隔 %d 天不像一个农历月", gap)
}
}
// ─── 多收件人 ───
func TestRecipientsRoundtrip(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{
Title: "三个 Agent 各自汇报",
Recipients: []string{"pi@/home/program/agentmail", "dsh", "opencode@/tmp"},
DeliveryMode: models.DeliverSeparate,
})
got, err := GetCalendarEvent(ctx, e.EventID)
if err != nil {
t.Fatalf("读回: %v", err)
}
if len(got.Recipients) != 3 {
t.Fatalf("收件人应有 3 个,得到 %d:%v", len(got.Recipients), got.Recipients)
}
if got.Recipients[0] != "pi@/home/program/agentmail" {
t.Errorf("顺序或内容不符:%v", got.Recipients)
}
if got.EffectiveDeliveryMode() != models.DeliverSeparate {
t.Errorf("投递模式 = %q", got.EffectiveDeliveryMode())
}
}
// 空收件人列表必须序列化成 [](而不是 null):Go 的 nil slice 会变 null,
// 前端 .map 直接崩。
func TestRecipientsNeverNull(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{Title: "没写收件人"})
got, err := GetCalendarEvent(ctx, e.EventID)
if err != nil {
t.Fatalf("读回: %v", err)
}
if got.Recipients == nil {
t.Error("Recipients 为 nil —— 会序列化成 null 让前端崩")
}
if len(got.Recipients) != 0 {
t.Errorf("应是空数组,得到 %v", got.Recipients)
}
}
// 旧数据(只有 agent_name / to_address)必须继续工作 —— 历史事件不迁移。
func TestEffectiveRecipientsFallbackChain(t *testing.T) {
cases := []struct {
name string
e models.CalendarEvent
want []string
}{
{
"Recipients 优先",
models.CalendarEvent{Recipients: []string{"a", "b"}, ToAddress: "c", AgentName: "d"},
[]string{"a", "b"},
},
{
"退回 to_address",
models.CalendarEvent{ToAddress: "pi@/tmp.alias", AgentName: "pi"},
[]string{"pi@/tmp.alias"},
},
{
"再退回 agent_name",
models.CalendarEvent{AgentName: "dsh"},
[]string{"dsh"},
},
{
"全空给 nil",
models.CalendarEvent{},
nil,
},
{
"Recipients 里全是空白时继续退回",
models.CalendarEvent{Recipients: []string{"", " "}, AgentName: "pi"},
[]string{"pi"},
},
}
for _, c := range cases {
got := c.e.EffectiveRecipients()
if len(got) != len(c.want) {
t.Errorf("%s: 得到 %v,期望 %v", c.name, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("%s: 第 %d 项 %q,期望 %q", c.name, i, got[i], c.want[i])
}
}
}
}
// 未知投递模式按 separate 处理:它的失败模式更轻。
// together 用错会让本该独立判断的 Agent 互相看到回复而趋同,事后无法分离。
func TestEffectiveDeliveryModeDefaultsToSeparate(t *testing.T) {
for _, in := range []string{"", "separate", "垃圾值", "SEPARATE"} {
e := models.CalendarEvent{DeliveryMode: in}
if got := e.EffectiveDeliveryMode(); got != models.DeliverSeparate {
t.Errorf("DeliveryMode=%q → %q,期望 separate", in, got)
}
}
e := models.CalendarEvent{DeliveryMode: models.DeliverTogether}
if e.EffectiveDeliveryMode() != models.DeliverTogether {
t.Error("together 应被保留")
}
}
// 公历每年:2 月 29 日在平年必须夹到 2 月 28,不能溢出成 3 月 1 日。
// 闰日生日的约定是「平年过 2 月 28」。
func TestNextOccurrenceYearlyClampsLeapDay(t *testing.T) {
cases := []struct {
from string
want string
why string
}{
{"2028-02-29", "2029-02-28", "闰日 +1 年 → 平年 2 月 28"},
{"2026-03-15", "2027-03-15", "普通日期不受影响"},
{"2027-02-28", "2028-02-28", "平年 2/28 → 闰年仍是 2/28(不跳到 29)"},
}
for _, c := range cases {
from, _ := time.ParseInLocation("2006-01-02", c.from, time.Local)
got, err := NextOccurrence(models.RecurYearly, from)
if err != nil {
t.Errorf("%s: %v", c.why, err)
continue
}
if got.Format("2006-01-02") != c.want {
t.Errorf("%s: 得到 %s,期望 %s", c.why, got.Format("2006-01-02"), c.want)
}
}
}
// 过期的重复事件必须一次推到未来,不能每轮补发一封。
//
// 实测的 bug:AdvanceRecurrence 只推进一步 —— 一条 100 天前设的每日事件,
// 每轮扫描都判定「已过期该触发」→ 发一封 → event_time 只前进一天 →
// 下一轮又过期。30 轮扫描触发 30 次,而调度周期是 30 秒,
// 人会收到一串垃圾提醒,连发 100 封才追上今天。
func TestStaleRecurringEventDoesNotFlood(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{
Title: "很久以前设的每日提醒",
EventTime: time.Now().AddDate(0, 0, -100),
Recurrence: models.RecurDaily,
})
fires := 0
// 模拟调度器连续跑 30 轮(生产上就是 15 分钟)
for i := 0; i < 30; i++ {
due, err := DueEvents(ctx)
if err != nil {
t.Fatalf("第 %d 轮扫描: %v", i+1, err)
}
hit := false
for _, d := range due {
if d.EventID == e.EventID {
hit = true
}
}
if !hit {
break
}
fires++
if err := MarkEventFired(ctx, e.EventID); err != nil {
t.Fatalf("标记: %v", err)
}
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
t.Fatalf("推进: %v", err)
}
}
if fires != 1 {
t.Errorf("过期的每日重复事件触发了 %d 次,应只触发 1 次", fires)
}
after, err := GetCalendarEvent(ctx, e.EventID)
if err != nil {
t.Fatalf("读回: %v", err)
}
if !after.EventTime.After(time.Now()) {
t.Errorf("推进后 event_time 仍在过去:%v", after.EventTime)
}
// 只跳到「刚过现在」的那一次,不是跳到很远的将来
if after.EventTime.After(time.Now().AddDate(0, 0, 2)) {
t.Errorf("推得太远了:%v", after.EventTime)
}
}
// 农历规则的过期事件同样不能刷屏。
func TestStaleLunarRecurringDoesNotFlood(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
e := seedEvent(t, &models.CalendarEvent{
Title: "去年设的农历每月提醒",
EventTime: time.Now().AddDate(-1, 0, 0),
Recurrence: models.RecurLunarMonthly,
})
if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil {
t.Fatalf("推进: %v", err)
}
after, _ := GetCalendarEvent(ctx, e.EventID)
if !after.EventTime.After(time.Now()) {
t.Errorf("一年前的农历事件推进后仍在过去:%v", after.EventTime)
}
// 农历日必须保持
if d := lunar.FromSolar(after.EventTime); d.Day != lunar.FromSolar(e.EventTime).Day {
t.Errorf("农历日从 %d 变成 %d", lunar.FromSolar(e.EventTime).Day, d.Day)
}
}
// 越过 recurrence_end 时必须置 cancelled 而不是留在 active。
//
// 留着的表现是一条僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
// 但 fired_for 已等于 event_time 所以又不触发 —— 永远排在到期列表里不动。
func TestAdvanceCancelsAfterRecurrenceEnd(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
end := time.Now().AddDate(0, 0, -1) // 昨天就该停
e := seedEvent(t, &models.CalendarEvent{
Title: "已到期的每日重复",
EventTime: time.Now().AddDate(0, 0, -5),
Recurrence: models.RecurDaily,
RecurrenceEnd: &end,
})
advanced, err := AdvanceRecurrence(ctx, e.EventID)
if err != nil {
t.Fatalf("推进: %v", err)
}
if advanced {
t.Error("已过 recurrence_end 不该报告推进成功")
}
after, err := GetCalendarEvent(ctx, e.EventID)
if err != nil {
t.Fatalf("读回: %v", err)
}
if after.Status != "cancelled" {
t.Errorf("状态应是 cancelled,得到 %q —— 留在 active 会变僵尸事件", after.Status)
}
// 且不该再出现在到期列表里
due, _ := DueEvents(ctx)
for _, d := range due {
if d.EventID == e.EventID {
t.Error("已 cancelled 的事件仍出现在 DueEvents")
}
}
}
// advanceToFuture 是纯函数,单独测三个终止条件。
func TestAdvanceToFuture(t *testing.T) {
now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.Local)
t.Run("跨过 now 就停", func(t *testing.T) {
from := now.AddDate(0, 0, -100)
got, err := advanceToFuture(models.RecurDaily, from, now, nil)
if err != nil {
t.Fatalf("推进: %v", err)
}
if !got.After(now) {
t.Errorf("结果 %v 不在 now 之后", got)
}
// 恰好是越过 now 的第一次,不是更远
if got.After(now.AddDate(0, 0, 1)) {
t.Errorf("推过头了:%v", got)
}
})
t.Run("越过 recurrenceEnd 给零值", func(t *testing.T) {
end := now.AddDate(0, 0, -1)
got, err := advanceToFuture(models.RecurDaily, now.AddDate(0, 0, -5), now, &end)
if err != nil {
t.Fatalf("不该报错:%v", err)
}
if !got.IsZero() {
t.Errorf("应给零值,得到 %v", got)
}
})
t.Run("不重复给零值无错", func(t *testing.T) {
got, err := advanceToFuture(models.RecurNone, now, now, nil)
if err != nil || !got.IsZero() {
t.Errorf("得到 %v err=%v", got, err)
}
})
t.Run("未来的事件原地推一步", func(t *testing.T) {
from := now.AddDate(0, 0, 5)
got, err := advanceToFuture(models.RecurDaily, from, now, nil)
if err != nil {
t.Fatalf("推进: %v", err)
}
// from 已在未来,第一次推进就该返回
if !got.Equal(from.AddDate(0, 0, 1)) {
t.Errorf("得到 %v,期望 %v", got, from.AddDate(0, 0, 1))
}
})
// 上限是防御性的:农历路径依赖外部库,一旦某年给出反直觉结果,
// 没有上限就是个死循环 goroutine,而它跑在调度器里 —— 整个提醒系统一起卡住
t.Run("十年前的每日事件也能在上限内追上", func(t *testing.T) {
got, err := advanceToFuture(models.RecurDaily, now.AddDate(-10, 0, 0), now, nil)
if err != nil {
t.Fatalf("十年(约 3650 步)应在 %d 上限内:%v", maxAdvanceSteps, err)
}
if !got.After(now) {
t.Errorf("结果 %v 不在 now 之后", got)
}
})
}