Files
MailUI4Agents/gateway/internal/db/migrations/init_sqlite.sql
JianFeeeee a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00

469 lines
23 KiB
SQL
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.

-- AgentMail Schema — SQLite默认后端
--
-- 与 init.sqlPostgreSQL保持同一套表结构与语义差异仅在方言
-- UUID → TEXTGo 侧 uuid 或 gen_random_uuid() 注册函数生成)
-- TIMESTAMPTZ → DATETIME必须写 DATETIMEdatabase/sql 才能扫进 time.Time
-- 默认值不用 CURRENT_TIMESTAMP它只有【秒】精度同一秒内插入的多行
-- 按 created_at 排序结果不确定,
-- 「会话里最早/最后那封邮件」都会取错行
-- (实测同秒插 5 封,排出来的顺序是乱的)。
-- 改用 strftime 的毫秒精度。mails 表另在 repo 层的
-- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要
-- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序
-- 直接决定 UI 上「最新进展」显示哪一封。
-- JSONB → TEXT存 JSON 字符串,用 json_each/json_extract 检索)
-- VARCHAR(n) → TEXTSQLite 不强制长度,长度约束由应用层负责)
-- NOW() → 由 internal/db 注册的同名函数提供,与 PG 侧 SQL 一致
--
-- 本文件只建表建索引不含数据迁移SQLite 是新引入的默认后端,不存在历史库。
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
last_login DATETIME,
-- 权限边界:空数组 = 不限
allowed_agents TEXT NOT NULL DEFAULT '[]',
allowed_paths TEXT NOT NULL DEFAULT '[]',
agent_aliases TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS user_sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
expires_at DATETIME NOT NULL,
user_agent TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
CREATE TABLE IF NOT EXISTS agents (
agent_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
agent_name TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL,
host_url TEXT NOT NULL DEFAULT '',
workspaces TEXT NOT NULL DEFAULT '[]',
platform TEXT NOT NULL DEFAULT 'pi',
status TEXT NOT NULL DEFAULT 'offline',
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
-- 配额是任务的属性,所以真正的约束在 sessions.max_rounds 上;
-- 这里只提供默认值 —— 不同 Agent 能力不同,默认值分开设才合理。
default_rounds INTEGER NOT NULL DEFAULT 20,
-- max_rounds / used_rounds 是历史遗留的「终身额度」。
-- 终身额度是错的工具:跑满就得管理员手工重置才能再干活,
-- 而 Agent 是长期在线的。现已降级为纯统计used_rounds 只累加、不拦请求),
-- max_rounds 保留列但不再参与判断。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0,
-- mode_enforcement 是该平台插件自报的权限档位强制能力native / advisory
-- 随心跳上报(与模型目录同一条通道 —— I-1平台自己说的才算
--
-- 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
-- homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
-- 把这个事实藏起来比做不到本身更危险。
--
-- 默认 advisory 而不是 native没自报过的插件我们不能替它宣称
-- 「档位在这里是被强制的」。
mode_enforcement TEXT NOT NULL DEFAULT 'advisory',
last_seen DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
session_alias TEXT,
-- workspace 是这条会话所属的工作目录(三维地址 name@path.session 的 path 位)。
--
-- 之前它只存在于 mails.to_workspace 上,于是「这个工作区下有哪些会话」
-- 必须 JOIN mails 再从收发双方的 workspace 里猜,而 Agent 回信时
-- from_workspace 填的是 Agent 名而不是路径 —— 猜出来的结果是错的,
-- 别名候选列表因此列不出本工作区的历史会话。
-- 会话归属哪个工作区是会话自己的属性,就该存在会话上。
workspace TEXT NOT NULL DEFAULT '',
from_agent TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
owner_user_id TEXT REFERENCES users(user_id),
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
-- 绑定到平台侧的哪条会话agent_platform_sessions.platform_id
--
-- 空 = 这条会话由邮件创建,平台侧的会话是桥按邮件开的。
-- 非空 = 这条会话**接管**了一条平台上已经存在的会话(人在 TUI 里开的那种)。
--
-- 为什么需要它TUI 与邮箱是同一个 Agent 的两个入口,不是两套隔离的世界。
-- 人在 TUI 里聊了一半想转到邮件上继续,或者想把一封邮件投进正在谈的那条
-- 会话 —— 补全早就把平台会话列为候选agent_platform_sessions
-- 但投递侧没有这一跳,选中后只能得到 404。这一列就是那一跳的落点。
platform_id TEXT NOT NULL DEFAULT '',
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
rename_dismissed TEXT,
-- 别名是谁定的:'platform'Agent 平台自动同步,可被后续同步覆盖)
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉,
-- 人上一秒记住的寻址地址下一秒失效。
alias_source TEXT NOT NULL DEFAULT 'platform',
-- 本次任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
--
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
--
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
-- 每条都是全新预算,全局上限就形同虚设。
max_rounds INTEGER NOT NULL DEFAULT 0,
used_rounds INTEGER NOT NULL DEFAULT 0,
-- 会话级权限档位plan / workspace / full
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
permission_mode TEXT NOT NULL DEFAULT 'workspace',
-- 接收平台实际做到的强制力native / advisory
-- 旧库默认 'advisory':没自报过的插件,我们不能替它宣称
-- 「档位在这里是被强制的」。保守方向是承认做不到,而不是假装做到了。
permission_enforcement TEXT NOT NULL DEFAULT 'advisory'
);
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
-- 会话别名负责寻址name@path.<alias>),必须全局唯一。
-- 部分唯一索引未命名会话NULL不受约束可以有任意多个。
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
CREATE TABLE IF NOT EXISTS mails (
mail_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
parent_mail_id TEXT REFERENCES mails(mail_id),
from_name TEXT NOT NULL,
from_workspace TEXT DEFAULT '',
to_name TEXT NOT NULL,
to_workspace TEXT DEFAULT '',
subject TEXT NOT NULL,
body TEXT NOT NULL,
-- 抄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
cc_list TEXT NOT NULL DEFAULT '[]',
mail_type TEXT NOT NULL DEFAULT 'normal',
permission_options TEXT,
permission_result TEXT,
status TEXT NOT NULL DEFAULT 'unread',
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
hop_limit INTEGER DEFAULT 5,
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,
-- 「谁在哪一封里提了什么」应当留痕。
rename_alias TEXT,
rename_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
CREATE INDEX IF NOT EXISTS idx_mails_created ON mails(created_at);
-- 抄送检索无对应索引SQLite 侧走 json_each 展开。
-- 单机邮件量级(数千至数万)下全表展开是毫秒级,不值得为此加物化列。
CREATE TABLE IF NOT EXISTS permission_requests (
request_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
agent_name TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL DEFAULT '["同意","拒绝"]',
context TEXT DEFAULT '',
result TEXT,
decided_at DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
-- ---------- 密钥认证体系 ----------
--
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys
-- agent_keys管理员签发用于 Agent 注册/心跳/SSE
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
--
-- key_type
-- permanent — 永不过期,可重复使用
-- one_time — 首次验证后写 used_at再用即拒
-- timed — expires_at 之后失效
CREATE TABLE IF NOT EXISTS agent_keys (
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
key_token TEXT NOT NULL UNIQUE,
agent_name TEXT, -- NULL = 待绑定
key_type TEXT NOT NULL DEFAULT 'permanent',
label TEXT NOT NULL DEFAULT '',
expires_at DATETIME,
used_at DATETIME,
created_by TEXT REFERENCES users(user_id),
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
CREATE TABLE IF NOT EXISTS user_keys (
key_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
key_token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
key_type TEXT NOT NULL DEFAULT 'permanent',
expires_at DATETIME,
used_at DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
-- ---------- 附件 ----------
--
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
-- 不塞 BLOBSQLite 的 BLOB 会让 .db 文件膨胀并拖慢 WAL而附件是只写一次多次读的冷数据。
--
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
-- 上传与发信是两步Agent 工具是 JSON 接口,没法在发信时带 multipart
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
-- 插件自动转发的邮件登记表。
--
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
--
-- relay_key 是上游那条消息的稳定标识opencode 的 permission id / assistant message id
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
-- * 插件重试、SSE 重连后重放都不会产生第二封
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
CREATE TABLE IF NOT EXISTS relayed_mails (
agent_name TEXT NOT NULL,
relay_key TEXT NOT NULL,
mail_id TEXT REFERENCES mails(mail_id),
kind TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
PRIMARY KEY (agent_name, relay_key)
);
-- 人类决策后要按 mail_id 反查上游 permission id
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
CREATE TABLE IF NOT EXISTS attachments (
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
mail_id TEXT REFERENCES mails(mail_id) ON DELETE CASCADE,
-- 上传者Agent 名或用户名),用于「只能挂自己上传的附件」校验
uploader TEXT NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL,
-- sha256 既是去重依据也是磁盘路径来源,绝不用用户给的 filename 拼路径
sha256 TEXT NOT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
-- GC 扫描待挂载附件用
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
-- 速率限制(登录失败 + 新建会话),替代进程内内存计数器。
CREATE TABLE IF NOT EXISTS rate_limits (
bucket TEXT NOT NULL,
ts DATETIME NOT NULL,
expired INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
-- ---------- 邮件场景下的可用模型 ----------
--
-- 拆成两张表,因为它们是两种不同的真相:
--
-- agent_model_catalog —— 平台**上报**它当前看得见哪些模型。每次注册整表替换。
-- agent_allowed_models —— 管理员**选定**其中哪些可以在邮件场景下用rank 即优先级。
--
-- 不合成一张带 allowed 标记的表:那样一来模型从平台目录里消失(换了 provider 配置、
-- 上游下线了某个模型)就会连带把管理员的选择删掉,等模型回来还得重新配一遍。
-- 分开存之后,选择是持久的,目录只决定「这一项现在是否可用」。
CREATE TABLE IF NOT EXISTS agent_model_catalog (
agent_name TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
-- 人类可读名,平台给什么就存什么;为空时前端显示 model id
display_name TEXT NOT NULL DEFAULT '',
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
PRIMARY KEY (agent_name, provider, model)
);
CREATE TABLE IF NOT EXISTS agent_allowed_models (
agent_name TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
-- rank 越小越先试。插件按它顺序降级,全部失败才回一封失败邮件。
rank INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (agent_name, provider, model)
);
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
-- ---------- 平台会话镜像 ----------
--
-- Agent 平台opencode / DSH自己也在开会话有些经由邮件驱动有些是人直接
-- 在平台界面上开的。写信时想续谈某条会话,就得先知道那个工作区下有哪些会话
-- 可以续 —— 而 Gateway 只看得见邮件驱动的那部分。
--
-- **由插件在心跳里上报,而不是 Gateway 反向拉取**:当前架构是单向的
-- Agent 持密钥主动连 GatewayGateway 从不外呼)。让 Gateway 去调平台接口
-- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。
--
-- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间,
-- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处
-- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
agent_name TEXT NOT NULL,
-- 平台侧的会话 idopencode 的 ses_xxx / DSH 的 session id
platform_id TEXT NOT NULL,
-- 平台侧 cwd即三维地址的 path 位
workspace TEXT NOT NULL DEFAULT '',
-- 平台自己的可寻址短名opencode 的 slugDSH 由模型标题派生)
slug TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
-- 该平台会话是否由 AgentMail 的邮件驱动。用来在候选列表里区分
-- 「续谈已有邮件线索」与「接入一条平台侧已经在跑的会话」。
mail_driven INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME,
reported_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
PRIMARY KEY (agent_name, platform_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
ON agent_platform_sessions(agent_name, workspace);
-- ─── 日历事件 ───
--
-- Outlook 风格:事件 → 提醒 → 邮件通知 Agent。
-- 事件本身是日历实体,提醒是定时触发器,邮件是投递通道。
-- 三者分离:同一条事件可以有多个提醒(提前提醒 + 当天提醒),
-- 同一条提醒只触发一封邮件(幂等由 fired_at 控制)。
CREATE TABLE IF NOT EXISTS calendar_events (
event_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
-- 事件标题UI 显示 + 邮件主题前缀)
title TEXT NOT NULL,
-- 事件描述UI 显示,可含 markdown
description TEXT NOT NULL DEFAULT '',
-- 用户可编辑的提醒消息模板。支持变量:{title} {time} {description}
reminder_text TEXT NOT NULL DEFAULT '',
-- 通知目标
agent_name TEXT NOT NULL DEFAULT '',
-- 收件人三维地址(空 = 用 agent_name 默认地址)
to_address TEXT NOT NULL DEFAULT '',
-- 时间安排
event_time DATETIME NOT NULL,
-- 提前多少分钟提醒0 = 事件触发时)
remind_before INTEGER NOT NULL DEFAULT 0,
-- 重复规则none / daily / weekly / monthly
-- lunar_monthly每农历月同一日/ lunar_yearly每农历年同月同日
--
-- 农历规则必须经 internal/lunar 推进,不能加固定天数 ——
-- 农历月 29~30 天不定、农历年 353~385 天(闰年多一整月),
-- 近似推进一年能偏半个月。
recurrence TEXT NOT NULL DEFAULT 'none',
-- 重复结束(空 = 永久)
recurrence_end DATETIME,
-- 收件人列表JSON 数组,每项是完整三维地址串)。
--
-- 存原始串而不是结构化地址session 位的 new/别名三态该在**触发那一刻**
-- 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化
-- 而重复事件每次触发都该重新决定落到哪条会话。
recipients TEXT NOT NULL DEFAULT '[]',
-- 多收件人的投递方式:
-- separate默认= 各发一封、落各自会话、互不可见
-- together = 首个为主收件人,其余进 cc_list、共享一条线索
--
-- 默认 separate 因为它的失败模式更轻together 用错会让本该独立判断的
-- Agent 互相看到回复而趋同,那种污染事后无法分离。
delivery_mode TEXT NOT NULL DEFAULT 'separate',
-- 状态active / paused / cancelled
status TEXT NOT NULL DEFAULT 'active',
-- 最后一次触发的墙上时钟(给 UI 显示「上次触发于」)
last_fired_at DATETIME,
-- 已触发的那个 occurrence值 = 当时的 event_time。
--
-- 去重不能拿 last_fired_at 跟 event_time 比大小DueEvents 有 60 秒
-- lookahead落在窗口内的**未来**事件被触发后 last_fired_at(now) 仍然
-- 小于 event_time于是每个 tick 重发一次,直到 event_time 真正过去。
-- 生产实测:一条 12:53:17 的事件在 12:52:30 / 12:53:00 / 12:53:06 /
-- 12:53:36 发了 4 封相同提醒。
-- 按 occurrence 比相等则精确AdvanceRecurrence 改了 event_time 就再触发,
-- 没改就永不重发。
fired_for DATETIME,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
-- 创建者(人类用户)
created_by TEXT NOT NULL DEFAULT ''
);
-- 调度器每分钟扫描 active 事件,按 event_time + remind_before 排序取下一个
CREATE INDEX IF NOT EXISTS idx_calendar_next_fire
ON calendar_events(status, event_time) WHERE status = 'active';
-- 按时间范围查(日历视图)
CREATE INDEX IF NOT EXISTS idx_calendar_time_range
ON calendar_events(event_time, status);
-- 附件:事件触发时随提醒邮件一起发出
CREATE TABLE IF NOT EXISTS calendar_attachments (
attachment_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
event_id TEXT NOT NULL REFERENCES calendar_events(event_id) ON DELETE CASCADE,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
);
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
ON calendar_attachments(event_id);
-- 注意idx_sessions_platform 不在这里。
-- 这个脚本在 addMissingColumns **之前**执行,而已部署的库里 sessions 表已经
-- 存在 —— CREATE TABLE IF NOT EXISTS 不会给它补 platform_id 列,于是这里建
-- 索引会以 "no such column" 失败,整个迁移中断(实测过一次)。
-- 依赖补出来的列的索引一律放 migrate.go 的 sqliteAddIndexes。