Files
MailUI4Agents/server/internal/handler/session_tree.go
JianFeeeee cd894d5e4d feat(线索树)★★: 会话级父子 + 树视图 —— 跨会话分叉第一次可见
## 要解决的问题

用户 2026-10-04:「对话树实现得非常原始,根本没有形成/展示为树结构」。

★ 先纠正我自己的一个误判:我先前只看 `ContactPanel`(会话列表)就断言
「前端纯平铺、零层级」。那是不完整的检查 —— `ThreadView.tsx` 早就在用
`node.depth` 做缩进 + 连接线渲染**会话内**的邮件树,服务端 `DescendantsRaw`
也早就有 `WITH RECURSIVE … lvl`。所以**会话内**的树是有的。

真正缺的是**跨会话**:`sessions` 表根本没有 `parent_session_id`,
而生产库实测有 **6 封**邮件的 parent 指向**另一条会话**,
界面上它们是几行互不相干的东西。而「A 交给 B 之后 B 继续推」正是
协作里最常见的形状 —— 那 6 条里就有 `本机-agent-能力盘点 → 渲染自检`
这种纯分叉。

## 回填后的真实拓扑(生产数据)

    harmony-emu-unblock ─┐
    deploy-pi-bridge-…  ─┴→ 邮件驱动…项目概述 ─┬→ 核实-HomeAgent-mail-bridge
                                              └→ 邮件驱动…项目概述-2 → 时间显示自检
    本机-agent-能力盘点 → 渲染自检

## 可见性:逐节点过滤 + 剪断不可见祖先(安全边界)

树天然会把父节点带给子节点,而「我能看见 B」≠「我能看见 B 的父 A」
(A 可能是别人与别人的对话)。

所以按节点过滤之后**必须重算 parent**:不可见的祖先一律跳过、树在可见处
重新起根。只过滤不重算,输出里就带着不可见父的 session_id 与标题 ——
一条真实的泄露路径,而且它藏在「树视图」这个新功能里,没人会想到去查。

**admin 看全部**(用户 2026-10-04 定的分层)。

★ 与 Agent 侧那条边界无关:`AgentMayReadSession` 是 15e4fe9 / 095213b
修出来的越权防护(Agent 只能读自己参与过的会话);本端点属人类登录态,
admin 全看是显式授权的。两者语义不同,不要混谈。

## 回填只跑一次

回填改的是会话**拓扑**,不是派生数据。若每次 Migrate 都跑,
「某人手工把 parent 改对」会在每次重启时被悄悄改回去 —— 与既有
`backfillMailReads` 同族(其注释:每次跑会把「某抄送方读过」按主收件人
写成已读,正是那次要修的语义错误)。故用 `app_meta` marker 守住。

★ 判据分两包:`repo` 测回填口径与剪枝,`db` 测「二次 Migrate 不覆盖人工设置」。
只写在 repo 包就只测到「回填幂等」,测不到 Migrate 那一层。

## 实现中修掉的三个真 bug(都被判据抓到)

1. **参数顺序反了** —— `t.lvl < ?` 是 SQL 里第一个占位符,我放在 args 末尾
   ⇒ 根查询拿到一个整数、匹配不到任何行,树只返回递归分支那半。
2. **环下打满 55s 超时** —— 计数写成相关子查询,环下每个节点都重跑一次
   mails 计数。改成 `LEFT JOIN` 两个聚合后 0.01s。
3. **`IN (NULL)`** —— parentIDs 为空时该表达式恒不匹配任何行。

## 判据(8 格)

含防环(环下 0.01s + 耗时断言)、不覆盖人工父、不把同会话内邮件父子
当会话父子、不回填不可见祖先(两个变异分别去掉「过滤」「剪断」都转红)。

★ 判据自己错了一次:「隐藏不可见祖先」那格我写成「对每个可见节点都断言
depth==0」,但 B 重起根(0)、C 仍挂在 B 下(1),两者不同 —— 判据红而代码
是对的。(又是「判据比语义宽/窄」那一族。)

14 包全绿。
2026-10-04 12:03:58 +08:00

87 lines
2.7 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 handler
/*
GET /api/v1/sessions/tree —— 会话级线索树(2026-10-04)。
# 可见性:逐节点过滤,且 admin 看全部
用户点开树时看到的是「**他能看到的**那部分树」。做法是**按节点过滤**而不是
按整棵树放行 —— 否则一条自己参与的线索会把它的**父节点**(可能是别人与别人
的对话)连标题一起带出来。
`UserCanAccessSession` 的口径与既有端点一致:会话 owner,或该用户在会话里有
过收发。这是「我参与过」,不是「我建过的」。
**admin 看全部**(用户 2026-10-04 明确要求分层):`IsAdmin()` 时跳过逐节点
过滤。理由是协作排查往往需要看**别人**的分叉 —— 那正是线索树的用途
(「A 交给 B 之后 B 去了哪」跨会话,只看自己那半边永远看不出来)。
★ 这与 Agent 侧那条边界无关:`AgentMayReadSession`(Agent 只能读自己参与过的
会话)是 15e4fe9 / 095213b 两处越权修出来的,**本端点属于人类登录态**,
且 admin 全看是用户显式授权的。两者不要混为一谈。
# parent 链要**剪断**
一个用户能看见节点 A、看不见它的父 P —— 若照样把 P 的信息发出去就泄露了。
所以输出前按可见性**重算 parent**:不可见的祖先一律跳过,树在可见处重新起根。
*/
import (
"net/http"
"github.com/google/uuid"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
)
func ListSessionTree(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
all, err := repo.ListSessionTree(r.Context(), nil)
if err != nil {
Error(w, http.StatusInternalServerError, "列会话树失败")
return
}
byID := make(map[string]repo.SessionNode, len(all))
for _, n := range all {
byID[n.SessionID] = n
}
// 可见性:逐节点判;admin 全看。
visible := make(map[string]bool, len(all))
if user.IsAdmin() {
for _, n := range all {
visible[n.SessionID] = true
}
} else {
for _, n := range all {
ok, err := repo.UserCanAccessSession(r.Context(), user, uuidOf(n.SessionID))
if err != nil {
// 判据查询失败 ⇒ fail-closed(当不可见),不放行。
continue
}
visible[n.SessionID] = ok
}
}
// 剪断不可见的祖先(实现在 repo 层,与判据共用同一份)
out := repo.PruneTree(all, visible)
JSON(w, http.StatusOK, map[string]any{
"nodes": out,
"admin": user.IsAdmin(),
"total": len(all),
"visible": len(out),
})
}
func uuidOf(s string) uuid.UUID {
return uuid.MustParse(s)
}