Files
MailUI4Agents/gateway/internal/repo/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

535 lines
19 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 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 侧列表用这个而不是 ListCalendarEventsAgent 不该看到别人(人类或
// 其他 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
}