-- AgentMail Schema — SQLite(默认后端) -- -- 与 init.sql(PostgreSQL)保持同一套表结构与语义,差异仅在方言: -- UUID → TEXT(Go 侧 uuid 或 gen_random_uuid() 注册函数生成) -- TIMESTAMPTZ → DATETIME(必须写 DATETIME,database/sql 才能扫进 time.Time) -- 默认值不用 CURRENT_TIMESTAMP:它只有【秒】精度,同一秒内插入的多行 -- 按 created_at 排序结果不确定, -- 「会话里最早/最后那封邮件」都会取错行 -- (实测同秒插 5 封,排出来的顺序是乱的)。 -- 改用 strftime 的毫秒精度。mails 表另在 repo 层的 -- INSERT 里显式传 NOW()(微秒精度)—— 一次插入只要 -- 几十到几百微秒,毫秒仍可能撞车,而邮件顺序 -- 直接决定 UI 上「最新进展」显示哪一封。 -- JSONB → TEXT(存 JSON 字符串,用 json_each/json_extract 检索) -- VARCHAR(n) → TEXT(SQLite 不强制长度,长度约束由应用层负责) -- 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.),必须全局唯一。 -- 部分唯一索引:未命名会话(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 在正文里提议改会话别名()。 -- 存在邮件上而非会话上:邮件是不可篡改的历史记录, -- 「谁在哪一封里提了什么」应当留痕。 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 派生),数据库只存元数据。 -- 不塞 BLOB:SQLite 的 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 持密钥主动连 Gateway,Gateway 从不外呼)。让 Gateway 去调平台接口 -- 需要它保存各平台的地址与凭证,那是另一套信任模型,暂不引入。 -- -- 与 sessions 表分开存:这里是**别人家的**会话,其 id 属于平台的 id 空间, -- 没有本侧的 owner / 预算 / 邮件。混进 sessions 会让每一处 -- 「按会话鉴权」都要先判断这条到底是不是真的本侧会话。 CREATE TABLE IF NOT EXISTS agent_platform_sessions ( agent_name TEXT NOT NULL, -- 平台侧的会话 id(opencode 的 ses_xxx / DSH 的 session id) platform_id TEXT NOT NULL, -- 平台侧 cwd,即三维地址的 path 位 workspace TEXT NOT NULL DEFAULT '', -- 平台自己的可寻址短名(opencode 的 slug;DSH 由模型标题派生) 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。