Files
MailUI4Agents/server/internal/handler/session_delete.go
JianFeeeee d284f1f0af feat(admin): 真实删除会话的 API —— DELETE /api/v1/admin/sessions/{id}
## 为什么要有

此前清理测试数据只能手工敲 sqlite3,而那立刻暴露了这套 schema 的两个陷阱,
两者都不是「照着表名删」能发现的:**级联在这套 schema 里不成立**。

    attachments        → CASCADE    ✔ 唯一声明了自动的
    mails              → NO ACTION
    mail_reads         → NO ACTION
    relayed_mails      → NO ACTION
    permission_requests→ NO ACTION
    session_agent_locks→ NO ACTION

漏删任何一张都不会报错,只会在几周后的一次体检里以 `foreign_key_check`
悬空引用的形式冒出来 —— 那时已经没人记得它是怎么来的。

实现按依赖倒序删 7 张表,全在一个事务里(任何一步失败即整体回滚;
半删比不删更糟:会话没了但邮件还在,而用户以为已经删干净了)。

返回**实际删掉的行数**而不是只回 200:一个只删了 sessions 却漏了 mails 的
实现也能返回 200,而 mails 还在意味着那封对话在界面上看不到却仍在库里。

## 两个设计决定

**① 挂 `AdminOnly` 组,不挂 `UserAuth` 组。**
会话是多方的协作记录(多个 Agent + 人类的往来)。`UserAuth` 组里任何登录
用户都能看到自己的全部会话 —— 放那里等于让任何人删别人的历史。

**② 刻意不进 MCP 工具面。**
删除不可撤销,而 MCP 的调用方是**模型**:误判一次就是真丢数据。
与 `connect_to_server` 刻意不提供改坐标参数同一条原则 ——
**不可逆的运维动作不进模型可及的面**。Agent 要结束线索走归档。

## ★ 实现中测出的两件事(都改了我的判断)

**① `parent_mail_id` 那步不是必需的 —— 我一开始写错了注释和判据**

我以为「有回复链时删除会撞外键约束」。实测:

    DELETE FROM mails WHERE session_id = X  →  同语句内删父子,SQLite 不报错

`NO ACTION` 只在删除后**仍有行**引用被删行时才拦,同语句内删父子是合法的。
所以那步是**防御性冗余**(为「将来拆成两条语句」那件事留的),
注释已改为陈述实测,不再说它必需。真正必须先处理的是 schema 本身:
历史数据里已有 7 条悬空引用,是早于这套代码的既存违规。

**② 判据自身出了两次假绿,都是同一个原因:观察方式比语义宽**

| 变异 | 表面 | 真相 |
|---|---|---|
| 撤掉 mails 删除 | 0 红 | 变异**没应用**(按字符串匹配命中了文件头注释) |
| 撤掉 parent 断开 | 0 红 | 变异确实应用了,但判据**断言了一个 SQLite 不提供的保证** |

第二次值得记:我写了个「删除是否生效」的断言,它报「未生效」,我一度以为
删除失败 —— 实际是**全文搜 `parent_mail_id = NULL` 命中了文件头注释里
同一句话**。断言本身写错了,不是删除错了。

改用**代码行特征**(反引号包裹的 SQL / 错误文案)判定后,两个真实变异都转红:

    漏删 mails            → 2 格红 ✓
    漏删 session_agent_locks → 1 格红 ✓

## 判据(5 格)

除上面两条,另含:删不存在的会话必须报 `ErrSessionNotFound`(幂等返回 200
会让「重试」与「成功」不可区分);不得误删别的会话;中途失败必须整体回滚
(用触发器注入失败,断言会话与邮件都还在)。

全量 14 包绿。
2026-10-03 13:41:02 +08:00

68 lines
2.2 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
/*
真实删除会话的 Web API(2026-10-03)。
# 为什么放在 admin 组而不是 UserAuth 组
会话是**协作记录**:里面有多个 Agent 与人类的多轮往来,删除后不可恢复,
且其中任何一方的历史都会随之消失。而 UserAuth 组里的任何登录用户都能
看到自己的全部会话 —— 把「删掉别人的协作记录」放在那里,等于让任何一个
登录用户都能删别人的东西。
所以路由挂在 `AdminOnly` 下(见 cmd/server/main.go),与
`DELETE /admin/users/{id}`、`DELETE /admin/agent-keys/{id}` 同一档。
# 为什么不提供 Agent 侧的删除工具
MCP 工具面(server/internal/mcp)**刻意**没有暴露删除:
· 删除不可撤销,而 MCP 的调用方是模型 —— 模型误判一次就是真丢数据
· 现有 MCP 工具里最接近破坏性的 `forward_mail` 也只是发信,撤销成本很低
· `connect_to_server` 已经刻意不提供改坐标的参数,同一条原则:
**不可逆的运维动作不进模型可及的面**
Agent 要「结束」一条线索,走归档(`ArchiveSession`)—— 那才是协作语义里
对应的操作。
*/
import (
"errors"
"net/http"
"github.com/agentmail/gateway/internal/repo"
)
// AdminDeleteSession 真实删除一条会话及其全部从属数据。
//
// 返回实际删掉的行数,调用方可以核对「删干净了」——
// 只回 200 的话,一个漏删 mails 的实现看起来同样成功。
func AdminDeleteSession(w http.ResponseWriter, r *http.Request) {
id, ok := pathUUID(w, r, "id")
if !ok {
return
}
res, err := repo.DeleteSession(r.Context(), id.String())
if err != nil {
if errors.Is(err, repo.ErrSessionNotFound) {
Error(w, http.StatusNotFound, "会话不存在")
return
}
Error(w, http.StatusInternalServerError, "删除会话失败")
return
}
JSON(w, http.StatusOK, map[string]any{
"status": "deleted",
"id": res.SessionID,
"deleted": map[string]int{
"mails": res.Mails,
"attachments": res.Attachments,
"mail_reads": res.MailReads,
"relayed_mails": res.Related,
"permission_requests": res.Permissions,
"session_agent_locks": res.Locks,
},
"total": res.Total(),
})
}