Files
MailUI4Agents/server/internal/repo/session_tree.go
JianFeeeee dfd5661e24 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 包全绿。
2026-10-04 12:03:58 +08:00

312 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
/*
会话级线索树(2026-10-04)。
# 为什么需要它
邮件级父子(`mails.parent_mail_id`)早已有,`ThreadView` 也早在用 `depth`
渲染**会话内**的树 —— 但**跨会话**的分叉完全不可见。生产库里实测有 **6 封**邮件
的 parent 指向**另一条会话**,界面上它们是几行互不相干的东西:
deploy-pi-bridge-authz-wakeup → 邮件驱动…项目概述
harmony-emu-unblock → 邮件驱动…项目概述
邮件驱动…项目概述 → 核实-HomeAgent-mail-bridge
邮件驱动…项目概述-2 → 时间显示自检
本机-agent-能力盘点 → 渲染自检
Re-第二个请求… → 邮件驱动…项目概述-2
而「A 把这件事交给 B 之后 B 继续推」正是协作里最常见的形状。回填后它们
连成一条真链(`ba9c194b` 自己也是子节点、且有两个子节点)。
# 回填口径:只认「确实跨会话分叉」
`BackfillSessionParents` 只在满足**全部**条件时才写 `parent_session_id`:
child.session_id ≠ parent.session_id (跨会话)
parent 是 child 的**直接**父邮件所在会话 (直接,不沿链上溯)
child 在库里**还没有** parent_session_id (不回填覆盖人工设置)
★ 为什么不做「沿 parent 链上溯到根会话」:那样会把「A→B→C 三跳」压成
「A 是 C 的祖先」,而中间那条 B 线索恰恰是独立的协作对象(可能参与方不同)。
把链压平会让拓扑失真 —— 比没有树更糟。
# 环防护
`parent_session_id` 是自引用,加不加约束都可能被人填出环(A→B→A)。而
`ListSessionTree` 用递归 CTE,环会让它**无限递归**。故加两条防护:
1. 回填只写「parent 的父为空 / parent 本身不是 child 的后代」——写入侧防
2. 读取侧带 `depth < 64` 上限 —— **兜底**:即便库里已有环,也不会挂死请求
第2 条不是可有可无的:环一旦存在是**数据问题**,读取侧必须能活下来。
*/
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
)
// SessionNode 是线索树上的一个会话节点。
type SessionNode struct {
SessionID string `json:"session_id"`
Alias string `json:"alias"`
Subject string `json:"subject"`
FromAgent string `json:"from_agent"`
Workspace string `json:"workspace"`
// ParentSessionID 是线索树上的父节点。为空 = 根。
//
// ★ 服务端必须按可见性**重算**这个字段再输出:用户能看见子节点却看不见
// 父节点时,直接返回原值就等于把不可见的父(可能是别人与别人的对话)
// 的 session_id 一起泄露。
ParentSessionID string `json:"parent_session_id,omitempty"`
Depth int `json:"depth"`
ChildCnt int `json:"child_count"`
MailCnt int `json:"mail_count"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
// maxTreeDepth 是读取侧的硬上限(防环)。
const maxTreeDepth = 64
// BackfillSessionParents 按跨会话分叉回填 parent_session_id。
//
// 幂等:已有 parent_session_id 的会话不动(人工设置优先于自动推断)。
// 返回实际写入的行数。
func BackfillSessionParents(ctx context.Context) (int64, error) {
if _, err := db.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 0, fmt.Errorf("回填会话父子: %w", err)
}
var n int64
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sessions WHERE parent_session_id IS NOT NULL`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计回填结果: %w", err)
}
return n, nil
}
// ListSessionTree 返回这些会话构成的**树**(按最近活动倒序,父先于子)。
//
// parentIDs 为空时返回全部会话的**根节点**(parent 为空的那些);给了就返回
// 这些节点的子树。
//
// ★ depth 上限是**防环兜底**,不是装饰:`parent_session_id` 是自引用,
// 库里一旦有环,递归 CTE 会无限膨胀直到把进程拖垮。
func ListSessionTree(ctx context.Context, parentIDs []string) ([]SessionNode, error) {
// 根条件:parentIDs 为空时找**全部根节点**(parent 为空、或指向已不存在的会话)。
//
// ★ 这里**不能**写成 `session_id IN (NULL)` —— 那个表达式恒不匹配任何行。
// 实测踩过:那样写时 parentIDs 为空这条分支直接返回 0 个节点。
rootCond := `s.parent_session_id IS NULL
OR s.parent_session_id = ''
OR s.parent_session_id NOT IN (SELECT session_id FROM sessions)`
args := []any{maxTreeDepth}
if len(parentIDs) > 0 {
placeholders := make([]string, 0, len(parentIDs))
for _, id := range parentIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
rootCond = `s.session_id IN (` + strings.Join(placeholders, ",") + `)`
}
// CTE 列名用 lvl 而不是 depth —— depth 会与 SELECT 里的 t.depth 撞名。
// 既有 DescendantsRaw 也是 down(mail_id, lvl),照它走。
//
// ★ 计数**不能**写成相关子查询:环下递归会展开 maxTreeDepth 层,每个节点
// 都再跑一次「按 session_id 数 mails」—— 实测 2 节点环直接打满 55s 超时。
// 改成 LEFT JOIN 两个聚合,各自只算一次。
//
// ★ 参数顺序:`t.lvl < ?` 是 SQL 里的第一个占位符($1),parentIDs 随后($2…)。
// 反过来 ⇒ 根查询拿到一个整数、匹配不到任何行,树只返回递归分支那半。
q := `
WITH RECURSIVE tree(session_id, lvl) AS (
SELECT s.session_id, 0
FROM sessions s
WHERE ` + rootCond + `
UNION ALL
SELECT c.session_id, t.lvl + 1
FROM sessions c JOIN tree t ON c.parent_session_id = t.session_id
WHERE t.lvl < ?
)
SELECT s.session_id, s.session_alias, s.subject, s.from_agent,
COALESCE(s.parent_session_id, ''), s.workspace, t.lvl,
COALESCE(kids.n, 0), COALESCE(mc.n, 0),
s.updated_at
FROM tree t
JOIN sessions s ON s.session_id = t.session_id
LEFT JOIN (SELECT parent_session_id AS sid, COUNT(*) AS n
FROM sessions WHERE parent_session_id IS NOT NULL
GROUP BY parent_session_id) kids ON kids.sid = s.session_id
LEFT JOIN (SELECT session_id AS sid, COUNT(*) AS n FROM mails GROUP BY session_id) mc
ON mc.sid = s.session_id
ORDER BY s.updated_at DESC
`
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("列会话树: %w", err)
}
defer rows.Close()
out := []SessionNode{}
for rows.Next() {
var n SessionNode
var alias, subject sql.NullString
if err := rows.Scan(&n.SessionID, &alias, &subject, &n.FromAgent,
&n.ParentSessionID, &n.Workspace, &n.Depth, &n.ChildCnt, &n.MailCnt,
&n.UpdatedAt); err != nil {
return nil, fmt.Errorf("读会话树行: %w", err)
}
n.Alias = alias.String
n.Subject = subject.String
if n.Alias == "" {
n.Alias = n.Subject
}
out = append(out, n)
}
return out, rows.Err()
}
// PruneTree 按可见性剪断树:不可见的节点直接丢弃,**可见节点若其父不可见则重新起根**
// (depth 归零、parent 清空)。
//
// ★ 为什么这是安全边界而不是展示逻辑:树天然会把父节点带给子节点,
//
// 而「我能看见 B」不等于「我能看见 B 的父 A」(A 可能是别人与别人的对话)。
// 只过滤节点、不重算 parent,输出里就带着不可见父的 session_id 与标题 ——
// 一条真实的泄露路径,而且它藏在「树视图」这个新功能里。
//
// 放在 repo 层而不是 handler:可见性判断与剪枝必须和查询同一层,
// 否则 handler 里那份内联实现会与判据里那份悄悄漂移(判据测 A、线上跑 B)。
func PruneTree(nodes []SessionNode, visible map[string]bool) []SessionNode {
byID := make(map[string]SessionNode, len(nodes))
for _, n := range nodes {
byID[n.SessionID] = n
}
// 祖先链:从自己往上走,直到根或遇到不可见的节点。
// 只要**每个**祖先都可见,这一段层级全部保留;一旦某个不可见,
// 就**在它下面重新起根**(那个不可见的节点本身不输出)。
ancestors := func(id string) (chain []string, cutAtInvisible bool) {
cur := id
for cur != "" {
n, ok := byID[cur]
if !ok {
break
}
parent := n.ParentSessionID
if parent == "" {
return chain, false // 到根了
}
if !visible[parent] {
return chain, true // ★ 父不可见 ⇒ 从当前节点起重新起根
}
chain = append([]string{parent}, chain...)
cur = parent
}
return chain, true
}
out := make([]SessionNode, 0, len(nodes))
for _, n := range nodes {
if !visible[n.SessionID] {
continue
}
chain, _ := ancestors(n.SessionID)
n.Depth = len(chain)
if len(chain) == 0 {
// 自己是根(链上没有任何**可见**祖先)。
n.ParentSessionID = ""
} else {
// 挂到链上**最深的那个可见祖先**上。
// (cutAtInvisible 时 chain 首元素就是最近的那个可见祖先,
// 而不是不可见的那个 —— 不可见的绝不写进输出。)
n.ParentSessionID = chain[len(chain)-1]
}
out = append(out, n)
}
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()
}