feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分

L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
This commit is contained in:
2026-09-06 15:18:06 +08:00
parent a44fd6949b
commit 79c4171c9d
40 changed files with 3369 additions and 116 deletions

View File

@ -338,7 +338,39 @@ func attachAll(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, ids []u
if len(ids) == 0 {
return true
}
err := repo.AttachToMail(r.Context(), mailID, ids, uploader)
return writeAttachErr(w, repo.AttachToMail(r.Context(), mailID, ids, uploader))
}
// checkAttachable 在**产生任何副作用之前**校验附件可不可挂。
//
// 返回 false 表示已写出错误响应,调用方应立即返回。
//
// # 为什么不能只靠 attachAll
//
// attachAll 在 CreateMail **之后**调用,于是附件不合法时请求返回 403/409,
// 但那封邮件**已经入库、已经通知了收件人、已经扣掉了会话预算**。
// 生产实测:两封探针邮件(一封 403「只能附加自己上传的附件」、一封 409
// 「附件已随其他邮件发出」)都躺在 mails 表里,used_rounds 也涨了。
// 发件方看到 4xx 会重试,收件方于是收到两封。
//
// 纯输入校验必须在副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
//
// 它**不取代** attachAll:两次调用之间仍有竞态窗口(另一个请求把同一个附件
// 挂走了),那一次由 attachAll 的原子 UPDATE 拦下、并由调用方回滚。
// 双层分工:这里挡住绝大多数(拼错 id、拿别人的附件、重复挂),
// attachAll 挡住真正的并发。
func checkAttachable(w http.ResponseWriter, r *http.Request, ids []uuid.UUID, uploader string) bool {
if len(ids) == 0 {
return true
}
return writeAttachErr(w, repo.EnsureAttachable(r.Context(), ids, uploader))
}
// writeAttachErr 把 repo 层的附件错误映射成 HTTP 响应。
//
// checkAttachable 与 attachAll 共用一份:同一种错误在两条路径上必须给出同一个
// 状态码与同一句话 —— 分开写早晚会分叉,而调用方无法区分自己碰上的是哪一层。
func writeAttachErr(w http.ResponseWriter, err error) bool {
switch {
case err == nil:
return true