用户 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 包全绿。
312 lines
12 KiB
Go
312 lines
12 KiB
Go
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()
|
||
}
|