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

@ -89,6 +89,35 @@ func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachm
return out, rows.Err()
}
// EnsureAttachable 只做**读取校验**:这批附件是否存在、属于该上传者、且尚未挂载。
//
// # 为什么要有一个「只查不改」的版本
//
// 原先只有 AttachToMail,而它在 CreateMail **之后**调用。于是附件不合法时
// (不属于我 / 已随别的邮件发出)请求返回 403/409,但那封邮件**已经入库、已经
// 通知了收件人、已经扣掉了会话预算**。实测两封探针邮件(403 与 409)都躺在库里,
// used_rounds 也涨了。发件方看到 4xx 会重试,收件方于是收到两封。
//
// 纯输入校验必须在产生任何副作用之前做完 —— 与「400 之后会话已建好」是同一个教训。
//
// 它不能取代 AttachToMail 里的原子判断:两次调用之间仍有竞态窗口
// (另一个请求把同一个附件挂走了)。那条路径靠调用方回滚,见 handler.attachAll。
func EnsureAttachable(ctx context.Context, ids []uuid.UUID, uploader string) error {
for _, id := range ids {
a, err := GetAttachment(ctx, id)
if err != nil {
return err // ErrAttachmentNotFound 或库错误
}
if a.Uploader != uploader {
return ErrAttachmentNotOwned
}
if a.MailID != nil {
return ErrAttachmentAlreadyAttached
}
}
return nil
}
// AttachToMail 把一批待挂载附件绑到某封邮件上。
//
// 每条都要求:存在、属于该上传者、且尚未挂载。
@ -212,6 +241,87 @@ func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, e
return removable, nil
}
// SweepUnreferencedBlobs 删掉磁盘上没有任何库记录指向的内容文件。
//
// # 为什么 SweepOrphanAttachments 不够
//
// 那个函数走的是 `SELECT … FROM attachments WHERE mail_id IS NULL` —— 它只能看见
// **库里还有记录**的孤儿。一旦记录本身消失(清库、手工 DELETE、迁移),
// 对应的文件就永远脱离了 GC 的视野:本机实测磁盘 8 个 blob 里 7 个没有任何库记录,
// 全部来自 09-03 那次清库,之后一直躺在那里。
//
// 这个反向清理从**磁盘**出发:枚举全部内容文件,凡是 attachments 与
// calendar_attachments 都不引用的就删。返回删掉的数量。
//
// # 为什么要 minAge
//
// 上传是「先落盘、再入库」(顺序不能反,否则会出现「库里有记录、磁盘没文件」的
// 下载 500)。那两步之间有一个窗口,此刻文件确实没有任何库记录 —— 不设年龄下限
// 会把正在上传的文件删掉。取一个远大于单次上传耗时的值。
func SweepUnreferencedBlobs(ctx context.Context, blobs BlobLister, minAge time.Duration) (int, error) {
if blobs == nil {
return 0, nil
}
sums, err := blobs.List()
if err != nil {
return 0, err
}
if len(sums) == 0 {
return 0, nil
}
// 一次查回全部被引用的 sha256。逐个文件查一次库是 N 次往返,
// 而这两张表加起来通常只有几百行。
referenced := map[string]struct{}{}
for _, q := range []string{
`SELECT sha256 FROM attachments`,
`SELECT sha256 FROM calendar_attachments`,
} {
rows, qErr := db.DB.QueryContext(ctx, q)
if qErr != nil {
return 0, qErr
}
for rows.Next() {
var s string
if sErr := rows.Scan(&s); sErr != nil {
rows.Close()
return 0, sErr
}
referenced[s] = struct{}{}
}
rows.Close()
if rErr := rows.Err(); rErr != nil {
return 0, rErr
}
}
cutoff := time.Now().Add(-minAge)
removed := 0
for sum, mod := range sums {
if _, ok := referenced[sum]; ok {
continue
}
if mod.After(cutoff) {
continue // 可能正在上传(落盘与入库之间的窗口)
}
if rErr := blobs.Remove(sum); rErr != nil {
continue // 删不掉就下一轮再试,不该让整次清理中断
}
removed++
}
return removed, nil
}
// BlobLister 是 SweepUnreferencedBlobs 需要的存储能力。
//
// 用 map[string]time.Time 而不是自定义结构体:那样 blob 包就不必 import repo
// (底层存储依赖上层仓储会很怪),而 Go 的接口是结构化匹配的,签名一致即可。
type BlobLister interface {
// List 返回 sha256 → 该内容文件的修改时间。
List() (map[string]time.Time, error)
Remove(sum string) error
}
// AttachmentAccessible 判断某人是否有权读取某附件:
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {