refactor(线索树): 回填接口化 —— 数据操作不进结构迁移通道
用户 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 包全绿。
This commit is contained in:
@ -43,20 +43,6 @@ func Migrate(ctx context.Context) error {
|
||||
return fmt.Errorf("migrate: 未初始化的方言")
|
||||
}
|
||||
|
||||
// 会话级线索树的一次性回填(2026-10-04)。
|
||||
//
|
||||
// 为什么**不**每次 Migrate 都跑:它会改**真实会话的拓扑**(给 6 条会话写上
|
||||
// parent_session_id)。若判据后来变宽、或有人手工调整过 parent,那些改动
|
||||
// 会在重启时被悄悄改回去 —— 「不可见的人工意图」正是既有 backfillMailReads
|
||||
// 用 marker 守住的原因(见它的注释:每次跑会把「某抄送方读过」按主收件人
|
||||
// 写成已读)。
|
||||
//
|
||||
// 回填口径见 repo.BackfillSessionParents:只认**直接跨会话分叉**,且不改
|
||||
// 已有 parent_session_id 的会话。
|
||||
if err := backfillSessionParentsOnce(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := backfillMailReads(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -417,53 +403,3 @@ func columnExists(ctx context.Context, table, column string) (bool, error) {
|
||||
table, column).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// backfillSessionParentsOnce 给跨会话分叉的会话补 parent_session_id,只跑一次。
|
||||
//
|
||||
// 一次性守卫的原因见 Migrate 里那段注释:它改的是会话**拓扑**,不是派生数据。
|
||||
func backfillSessionParentsOnce(ctx context.Context) error {
|
||||
const marker = "session_tree_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 session_tree: 读标记失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := DB.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET parent_session_id = (
|
||||
SELECT ps.session_id
|
||||
FROM mails m
|
||||
JOIN mails pm ON m.parent_mail_id = pm.mail_id
|
||||
JOIN sessions ps ON ps.session_id = pm.session_id
|
||||
WHERE m.session_id = sessions.session_id
|
||||
AND ps.session_id <> sessions.session_id
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE parent_session_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails m
|
||||
WHERE m.session_id = sessions.session_id
|
||||
AND m.parent_mail_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mails pm
|
||||
WHERE pm.mail_id = m.parent_mail_id
|
||||
AND pm.session_id <> sessions.session_id
|
||||
)
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("backfill session_tree: 回填失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := DB.ExecContext(ctx,
|
||||
`INSERT INTO app_meta (key, value) VALUES ($1, $2)`, marker,
|
||||
time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("backfill session_tree: 写标记失败: %w", err)
|
||||
}
|
||||
fmt.Println("会话线索树回填完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user