refactor(repo): workspace 谓词抽成共享构造器 + 删一个死函数

用户 2026-09-26:「审查一下服务端,我觉得现在还是有大量不符合设计的地方与冗余代码」。

# 先说审查结论:**"大量冗余"核不出来**

| 检查项 | 读数 |
| --- | --- |
| 99 个 handler | **全部注册,零死路由** |
| 死函数 | 2 个(本次删 1,另 1 个被测试用、保留) |
| 注释占比 | 23%(这个仓每个非显然决定都记"为什么",是有意的) |
| 测试 | 13644 行 = 源的 41% |

# 但找到一处真问题:`workspace` 谓词手抄了三遍

同一件事在三处各写一遍:

	args := []any{agentName}
	if strings.TrimSpace(workspace) != "" {
		args = append(args, workspace)
		q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
	}

★ 代价不是"多几行",是**加参数要改三处、漏一处不会编译报错**。
本次给三个函数加 workspace 参数(`ListInboxScoped`/`CountUnreadScoped`/
`MarkAllInboxReadForSession`)就是手抄了三遍。

同仓有同类先例:`quota.go` 里那条 `★★★ 判据自检` 记的
「占位符编号错位导致静默少行」—— 根因完全一样(同一个模板抄多处,
靠人肉保持一致)。

⇒ 抽 `workspaceScope(q, args, workspace) (string, []any)`,三处各变成一行。

# 为什么"必需"这条不在 repo 层

`checkWorkspace` **允许空**:空 = 不过滤 = 人类侧(一个人跨工作区,WebUI
按 session_workspace 分组显示)。"Agent 侧必须带"是**接口契约**,放在 Handler。

抽出来的函数注释里把这层分工写死了,免得后来者以为 repo 层该拒绝空值。

# 与 `FindOrCreateDefaultSession` 里那套**故意不共用**

那里要的是「工作区为空时从 mails 反推」(历史会话兼容),语义更宽。
合并前要先确认那是不是想要的行为 —— 现在保持分开。

# 删 `SessionMailCount`

全仓零调用(连测试都没有)。`GetSessionMailByID` 也只被两个测试用,
但它是那两个测试的被测对象,**不删**(测试专用包装与死代码不是一回事)。

# 验证

· 变异:把 `workspaceScope` 改成永远不过滤 ⇒
  `TestInboxListIsScopedByWorkspace` + `TestMarkAllReadIsScopedByWorkspace` 判红
· 12 个包通过;`internal/repo` 唯一的 FAIL
  (`TestReplacePlatformSessionsKeepsOtherWorkspaces`)**改动前就红** ——
  已用 `git stash` 式回退验证,它是 `DEBTS.json` 里记的 platform_sessions
  PK 缺陷那条判据,与本次无关。

# 顺带记一笔(对我自己的)

本机 `go` 是 1.24.4 而 `go.mod` 要求 1.25.0,**`go build` 会去下载 toolchain
并因离线失败**(exit=1)。我前面几轮用 `go build ./... | head -5 && echo "编译 ok"`
判断,把 `head` 的 exit 0 当成了编译成功 —— **那是假的**。本轮才发现,
改用本地已有的 `toolchain@v0.0.1-go1.26.7` 才拿到可信结果。
⇒ 判据里凡用 `cmd | head && echo ok` 的形状,退出码被管道最后一道吞掉,
  之后一律用 `cmd >/dev/null 2>&1; echo $?` 或显式检查 `${PIPESTATUS[0]}`。
This commit is contained in:
2026-09-26 14:08:48 +08:00
parent 7c9d1cedc9
commit 667d368a48
2 changed files with 145 additions and 25 deletions

View File

@ -620,6 +620,47 @@ func checkWorkspace(workspace string) error {
}
// unreadFor 返回"$n 这个读者看这封邮件是未读"的谓词;`m` 必须是 mails 的别名。
/*
workspaceScope 把「收窄到某个工作区」这个谓词加到一条 SQL 上,并返回追加后的实参。
# 为什么要抽出来(不是"洁癖",是已经发生过的事故)
收件箱列表、未读计数、标记已读这三个查询**必须是同一个口径**:
列表里看不到的信却被"全部标已读"标掉,就是静默丢信(session_scope_test.go 记过
这个形状:read_inbox 按会话收窄那次修的就是它)。
而这三处原本各自维护同样的三行:
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
★ 三行手抄的代价不是"多几行",而是**加参数时要改三处、漏一处不会编译报错**。
本次给三个函数加 workspace 参数就是手抄了三遍(2026-09-26)。
同类事故在本仓有先例:quota.go 的 `★★★ 判据自检` 记的"占位符编号错位导致
静默少行" —— 根因完全一样(同一个模板抄多处,靠人肉保持一致)。
# 与 `FindOrCreateDefaultSession` 里那套的区别(**故意不同**)
那里要的是「工作区为空时从邮件反推」(历史会话没有 workspace 的兼容),
语义更宽,是另一件事 —— 所以没共用这个。要合并前先确认那是不是想要的行为。
# 空值怎么办
空 = **不过滤**(人类侧跨工作区,WebUI 按 session_workspace 分组显示)。
"Agent 侧必须带 workspace"这条约束在 Handler 层(接口契约),不在这里
(数据层不变量)—— 混在一起会让人以为 repo 层该拒绝空值。
*/
func workspaceScope(q string, args []any, workspace string) (string, []any) {
if strings.TrimSpace(workspace) == "" {
return q, args
}
args = append(args, workspace)
return q + fmt.Sprintf(` AND s.workspace = $%d`, len(args)), args
}
func unreadFor(arg string) string {
return `(m.status <> 'archived' AND NOT EXISTS (
SELECT 1 FROM mail_reads r WHERE r.mail_id = m.mail_id AND r.reader_name = ` + arg + `))`
@ -746,10 +787,7 @@ func ListInboxScoped(ctx context.Context, agentName, status, workspace string, l
// 用 EXISTS 而不是再 JOIN 一次 sessions:s 已经在上面 JOIN 过了,
// 这里直接把条件写进 WHERE 即可(同一条 s)。
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
q, args = workspaceScope(q, args, workspace)
if sessionID != uuid.Nil {
// 会话收窄:只列这条线索里的邮件(见上面「会话维度」的说明)
args = append(args, sessionID)
@ -843,10 +881,7 @@ func CountUnreadScoped(ctx context.Context, agentName, workspace string, session
AND ` + unreadFor("$1") + `
AND s.status <> 'archived'`
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
q, args = workspaceScope(q, args, workspace)
if sessionID != uuid.Nil {
args = append(args, sessionID)
q += fmt.Sprintf(` AND m.session_id = $%d`, len(args))
@ -1327,13 +1362,6 @@ func aliasOwner(ctx context.Context, alias string) (*uuid.UUID, error) {
return &id, nil
}
func SessionMailCount(ctx context.Context, sessionID uuid.UUID) (int, error) {
var count int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&count)
return count, err
}
// ---------- Contacts / Archive ----------
// Contact 是「联系人」= 一条 name@path.session 三维地址