Files
MailUI4Agents/gateway/internal/models/calendar.go
JianFeeeee 069bf03ae2 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 例模板渲染。
生产端到端验证并清理了数据。
2026-09-04 06:28:03 +08:00

144 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package models
import (
"strings"
"time"
)
// CalendarEvent 日历事件。
//
// 设计参照 Outlook事件有时间、提醒、收件人触发时产生一封邮件。
// 事件本身是日历实体,提醒是触发器,邮件是投递通道 —— 三者分离。
type CalendarEvent struct {
EventID string `json:"event_id"`
Title string `json:"title"`
Description string `json:"description"`
ReminderText string `json:"reminder_text"`
// AgentName / ToAddress 是**单收件人时代的字段**,保留作兼容与兜底:
// Recipients 为空时用它们。新代码一律读 EffectiveRecipients()。
AgentName string `json:"agent_name"`
ToAddress string `json:"to_address"`
// Recipients 是完整的收件人列表(每项是完整三维地址串)。
//
// 为什么不用 []Address 而用 []string地址的三段语义尤其 session 位的
// new/别名三态)在**触发那一刻**才该被解析 —— 存结构化的话,
// 「.new」这种一次性语义在建事件时就被固化而重复事件每次触发都该
// 重新决定落到哪条会话。存原始串让 ParseAddress 在投递时做这个决定。
Recipients []string `json:"recipients"`
// DeliveryMode 决定多收件人怎么投:
// "separate"(默认)—— 每人各发一封,落在各自的会话里,互相看不到
// "together" —— 第一个是主收件人,其余进 cc_list共享同一条线索
//
// 两种语义都需要而不是二选一:「让三个 Agent 各自独立汇报」与
// 「让 pi 主办、dsh 知情」是完全不同的任务形态,用错会让协作失败 ——
// 前者用 together 会让三个 Agent 互相看到对方的回复而趋同,
// 后者用 separate 会让 dsh 完全不知道 pi 在做什么。
DeliveryMode string `json:"delivery_mode"`
EventTime time.Time `json:"event_time"`
RemindBefore int `json:"remind_before"` // 提前多少分钟
// Recurrence公历 none/daily/weekly/monthly/yearly + 农历两种
// lunar_monthly —— 每农历月同一日(如每月十五)
// lunar_yearly —— 每农历年同月同日(过农历生日/祭日)
//
// lunar_daily 不存在:农历的「日」与公历同长,那就是 daily。
// lunar_weekly 也不存在:农历没有「周」这个单位。
Recurrence string `json:"recurrence"`
RecurrenceEnd *time.Time `json:"recurrence_end,omitempty"`
Status string `json:"status"` // active/paused/cancelled
LastFiredAt *time.Time `json:"last_fired_at,omitempty"`
// FiredFor 是已触发的那个 occurrence值 = 当时的 EventTime
// 去重靠它与 EventTime 相等判断,不是拿 LastFiredAt 比大小 ——
// DueEvents 有 60 秒 lookahead后者在窗口内恒为真会导致每 tick 重发。
FiredFor *time.Time `json:"fired_for,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
}
// 重复规则常量。农历规则单独一组:它们的推进要经过 internal/lunar
// 不能像公历那样 AddDate 固定天数(农历月 29~30 天、闰年 13 个月)。
const (
RecurNone = "none"
RecurDaily = "daily"
RecurWeekly = "weekly"
RecurMonthly = "monthly"
RecurYearly = "yearly"
RecurLunarMonthly = "lunar_monthly"
RecurLunarYearly = "lunar_yearly"
)
// IsLunarRecurrence 判断一条重复规则是否按农历推进。
func IsLunarRecurrence(r string) bool {
return r == RecurLunarMonthly || r == RecurLunarYearly
}
// 投递模式常量。
const (
DeliverSeparate = "separate"
DeliverTogether = "together"
)
// EffectiveRecipients 返回真正要投的收件人列表。
//
// Recipients 优先;为空时退回 ToAddress再退回 AgentName。
// 这个兜底链让旧数据(只有 agent_name 的事件)继续工作 ——
// 历史事件不迁移,读的时候归一化。
func (e *CalendarEvent) EffectiveRecipients() []string {
if len(e.Recipients) > 0 {
out := make([]string, 0, len(e.Recipients))
for _, r := range e.Recipients {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
if len(out) > 0 {
return out
}
}
if a := strings.TrimSpace(e.ToAddress); a != "" {
return []string{a}
}
if a := strings.TrimSpace(e.AgentName); a != "" {
return []string{a}
}
return nil
}
// EffectiveDeliveryMode 归一化投递模式,未知值按 separate 处理。
//
// 默认 separate 而不是 togetherseparate 的失败是「Agent 各干各的」,
// together 的失败是「本该独立的 Agent 互相污染了上下文」——
// 后者更难发现也更难挽回。
func (e *CalendarEvent) EffectiveDeliveryMode() string {
if e.DeliveryMode == DeliverTogether {
return DeliverTogether
}
return DeliverSeparate
}
// CalendarAttachment 事件附件。
type CalendarAttachment struct {
AttachmentID string `json:"attachment_id"`
EventID string `json:"event_id"`
Filename string `json:"filename"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// CalendarView 日历视图(月/周/日)。
type CalendarView struct {
Events []CalendarEvent `json:"events"`
// 当前时间线上的事件数(用于统计徽章)
UpcomingCount int `json:"upcoming_count"`
TodayCount int `json:"today_count"`
}