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() }