三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`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 例模板渲染。
生产端到端验证并清理了数据。
535 lines
19 KiB
Go
535 lines
19 KiB
Go
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
|
||
}
|