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,479 @@
-- 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,
-- permission = 危险操作审批question = Agent 主动补充询问。
permission_kind TEXT NOT NULL DEFAULT '',
permission_multi_select INTEGER NOT NULL DEFAULT 0,
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 '',
-- permission = 危险操作审批question = Agent 主动补充询问。
kind TEXT NOT NULL DEFAULT 'permission',
-- ask_user_question 的多选语义;普通权限审批恒为 0。
multi_select INTEGER NOT NULL DEFAULT 0,
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,
-- 权限档位plan / workspace / full。事件触发时新建会话 → 用此档位定死;
-- 复用已有会话 → 取「会话现档 与 事件档」中更严那个ModeAtMost
-- 不允许通过重复事件提权plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan
permission_mode TEXT NOT NULL DEFAULT 'workspace',
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。