Files
MailUI4Agents/server/internal/repo/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

213 lines
7.6 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 repo
/*
DeleteSession 真实删除一条会话及其全部从属数据(2026-10-03)。
# 为什么要有这个(而不是继续手写 SQL)
此前清理测试数据只能手工敲 sqlite3命令,而那立刻暴露了 schema 的两个
陷阱,两者都不是「照着表名删」能发现的:
**① `mails.parent_mail_id` 是自引用 `NO ACTION`**:
parent_mail_id TEXT REFERENCES mails(mail_id) ← 无 ON DELETE
删一封被别人回复过的邮件会撞上约束。那条被删的邮件若还有子回复,
就得先把子回复的 `parent_mail_id` 置空(或一并删)。手工清理时我正是
被这个绊住了一次 —— 第一版脚本只写了三个 DELETE,被 `foreign_key_check`
查出悬空引用才回头补 `UPDATE ... SET parent_mail_id = NULL`。
**② 只有 `attachments` 声明了 `ON DELETE CASCADE`,其余全是 `NO ACTION`**:
attachments → CASCADE ✔ 自动
mails → NO ACTION
mail_reads → NO ACTION
relayed_mails → NO ACTION
permission_requests → NO ACTION
session_agent_locks → NO ACTION
所以「级联」在这套 schema 里**不成立**,必须显式按依赖倒序删。少删一张
就会留下悬空引用,而悬空引用不会报错,只会让 `foreign_key_check` 在
几周后的一次体检里冒出来 —— 那时已经没人记得它是怎么来的。
# 删除顺序(从被引用者到引用者)
1. attachments(依赖 mails;虽声明 CASCADE,仍显式删以便计数准确)
2. mail_reads(依赖 mails)
3. relayed_mails(依赖 mails)
4. permission_requests(依赖 mails + sessions)
5. 断开 mails 内部的父子引用(防御性冗余,见实测说明)
6. mails(依赖 sessions)
7. session_agent_locks(依赖 sessions)
8. sessions
第 5 步在第 6 步之前。它**不是必需的**(实测:一条 DELETE 已覆盖父子),
但保留它能让「拆成两条语句」的未来改动不会静默留下悬空引用。
# 事务与返回
全在一个事务里:任何一步失败即整体回滚,不留半删状态。返回删掉的
各表行数,供调用方核对(也供判据断言)。
*/
import (
"context"
"database/sql"
"fmt"
"github.com/agentmail/gateway/internal/db"
)
// rowsDeleted 取 RowsAffected 并转成 int。
// RowsAffected 在出错时返回 (0, err);这里返回 0 而不是崩 ——
// 真删失败的话,紧随其后的 err != nil 分支已经会让整个事务回滚。
func rowsDeleted(tag sql.Result) int {
n, err := tag.RowsAffected()
if err != nil {
return 0
}
return int(n)
}
// 删除不存在的会话时返回 repo.go 里既有的 ErrSessionNotFound ——
// 不另立一个:新旧两个同义错误会让 handler 写出两套 404 分支,
// 而两套里必有一处忘了映射。
// SessionDeleteResult 是删除的实际影响面。
//
// 单独返回而不是只回 error:调用方(尤其是判据)需要知道「到底删了多少」,
// 否则一个只删了 sessions 却漏了 mails 的实现也能返回 200 ——
// 而 mails 还在意味着那封对话还在库里,用户在界面上看不到却仍在。
type SessionDeleteResult struct {
SessionID string
Mails int
Attachments int
MailReads int
Related int
Permissions int
Locks int
}
// Total 是从属行的总数(不含会话本身)。
func (r SessionDeleteResult) Total() int {
return r.Mails + r.Attachments + r.MailReads + r.Related + r.Permissions + r.Locks
}
// DeleteSession 删掉一条会话及其全部从属数据。
//
// 幂等:会话不存在返回 ErrSessionNotFound(由 handler 映射成 404),
// 而不是静默成功 —— 后者会让「删了两遍」和「删对了」无法区分。
func DeleteSession(ctx context.Context, sessionID string) (SessionDeleteResult, error) {
var res SessionDeleteResult
res.SessionID = sessionID
// 先确认会话存在。放在事务里做,避免"查到了却被并发删掉"的窗口。
if err := withTx(ctx, func(tx *sql.Tx) error {
var n int
err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sessions WHERE session_id = $1`, sessionID).Scan(&n)
if err != nil {
return fmt.Errorf("查会话: %w", err)
}
if n == 0 {
return ErrSessionNotFound
}
// 1. attachments:声明了 CASCADE,仍显式删 —— CASCADE 的 ROWS AFFECTED
// 不计入父表的返回,且我们要把计数准确报给调用方。
tag, err := tx.ExecContext(ctx,
`DELETE FROM attachments WHERE mail_id IN
(SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID)
if err != nil {
return fmt.Errorf("删附件: %w", err)
}
res.Attachments = rowsDeleted(tag)
// 2. mail_reads
tag, err = tx.ExecContext(ctx,
`DELETE FROM mail_reads WHERE mail_id IN
(SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID)
if err != nil {
return fmt.Errorf("删已读记录: %w", err)
}
res.MailReads = rowsDeleted(tag)
// 3. relayed_mails(自动转发的幂等键)
tag, err = tx.ExecContext(ctx,
`DELETE FROM relayed_mails WHERE mail_id IN
(SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID)
if err != nil {
return fmt.Errorf("删转发记录: %w", err)
}
res.Related = rowsDeleted(tag)
// 4. permission_requests(同时依赖 mails 与 sessions)
tag, err = tx.ExecContext(ctx,
`DELETE FROM permission_requests WHERE session_id = $1`, sessionID)
if err != nil {
return fmt.Errorf("删权限请求: %w", err)
}
res.Permissions = rowsDeleted(tag)
// 5. 断开 mails 内部的父子引用(**防御性冗余**,见文件头实测说明)。
//
// 同一条 `DELETE FROM mails WHERE session_id=X` 已经把父子一起删掉,
// SQLite **不会**报错(实测:NO ACTION 只在删除后仍有行引用被删行时
// 才拦,同语句内删父子是合法的)。所以这一步不是必需的。
// 留着是为了:万一将来把删除拆成「先子后父」两条语句,它是唯一
// 挡住悬空引用的东西。代价只是一条 UPDATE。
//
// parent_mail_id 是自引用 NO ACTION:会话里任何一封被回复过的邮件,
// 其子回复都指向它。直接删会在那一封上报 FOREIGN KEY failed。
// 把子回复的 parent 置空,它们本身仍在(被同一次删除覆盖),
// 于是不会留下悬空引用。
if _, err := tx.ExecContext(ctx,
`UPDATE mails SET parent_mail_id = NULL
WHERE parent_mail_id IN
(SELECT mail_id FROM mails WHERE session_id = $1)`, sessionID); err != nil {
return fmt.Errorf("断开源引用: %w", err)
}
// 6. mails
tag, err = tx.ExecContext(ctx,
`DELETE FROM mails WHERE session_id = $1`, sessionID)
if err != nil {
return fmt.Errorf("删邮件: %w", err)
}
res.Mails = rowsDeleted(tag)
// 7. session_agent_locks
tag, err = tx.ExecContext(ctx,
`DELETE FROM session_agent_locks WHERE session_id = $1`, sessionID)
if err != nil {
return fmt.Errorf("删会话锁: %w", err)
}
res.Locks = rowsDeleted(tag)
// 8. sessions
if _, err := tx.ExecContext(ctx,
`DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil {
return fmt.Errorf("删会话: %w", err)
}
return nil
}); err != nil {
return SessionDeleteResult{}, err
}
return res, nil
}
// withTx 在一个事务里跑 fn,出错回滚。
func withTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.DB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开事务: %w", err)
}
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("%w(回滚也失败: %v)", err, rbErr)
}
return err
}
return tx.Commit()
}