JianFeeeee
6e4bcd66be
fix(网关): 附件竞态回滚一件都退不掉 —— BindRelayMail 早于 attachAll 导致撞外键
## 缺陷
`/mail/send` 的回滚块注释写着「三件事都要退:邮件本身、本次往返预算、relay
幂等键」,但实际**一件都退不掉**:
relayed_mails.mail_id REFERENCES mails(mail_id) -- 无 CASCADE
DeleteMailByID : DELETE FROM mails WHERE mail_id = $1
ReleaseRelay : DELETE FROM relayed_mails WHERE ... AND mail_id IS NULL
`BindRelayMail` 原来在 `attachAll` **之前**执行,所以走到回滚块时该键已经绑上了:
- `DeleteMailByID` 撞外键失败(生产 DSN 有 `foreign_keys(1)`)⇒ 邮件留在库里;
- `ReleaseRelay` 的 WHERE 是 `mail_id IS NULL`,对已绑定的行是 no-op ⇒ 键没退。
后果与注释想避免的正好相反:发件方收到 4xx 会重试,收件方看到那封残余邮件 ——
两封。
## 修法
把 `BindRelayMail` 挪到回滚块**之后**。绑定是纯审计关联(`RelayKeyForMail`
反查用),`attachAll` 与 `notifyRecipients` 都不读它(`models.Mail` 零 relay
字段),所以推迟没有副作用。
## 判据
`TestMailSendRollbackRemovesMailAndRelayKey`:同一个 `attachment_id` 传两次 ——
`checkAttachable` 逐条查时都还没挂载(都通过),`attachAll` 的原子 UPDATE 在
第二条改到 0 行 ⇒ 409 ⇒ 回滚。这样无需真并发就能确定性地走到那条路径。
三条断言:① 触发条件成立(409,否则用例会静默退化成空跑);② 邮件没留下;
③ 幂等键退回去了。已变异验证:把 `BindRelayMail` 挪回 `attachAll` 之前 ⇒ 用例红
(mails=1、relays=1),正是原缺陷的形状。
`me.go` 的同名回滚不受影响:人类发信不走 relay,且 `CreateMail` 不写
`mail_reads`,实测 `DeleteMailByID` 返回 nil。
`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
2026-09-25 05:50:08 +08:00
..
2026-09-18 11:23:12 +08:00
2026-09-25 05:50:08 +08:00
2026-09-19 12:30:05 +08:00
2026-09-08 19:16:35 +08:00