## 为什么要有
此前清理测试数据只能手工敲 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 包绿。
213 lines
7.6 KiB
Go
213 lines
7.6 KiB
Go
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()
|
||
}
|