# 现象
全量服务端测试稳定失败:
--- FAIL: TestStaleLunarRecurringDoesNotFlood
calendar_test.go:869: 农历日从 21 变成 20
不是随机失败,也不是测试写错 —— 是产品逻辑的真实缺陷。
# 根因
SQLite 的 DSN 带 `_timezone=UTC`(为了让 `expires_at > NOW()` 这类字符串比较
同一时间轴,见 db.sqliteDSN 的注释)。因此从库里 Scan 出来的 `event_time` 是
UTC 时刻的表示。
对公历重复规则,这无关紧要 —— `AddDate` 操作的是同一时刻的另一种表示。
但**农历换算直接读取 Year/Month/Day**:
本地 2025-09-12 07:00 (+0800) → 存库 → 读出 UTC 2025-09-11 23:00
农历(本地) = 七月廿一 → 农历(UTC 字段) = 七月二十 ← 少一天
后果:在本地时间 0:00–8:00(+0800)创建的农历提醒,之后每次推进都按前一天
计算,日期永久偏一天;而且只有等到下一次该提醒时才暴露,没有任何报错。
# 修法
在 `AdvanceRecurrence` 里,仅对两条农历规则把 event_time 转回 `time.Local`
再交给 `NextOccurrence`。
只转农历规则而不是无条件转:公历规则不需要,且 UTC 与 Local 表示同一时刻,
`AddDate` 在两者上结果相同 —— 无条件转会掩盖「DSN 时间是 UTC」这个事实,
让后来者更难判断该在哪一层做时区处理。
# 测试
新增 `TestAdvanceRecurrenceLunarUsesLocalCalendarDay`,用**固定日期**
(2025-09-12 07:00 本地)而不是 `time.Now()`,因此任何时刻跑都稳定;
并且它先断言测试前提成立:
- 库里读回的时刻确实与输入跨了不同公历日
- 直接按 UTC 字段做农历换算确实会得到不同的农历日
前提不成立就直接 Fatal —— 否则这个用例可能在某个时区/时段下变成永远通过的
空壳(那正是它要防的那类假绿)。
# 验证
- 新用例与原有的两条农历用例 ×10 连跑全绿(`-count=10`)
- `go vet ./...` 干净;`go test ./... -count=1` 全量通过
- 修复前该用例 5/5 失败,修复后 10/10 通过
553 lines
20 KiB
Go
553 lines
20 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, 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
|
||
}
|