用户 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 包全绿。
84 lines
2.8 KiB
Go
84 lines
2.8 KiB
Go
package handler
|
||
|
||
/*
|
||
POST /api/v1/admin/sessions/tree/backfill —— 会话级线索树回填(2026-10-04)。
|
||
|
||
# 为什么是接口而不是 Migrate 里的自动步骤
|
||
|
||
用户 2026-10-04 明确要求「一切数据调用都要接口化」。而回填是**数据操作**:
|
||
它改真实会话的拓扑、可重跑、需要留审计痕迹。`Migrate` 是**结构**变更通道
|
||
(建表、加列),让<EFBFBD><EFBFBD>兼做数据改写是混用通道 —— 后果是不可重跑、不可通过
|
||
接口触发、看不出谁在什么时候改的。
|
||
|
||
⇒ 从 Migrate 移到这里,挂在 AdminOnly 组下(同一档的还有
|
||
`DELETE /admin/sessions/{id}`、`POST /admin/agent-keys`)。
|
||
|
||
# dry_run:先看后写
|
||
|
||
默认 `dry_run=1` —— **只返回将要写入的父子关系,不落库**。回填会改会话拓扑,
|
||
不能靠「先写了再检查对不对」。管理员确认后显式传 `dry_run=0` 才真正写入。
|
||
|
||
# 幂等
|
||
|
||
`BackfillSessionParents` 只写 `parent_session_id IS NULL` 的会话,
|
||
所以重跑安全;人工设置过的父**不会被覆盖**(判据
|
||
`TestBackfillSessionParentsRunsOnlyOnce`)。
|
||
*/
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
)
|
||
|
||
// AdminPreviewSessionParents 只读预览:若现在回填,会写哪些父子关系。
|
||
func AdminPreviewSessionParents(w http.ResponseWriter, r *http.Request) {
|
||
preview, err := repo.PreviewSessionParents(r.Context())
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "预览会话父子失败")
|
||
return
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"would_set": preview,
|
||
"count": len(preview),
|
||
"dry_run": true,
|
||
})
|
||
}
|
||
|
||
// AdminBackfillSessionParents 执行回填。
|
||
//
|
||
// 默认 dry-run;必须显式 `dry_run=0` 才写 —— 免得误点一次就改了真实拓扑。
|
||
func AdminBackfillSessionParents(w http.ResponseWriter, r *http.Request) {
|
||
// ★ 默认 dry-run,**且 `dry_run` 未指定时也走预览**。
|
||
//
|
||
// 初版写成 `if v := …; v != "" { … }` —— 参数缺失时整个 if 落空,
|
||
// 直接执行回填。也就是注释写着「默认 dry-run」而代码**默认就写**。
|
||
// 后果:误点一次 POST 就改了 6 条真实会话的拓扑,且没有任何预览。
|
||
// 判据 TestBackfillDefaultsToDryRun 抓到的就是这一条(它红得对)。
|
||
dry := true // 默认预览
|
||
if v := r.URL.Query().Get("dry_run"); v != "" {
|
||
parsed, err := strconv.ParseBool(v)
|
||
if err != nil {
|
||
Error(w, http.StatusBadRequest, "dry_run 必须是 true/false")
|
||
return
|
||
}
|
||
dry = parsed
|
||
}
|
||
if dry {
|
||
AdminPreviewSessionParents(w, r)
|
||
return
|
||
}
|
||
|
||
n, err := repo.BackfillSessionParents(r.Context())
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "回填会话父子失败")
|
||
return
|
||
}
|
||
JSON(w, http.StatusOK, map[string]any{
|
||
"status": "backfilled",
|
||
"parents": n,
|
||
"dry_run": false,
|
||
})
|
||
}
|