用户 2026-10-04:「一切数据调用都要接口化」。这条要求直接指向上一轮的两个错误。
## 撤掉的:Migrate 里的自动回填
`Migrate` 是**结构**变更通道(建表、加列)。让��兼做数据改写(给会话写
parent_session_id)是混用通道,后果不是理论上的:
- 不可重跑 —— 上一轮那次回填已经在部署中执行,`app_meta` 标记
`2026-10-04T02:54:37Z`,**无法通过任何方式重跑或撤销**;
- 不可审计 —— 库上看不出谁在什么时候改的;
- 不可触发 —— 没有接口能主动执行它。
⇒ 移到 admin 端点,与 `DELETE /admin/sessions/{id}`、
`POST /admin/agent-keys` 同一档。
## 加的
POST /api/v1/admin/sessions/tree/backfill 默认 dry-run,显式 dry_run=0 才写
GET /api/v1/admin/sessions/tree/preview 只读
`PreviewSessionParents`(repo 层,只读)是必需的配套:接口化之后**验证本身
也必须走接口**,那么「回填到底会改什么」就得有一条不写任何东西的接口来回答,
否则只剩「先写了再看看对不对」。
## 判据抓到的真 bug:注释说默认 dry-run,代码默认就写
if v := r.URL.Query().Get("dry_run"); v != "" { // 参数缺失 → 整个 if 落空
...
}
n, err := repo.BackfillSessionParents(...) // ← 直接执行
误点一次 POST 就改了 6 条真实会话的拓扑,且没有任何预览。修成
`dry := true` 起手、只在显式解析出 false 时才写。
## 判据自己假绿了一次(本轮第四次做形状判据)
`TestBackfillDefaultsToDryRun` 初版读源码、数 `AdminPreviewSessionParents`
出现几次。变异 `dry := false`(**正是上面那个 bug**)之后判据**依然全绿** ——
因为 `if dry {…}` 分支在源码里始终存在。
⇒ 改成行为测试:真起 httptest 发一个不带参数的 POST,然后去**库里**看
parent_session_id 有没有被写。重测同一变异,两条断言都红:
★ 无参 POST 的响应应表明这是预览,实际:{"dry_run":false,…}
★ 无参 POST 就把 parent_session_id 写成了 "31949dc4-…"
同轮修掉判据两次「匹配比语义宽」:`strings.Contains(seg, "UPDATE")`
把 `ORDER BY c.updated_at DESC` 里的 `updated` 当成了写库;
函数边界用 `strings.Index("\nfunc ")` 在「函数是文件最后一个」时落空,
取到全文后把别的函数里的 UPDATE 判成预览写库。
## 尚未验证
树端点只能验到「匿名被正确拒绝(401)」—— **不足以证明树能用**。
缺 admin 凭证,admin 全看 vs 普通用户收窄的差异还没在真数据上跑过。
14 包全绿。
406 lines
19 KiB
Go
406 lines
19 KiB
Go
package db
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
_ "embed"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/google/uuid"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
//go:embed migrations/init.sql
|
||
var initSQLPostgres string
|
||
|
||
//go:embed migrations/init_sqlite.sql
|
||
var initSQLSQLite string
|
||
|
||
// Migrate 建表建索引。两种方言各有一份 schema,语义保持一致。
|
||
func Migrate(ctx context.Context) error {
|
||
switch D {
|
||
case Postgres:
|
||
// PG 侧含 DO $$ … $$ 迁移块,必须整体提交
|
||
if _, err := DB.ExecContext(ctx, initSQLPostgres); err != nil {
|
||
return fmt.Errorf("migrate postgres: %w", err)
|
||
}
|
||
case SQLite:
|
||
// modernc.org/sqlite 的 Exec 不接受多语句,逐条执行
|
||
for i, stmt := range splitStatements(initSQLSQLite) {
|
||
if _, err := DB.ExecContext(ctx, stmt); err != nil {
|
||
return fmt.Errorf("migrate sqlite (语句 #%d: %.60s): %w", i+1, stmt, err)
|
||
}
|
||
}
|
||
// CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,而 SQLite 又没有
|
||
// ADD COLUMN IF NOT EXISTS。已部署的库靠这一步补齐新列。
|
||
if err := addMissingColumns(ctx); err != nil {
|
||
return err
|
||
}
|
||
default:
|
||
return fmt.Errorf("migrate: 未初始化的方言")
|
||
}
|
||
|
||
if err := backfillMailReads(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := backfillMailReadsCC(ctx); err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Printf("数据库迁移完成(%s)\n", D)
|
||
return nil
|
||
}
|
||
|
||
/*
|
||
backfillMailReads 把已读模型从"邮件级"迁到"读者级"时补一次历史数据。
|
||
|
||
背景:`mails.status='read'` 原先表示"有人读过",但**没记是谁读的**。新的
|
||
mail_reads 表按读者记录,因此老数据只能推断 —— 取主收件人(to_name)作为默认读者:
|
||
绝大多数已读发生在主收件人身上,而抄送方"被代读"的情况本来就是要修掉的错。
|
||
|
||
**只能跑一次**:每次启动都跑的话,它会把"某个抄送方读过"的邮件按主收件人写成已读 ——
|
||
正是这次要修的语义错误。所以用 app_meta 里的标记守住(见 init_sqlite.sql 的注释)。
|
||
*/
|
||
func backfillMailReads(ctx context.Context) error {
|
||
const marker = "read_model_per_recipient_v1"
|
||
var v string
|
||
err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = $1`, marker).Scan(&v)
|
||
if err == nil {
|
||
return nil // 已迁过
|
||
}
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
return fmt.Errorf("backfill mail_reads: 读标记失败: %w", err)
|
||
}
|
||
|
||
// INSERT ... SELECT ... WHERE NOT EXISTS 两种方言都认;WHERE 只是防御
|
||
// (标记保证只跑一次,但万一上次中断在半路,这条能安全续上)。
|
||
res, err := DB.ExecContext(ctx, `
|
||
INSERT INTO mail_reads (mail_id, reader_name)
|
||
SELECT m.mail_id, m.to_name
|
||
FROM mails m
|
||
WHERE m.status = 'read'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = m.to_name
|
||
)`)
|
||
if err != nil {
|
||
return fmt.Errorf("backfill mail_reads: %w", err)
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
|
||
if _, err := DB.ExecContext(ctx, `INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker, fmt.Sprintf("done rows=%d", n)); err != nil {
|
||
return fmt.Errorf("backfill mail_reads: 写标记失败: %w", err)
|
||
}
|
||
// 记下切换时刻:抄送方的回填(backfillMailReadsCC)需要它划"迁移前的老邮件"这条线。
|
||
// 记不进去不算致命 —— 那条路会退化成用 MIN(read_at) 推断(见 switchoverAt)。
|
||
if _, err := DB.ExecContext(ctx,
|
||
`INSERT INTO app_meta (key, value) VALUES ($1, $2)`,
|
||
"read_model_switchover_at", time.Now().UTC().Format("2006-01-02 15:04:05")); err != nil {
|
||
return fmt.Errorf("backfill mail_reads: 写切换时刻失败: %w", err)
|
||
}
|
||
if n > 0 {
|
||
fmt.Printf("已读模型迁移:把 %d 封历史已读邮件记到主收件人名下(一次性)\n", n)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// switchoverAt 返回已读语义的切换时刻(UTC):
|
||
// 迁移时写过就用它;没写过(例如切换发生在这个键出现之前)退化成 mail_reads 里最早的一行
|
||
// —— 那张表的第一笔写入就是回填批次,所以 MIN(read_at) 恰好等于切换时刻。
|
||
func switchoverAt(ctx context.Context) (time.Time, error) {
|
||
var v string
|
||
err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = 'read_model_switchover_at'`).Scan(&v)
|
||
if err == nil {
|
||
if t, perr := time.Parse("2006-01-02 15:04:05", v); perr == nil {
|
||
return t.UTC(), nil
|
||
}
|
||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||
return time.Time{}, err
|
||
}
|
||
var raw sql.NullString
|
||
// CAST 成文本再解析:两种方言的驱动对 timestamptz 返回的类型不一样
|
||
// (PG 给 time.Time,SQLite 给字符串),文本形态才是共通的。
|
||
if err := DB.QueryRowContext(ctx, `SELECT CAST(MIN(read_at) AS TEXT) FROM mail_reads`).Scan(&raw); err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
if !raw.Valid {
|
||
return time.Now().UTC(), nil // 表还是空的:本机没有"老邮件"要照顾
|
||
}
|
||
// 认不出来的形态退回"现在":那等于把当下已有的已读邮件全算作迁移前 ——
|
||
// 首次升级时正是对的;而且这条路有一次性标记守着,不会反复跑。
|
||
for _, layout := range []string{
|
||
"2006-01-02 15:04:05.000", "2006-01-02 15:04:05",
|
||
"2006-01-02 15:04:05.999999-07", "2006-01-02 15:04:05-07",
|
||
time.RFC3339,
|
||
} {
|
||
if t, perr := time.Parse(layout, raw.String); perr == nil {
|
||
return t.UTC(), nil
|
||
}
|
||
}
|
||
return time.Now().UTC(), nil
|
||
}
|
||
|
||
/*
|
||
backfillMailReadsCC 把**抄送方**在旧模型下的状态也补上,让这次迁移对使用者是"行为不变"的。
|
||
|
||
为什么必须补(2026-09-13 实测的回归):旧模型里 `mails.status='read'` 对**所有人**都算已读。
|
||
第一版回填只把它记到主收件人名下 ⇒ 抄送方在新模型下突然看到一批"未读"的旧邮件 ⇒
|
||
桥的补投判据 `pending_mails = CountUnread` 跟着变大 ⇒ **桥一重启就把旧信当漏投重放并再次回信**。
|
||
现场证据(opencode 桥,部署后 46 分钟):
|
||
|
||
16:06:48 [mail-bridge] 已接入 http://127.0.0.1:8180,身份 opencode
|
||
16:06:49 [mail-bridge] 补投 2 封离线期间的邮件(共 2 封未读)
|
||
|
||
随后它对 05:42 那封"打个招呼"又回了两封信 —— 纯粹是我这次迁移造成的新邮件噪声。
|
||
|
||
判据:**迁移前的邮件**(created_at < 切换时刻)凡是 `status='read'`,就给它的**所有收件人**
|
||
(主收件人 + 抄送)各记一行 —— 与旧模型下"所有人看到的都是已读"完全一致。
|
||
迁移后的邮件不走这条路(那时已是按读者记录,只给真正读过的人记)—— 这条边界是本函数的判据核心。
|
||
*/
|
||
func backfillMailReadsCC(ctx context.Context) error {
|
||
const marker = "read_model_cc_backfill_v1"
|
||
var v string
|
||
err := DB.QueryRowContext(ctx, `SELECT value FROM app_meta WHERE key = $1`, marker).Scan(&v)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
return fmt.Errorf("backfill mail_reads(cc): 读标记失败: %w", err)
|
||
}
|
||
switchover, err := switchoverAt(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("backfill mail_reads(cc): 取切换时刻失败: %w", err)
|
||
}
|
||
n, err := backfillMailReadsCCSince(ctx, switchover)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := DB.ExecContext(ctx, `INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker, fmt.Sprintf("done rows=%d", n)); err != nil {
|
||
return fmt.Errorf("backfill mail_reads(cc): 写标记失败: %w", err)
|
||
}
|
||
if n > 0 {
|
||
fmt.Printf("已读模型迁移:再给 %d 个抄送方补记历史已读(迁移行为保持不变)\n", n)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// backfillMailReadsCCSince 是上面那条的一次性逻辑本体(switchover 显式传入,便于判据直接调)。
|
||
func backfillMailReadsCCSince(ctx context.Context, switchover time.Time) (int, error) {
|
||
rows, err := DB.QueryContext(ctx, `
|
||
SELECT m.mail_id, m.cc_list
|
||
FROM mails m
|
||
WHERE m.status = 'read'
|
||
AND m.created_at < $1`, switchover)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("backfill mail_reads(cc): 选区失败: %w", err)
|
||
}
|
||
type ccEntry struct {
|
||
Name string `json:"name"`
|
||
}
|
||
type pending struct {
|
||
mailID uuid.UUID
|
||
name string
|
||
}
|
||
var todo []pending
|
||
for rows.Next() {
|
||
var id uuid.UUID
|
||
var ccRaw []byte
|
||
if err := rows.Scan(&id, &ccRaw); err != nil {
|
||
rows.Close()
|
||
return 0, err
|
||
}
|
||
var ccs []ccEntry
|
||
if len(ccRaw) > 0 {
|
||
if err := json.Unmarshal(ccRaw, &ccs); err != nil {
|
||
continue // cc_list 形状不认识就跳过这封,别让整个迁移挂掉
|
||
}
|
||
}
|
||
for _, c := range ccs {
|
||
if c.Name != "" {
|
||
todo = append(todo, pending{id, c.Name})
|
||
}
|
||
}
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
inserted := 0
|
||
for _, p := range todo {
|
||
res, err := DB.ExecContext(ctx,
|
||
`INSERT INTO mail_reads (mail_id, reader_name)
|
||
SELECT $1, $2
|
||
WHERE NOT EXISTS (SELECT 1 FROM mail_reads WHERE mail_id = $1 AND reader_name = $2)`,
|
||
p.mailID, p.name)
|
||
if err != nil {
|
||
return inserted, fmt.Errorf("backfill mail_reads(cc): 插行失败: %w", err)
|
||
}
|
||
if k, _ := res.RowsAffected(); k > 0 {
|
||
inserted += int(k)
|
||
}
|
||
}
|
||
return inserted, nil
|
||
}
|
||
|
||
// splitStatements 按分号切分 SQL 脚本并剔除注释行。
|
||
// 本项目的 SQLite schema 只有 CREATE 语句,不含字符串字面量里的分号,
|
||
// 因此按分号朴素切分是安全的;若将来加入含分号的字面量需改用真正的词法切分。
|
||
func splitStatements(script string) []string {
|
||
var out []string
|
||
for _, raw := range strings.Split(script, ";") {
|
||
var lines []string
|
||
for _, line := range strings.Split(raw, "\n") {
|
||
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "--") {
|
||
continue
|
||
}
|
||
lines = append(lines, line)
|
||
}
|
||
if stmt := strings.TrimSpace(strings.Join(lines, "\n")); stmt != "" {
|
||
out = append(out, stmt)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sqliteAddColumns 声明 SQLite 侧需要在已存在的表上补齐的列。
|
||
//
|
||
// 新库由 init_sqlite.sql 的 CREATE TABLE 一次建全,这里只服务**已部署的库**。
|
||
// PG 侧用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 就够,SQLite 没有这个语法,
|
||
// 只能先查 pragma 再决定加不加。
|
||
//
|
||
// 新增列时同时改两处:init_sqlite.sql 的 CREATE TABLE(给新库)与这张表(给老库)。
|
||
var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||
{"mails", "rename_alias", "ALTER TABLE mails ADD COLUMN rename_alias TEXT"},
|
||
{"mails", "rename_reason", "ALTER TABLE mails ADD COLUMN rename_reason TEXT"},
|
||
{"sessions", "rename_dismissed", "ALTER TABLE sessions ADD COLUMN rename_dismissed TEXT"},
|
||
{"sessions", "alias_source", "ALTER TABLE sessions ADD COLUMN alias_source TEXT NOT NULL DEFAULT 'platform'"},
|
||
// 会话级往返预算(0 = 不限)。旧库默认 0:引入预算不应该把已在进行的会话卡死。
|
||
{"sessions", "max_rounds", "ALTER TABLE sessions ADD COLUMN max_rounds INTEGER NOT NULL DEFAULT 0"},
|
||
{"sessions", "used_rounds", "ALTER TABLE sessions ADD COLUMN used_rounds INTEGER NOT NULL DEFAULT 0"},
|
||
// 会话所属的工作目录。旧库默认空串:历史会话的 workspace 无法可靠反推
|
||
// (Agent 回信的 from_workspace 存的是 Agent 名而不是路径),强行回填只会
|
||
// 造出一批看起来有值实际是错的数据。
|
||
{"sessions", "workspace", "ALTER TABLE sessions ADD COLUMN workspace TEXT NOT NULL DEFAULT ''"},
|
||
// 会话级线索树的父指针(2026-10-04)。
|
||
//
|
||
// 为什么需要它:邮件级父子(`mails.parent_mail_id`)早已有,ThreadView 也早
|
||
// 在用 depth 渲染**会话内**的树 —— 但**跨会话**的分叉完全不可见:生产库里
|
||
// 有 6 封邮件的 parent 指向**另一条会话**,界面上它们是互不相干的几行。
|
||
// 而「A 把这件事交给 B 之后 B 继续推」正是协作里最常见的形状,
|
||
// 那 6 条里就有 `本机-agent-能力盘点 → 渲染自检` 这类纯分叉。
|
||
//
|
||
// 默认空:没有父的会话就是**根节点**。不据历史数据乱回填 —— 父子关系只
|
||
// 在「确实是分叉」时才成立,猜错会让线索拓扑失真(比没有更糟)。
|
||
{"sessions", "parent_session_id", "ALTER TABLE sessions ADD COLUMN parent_session_id TEXT"},
|
||
// 日历多收件人。旧库默认 '[]':读的时候由 EffectiveRecipients() 退回
|
||
// to_address / agent_name,历史事件因此继续工作,不需要数据迁移。
|
||
{"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"},
|
||
{"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"},
|
||
// 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」,
|
||
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
|
||
// 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。
|
||
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
|
||
// 日历事件的权限档位(plan / workspace / full)。事件触发时若新建会话,
|
||
// 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。
|
||
//
|
||
// 旧库默认 'workspace':历史事件补发提醒不该静默升到 full(提权路径
|
||
// 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。
|
||
// 与 sessions.permission_mode 的默认取向一致。
|
||
{"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
|
||
// 与新建会话的语义一致,不需要数据迁移。
|
||
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},
|
||
// 派给该 Agent 的新任务默认多少个来回。
|
||
// 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同,
|
||
// 不能直接搬过来当单任务预算。
|
||
{"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"},
|
||
// 会话级权限档位(plan / workspace / full)。
|
||
//
|
||
// 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||
// 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||
// 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||
{"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
|
||
// 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||
//
|
||
// 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||
// 保守方向是承认做不到,而不是假装做到了。
|
||
{"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||
// Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||
// 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是
|
||
// 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||
{"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"},
|
||
// 待决请求类型(permission/question)与多选语义,供 ask_user_question 桥接使用。
|
||
// 旧库默认 'permission'/0:历史请求是危险工具审批,语义不变。
|
||
{"permission_requests", "kind", "ALTER TABLE permission_requests ADD COLUMN kind TEXT NOT NULL DEFAULT 'permission'"},
|
||
{"permission_requests", "multi_select", "ALTER TABLE permission_requests ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0"},
|
||
// 邮件上的请求类型与多选标记(与 permission_requests 表一致)。
|
||
// 旧库默认 ''/0:历史权限邮件按单选审批渲染。
|
||
{"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"},
|
||
{"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"},
|
||
// 个人签名(顶栏「一言」的另一个来源)。
|
||
// 旧库默认空串 = 「还没设过」,客户端因此不会显示签名(而不是显示一个空白)。
|
||
{"users", "signature", "ALTER TABLE users ADD COLUMN signature TEXT NOT NULL DEFAULT ''"},
|
||
}
|
||
|
||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||
// CREATE INDEX IF NOT EXISTS 天然幂等,直接执行即可。
|
||
var sqliteAddIndexes = []string{
|
||
// 接管平台会话时按 platform_id 反查(依赖上面补的列)
|
||
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
|
||
// 人类决策后要按 mail_id 反查上游 permission id
|
||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||
// 一言句库按来源取(顶栏轮播时只挑一个来源)
|
||
"CREATE INDEX IF NOT EXISTS idx_quotes_origin ON quotes(origin)",
|
||
// 会话的「path + 别名」唯一索引 —— 它引用 sessions.workspace,
|
||
// 而那一列是**后补的**(见 sqliteAddColumns)。
|
||
//
|
||
// ★★ 2026-09-25 从 init_sqlite.sql 搬到这里,修一个**只在老库升级时暴露**的真 bug:
|
||
// 原来它住在 init_sqlite.sql,而那个文件在 addMissingColumns **之前**执行。
|
||
// 新库没问题(建表时就有 workspace);老库直接死:
|
||
// migrate sqlite (语句 #10 … idx_sessions_path_alias_uniq):
|
||
// SQL logic error: no such column: workspace
|
||
// 报错指向索引名,看着像索引写错 —— 实际是**时序**(列还没补就建索引)。
|
||
//
|
||
// 证据:`git stash` 掉当天全部改动后**同样复现** ⇒ 既有缺陷,不是新引入的。
|
||
// 生产库一直没事,因为它早就补过列(暴露面只有"从很旧的库升级")。
|
||
//
|
||
// 放这里两边都对:新库建表时已有列;老库补列后才建索引。
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq " +
|
||
"ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL",
|
||
}
|
||
|
||
func addMissingColumns(ctx context.Context) error {
|
||
for _, c := range sqliteAddColumns {
|
||
has, err := columnExists(ctx, c.table, c.column)
|
||
if err != nil {
|
||
return fmt.Errorf("migrate sqlite: 检查 %s.%s: %w", c.table, c.column, err)
|
||
}
|
||
if has {
|
||
continue
|
||
}
|
||
if _, err := DB.ExecContext(ctx, c.ddl); err != nil {
|
||
return fmt.Errorf("migrate sqlite: 补列 %s.%s: %w", c.table, c.column, err)
|
||
}
|
||
fmt.Printf("补列 %s.%s\n", c.table, c.column)
|
||
}
|
||
for _, ddl := range sqliteAddIndexes {
|
||
if _, err := DB.ExecContext(ctx, ddl); err != nil {
|
||
return fmt.Errorf("migrate sqlite: 建索引 %.60s: %w", ddl, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||
// pragma_table_info 是表函数形式的 PRAGMA,可以直接当表查(比解析 PRAGMA 输出干净)。
|
||
// table 与 column 都来自上面的硬编码常量表,不存在注入面。
|
||
var n int
|
||
err := DB.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`,
|
||
table, column).Scan(&n)
|
||
return n > 0, err
|
||
}
|