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

@ -304,6 +304,13 @@ func main() {
// 刻意**不进 MCP 工具面**:调用方是模型,误判一次就是真丢数据。
// Agent 要结束线索走归档(那是不可逆性低得多的操作)。
r.Delete("/admin/sessions/{id}", handler.AdminDeleteSession)
// 会话级线索树回填(2026-10-04)。★ 是接口而非 Migrate 里的
// 自动步骤:它改的是**数据**(真实会话拓扑)而不是结构,
// 混用迁移通道会导致不可重跑、不可审计、看不出谁改的。
// 默认 dry-run,显式 dry_run=0 才写入。
r.Post("/admin/sessions/tree/backfill", handler.AdminBackfillSessionParents)
r.Get("/admin/sessions/tree/preview", handler.AdminPreviewSessionParents)
r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey)
// Agent 发信配额

View File

@ -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
}

View File

@ -0,0 +1,83 @@
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,
})
}

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
}

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()
}