## 要解决的问题
用户 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 包全绿。
87 lines
2.7 KiB
Go
87 lines
2.7 KiB
Go
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)
|
||
}
|