chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,541 @@
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
}
// **一路推到未来**,不是只推一步。
//
// 只推一步的后果(实测):一条 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
}