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:
143
gateway/internal/models/calendar.go
Normal file
143
gateway/internal/models/calendar.go
Normal file
@ -0,0 +1,143 @@
|
||||
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 而不是 together:separate 的失败是「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"`
|
||||
}
|
||||
Reference in New Issue
Block a user