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:
2026-10-04 11:28:18 +08:00
parent cd894d5e4d
commit dfd5661e24
5 changed files with 391 additions and 64 deletions

View File

@ -256,3 +256,56 @@ func PruneTree(nodes []SessionNode, visible map[string]bool) []SessionNode {
}
return out
}
// SessionParentPreview 是「若现在回填,将会写入哪条父子关系」。
type SessionParentPreview struct {
SessionID string `json:"session_id"`
Alias string `json:"alias"`
WillSetParent string `json:"will_set_parent_id"`
WillSetAlias string `json:"will_set_parent_alias"`
ViaMail string `json:"via_mail_subject"`
}
// PreviewSessionParents 返回「若现在回填,会写入哪些父子关系」,**只读**。
//
// ★ 为什么要有它:用户 2026-10-04 要求「一切数据调用都要接口化」,
//
// 于是验证也必须走接口 —— 而**不得**直连库。那么「回填到底会改什么」
// 就需要一条不写任何东西的接口来回答,否则只能「先写了再看看对不对」。
//
// 顺带让无参 POST 变成安全的:默认走预览(见 handler 的 dry_run 处理)。
func PreviewSessionParents(ctx context.Context) ([]SessionParentPreview, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT c.session_id, COALESCE(c.session_alias, c.subject, ''),
ps.session_id, COALESCE(ps.session_alias, ps.subject, ''),
COALESCE(pm.subject, '')
FROM sessions c
JOIN mails m ON m.session_id = c.session_id
JOIN mails pm ON pm.mail_id = m.parent_mail_id
JOIN sessions ps ON ps.session_id = pm.session_id
WHERE c.parent_session_id IS NULL
AND ps.session_id <> c.session_id
ORDER BY c.updated_at DESC
`)
if err != nil {
return nil, fmt.Errorf("预览会话父子: %w", err)
}
defer rows.Close()
out := []SessionParentPreview{}
seen := map[string]bool{}
for rows.Next() {
var p SessionParentPreview
if err := rows.Scan(&p.SessionID, &p.Alias, &p.WillSetParent,
&p.WillSetAlias, &p.ViaMail); err != nil {
return nil, fmt.Errorf("读预览行: %w", err)
}
// 一个子节点只报一次(与 BackfillSessionParents 的 … LIMIT 1 一致)
if seen[p.SessionID] {
continue
}
seen[p.SessionID] = true
out = append(out, p)
}
return out, rows.Err()
}