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

@ -0,0 +1,248 @@
package handler
/*
回填端点的判据(2026-10-04)。
★ 这批判据盯的是**接口化本身**(用户 2026-10-04:「一切数据调用都要接口化」):
1. 回填**不在** Migrate 里 —— 那是结构变更通道,让它改数据(会话拓扑)
会导致不可重跑、不可通过接口触发、看不出谁改的。
2. 端点挂在 AdminOnly 组 —— 会话拓扑是全站数据,非 admin 不该能改。
3. **默认 dry-run** —— 免得误点一次就改了 6 条真实会话的父子关系。
4. `PreviewSessionParents` 只读 —— 验证也必须走接口(不直连库),
所以需要一个「不写也能看到将发生什么」的接口。
*/
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
func readMain(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("../../cmd/server/main.go")
if err != nil {
t.Fatalf("读 main.go: %v", err)
}
return string(b)
}
// ★ 回填不得留在 Migrate 里。
func TestSessionBackfillNotInMigrate(t *testing.T) {
b, err := os.ReadFile("../db/migrate.go")
if err != nil {
t.Fatalf("读 migrate.go: %v", err)
}
src := string(b)
for _, forbidden := range []string{"parent_session_id = (", "UPDATE sessions"} {
if strings.Contains(src, forbidden) {
t.Errorf("★ migrate.go 里出现了 %q —— 回填是**数据**改写,不该混进结构迁移通道。"+
"\n 后果:不可重跑、不可经接口触发、看不出谁在什么时候改的。", forbidden)
}
}
}
// ★ 两个 admin 端点都要挂在 AdminOnly 组里。
func TestSessionTreeAdminRoutesGuarded(t *testing.T) {
src := readMain(t)
adminGroupStart := strings.Index(src, "middleware.AdminOnly")
if adminGroupStart < 0 {
t.Fatal("找不到 AdminOnly 分组")
}
// 取 AdminOnly 分组之后的一段(到下一个 Group 或文件尾)
rest := src[adminGroupStart:]
if end := strings.Index(rest[1:], "r.Group("); end > 0 {
rest = rest[:end]
}
for _, route := range []string{
"/admin/sessions/tree/backfill",
"/admin/sessions/tree/preview",
} {
if !strings.Contains(rest, route) {
t.Errorf("★ %s 不在 AdminOnly 分组里 —— 会话拓扑是全站数据,非 admin 不该能改/能看", route)
}
}
}
// ★ 默认必须 dry-run —— 测**行为**,不测源码形状。
//
// 判据演进(2026-10-04):初版读源码、数 `AdminPreviewSessionParents`
// 出现几次。变异 `dry := false`(正是初版那个真 bug)之后,判据**依然全绿**
// —— 因为 `if dry {...}` 分支在 false 时也存在于源码里。
// ⇒ 形状判据第四次被形状骗。改成真起 httptest、直接发一个不带参数的 POST,
//
// 然后去**库里**看 parent 有没有被写进去。
func TestBackfillDefaultsToDryRun(t *testing.T) {
setupTreeAdminHandlerDB(t)
ctx := context.Background()
parent := mustTreeSession(t, "父")
child := mustTreeSession(t, "子")
pm := mustTreeMail(t, parent, "父里的原邮件", "")
mustTreeMail(t, child, "子里的回信", pm)
// 不带任何参数 —— 这正是「误点一次」的场景
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/sessions/tree/backfill", nil)
AdminBackfillSessionParents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("应 200,实际 %d:%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"dry_run":true`) {
t.Errorf("★ 无参 POST 的响应应表明这是预览,实际:%s", rec.Body.String())
}
// 真正的判据在库里:不该被写进去
var got sql.NullString
if err := db.DB.QueryRowContext(ctx,
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
t.Fatalf("读父: %v", err)
}
if got.Valid {
t.Errorf("★ 无参 POST 就把 parent_session_id 写成了 %q —— 一次误点改了真实会话拓扑", got.String)
}
// 显式 dry_run=0 才写
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost,
"/api/v1/admin/sessions/tree/backfill?dry_run=0", nil)
AdminBackfillSessionParents(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("显式写入应 200,实际 %d:%s", rec2.Code, rec2.Body.String())
}
if err := db.DB.QueryRowContext(ctx,
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
t.Fatalf("读父: %v", err)
}
if got.String != parent {
t.Errorf("dry_run=0 后应写入 %s,实际 %q", parent, got.String)
}
// 预览端点在**有真实分叉待写**时也只读
child2 := mustTreeSession(t, "子2")
mustTreeMail(t, child2, "又一条回信", pm)
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet,
"/api/v1/admin/sessions/tree/preview", nil)
AdminPreviewSessionParents(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("预览应 200,实际 %d", rec3.Code)
}
if !strings.Contains(rec3.Body.String(), child2) {
t.Errorf("★ 预览应列出将要写入的 %s(验证必须有不写库的路径),实际:%s",
child2, rec3.Body.String())
}
if err := db.DB.QueryRowContext(ctx,
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child2).Scan(&got); err != nil {
t.Fatalf("读父: %v", err)
}
if got.Valid {
t.Errorf("★ 预览端点写了库(parent=%q)", got.String)
}
}
// ★ 预览接口必须只读(不能含写操作)。
func TestPreviewEndpointIsReadOnly(t *testing.T) {
b, err := os.ReadFile("../repo/session_tree.go")
if err != nil {
t.Fatalf("读 repo: %v", err)
}
src := string(b)
i := strings.Index(src, "func PreviewSessionParents")
if i < 0 {
t.Fatal("找不到 PreviewSessionParents —— 接口化后验证必须有不写库的路径")
}
// ★ 按**行**取到下一个顶格 `func ` 为止,而不是 strings.Index("\nfunc ")。
// Index 那版在两次实测里都取错了段(一次取到别的函数的 UPDATE、
// 一次在函数是文件最后一个时落空而取了全文)—— 同一族「观察窗口不对」。
// 行扫描的语义就是我要的:这个函数体到下一个函数声明为止。
lines := strings.Split(src[i:], "\n")
var body []string
for _, ln := range lines {
if len(body) > 0 && strings.HasPrefix(ln, "func ") {
break
}
body = append(body, ln)
}
seg := strings.Join(body, "\n")
// ★ 必须按**关键字边界**匹配,不能 strings.Contains。
// 初版判据对整段做 Contains("UPDATE"),而查询里的
// `ORDER BY c.updated_at DESC` 含有 "updated" ⇒ 判据把一个**只读**的
// 预览函数判成会写库。这已是本轮第三次「匹配比语义宽」:
// 前两次是 .d.ts 的 `parent_mail_id = NULL` 命中注释、
// 以及 session-{id,log} 的正则多写了一个 `\.`。
for _, w := range []string{"INSERT", "UPDATE", "DELETE"} {
re := regexp.MustCompile(`(?i)\b` + w + `\s`)
if re.MatchString(seg) {
t.Errorf("★ PreviewSessionParents 里出现 SQL 关键字 %s ⇒ 预览接口会写库", w)
}
}
}
var _ = http.StatusOK
func setupTreeAdminHandlerDB(t *testing.T) {
t.Helper()
db.Close()
path := filepath.Join(t.TempDir(), "tree-admin-handler.db")
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移测试库: %v", err)
}
t.Cleanup(db.Close)
}
func mustTreeSession(t *testing.T, alias string) string {
t.Helper()
var id string
if err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
VALUES ($1,'x','active','/tmp','pi') RETURNING session_id`, alias).Scan(&id); err != nil {
t.Fatalf("建会话: %v", err)
}
return id
}
func mustTreeMail(t *testing.T, session, subject, parent string) string {
t.Helper()
ctx := context.Background()
var parentPtr *uuid.UUID
if parent != "" {
pid, err := uuid.Parse(parent)
if err != nil {
t.Fatalf("解析父邮件: %v", err)
}
parentPtr = &pid
}
sid, err := uuid.Parse(session)
if err != nil {
t.Fatalf("解析会话: %v", err)
}
if _, err := repo.CreateMail(ctx, sid, parentPtr, "pi", "/tmp", "dsh", "/tmp",
subject, "", nil); err != nil {
t.Fatalf("建邮件: %v", err)
}
var id string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_id FROM mails WHERE session_id = ? ORDER BY created_at DESC LIMIT 1`,
session).Scan(&id); err != nil {
t.Fatalf("取邮件: %v", err)
}
return id
}