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

@ -48,6 +48,13 @@ func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
RemindBefore int `json:"remind_before"`
Recurrence string `json:"recurrence"`
RecurrenceEnd *time.Time `json:"recurrence_end"`
// Status 在创建时存在只为与更新端点同形:前端的 CalendarEventInput 是
// **一份**类型,新建与编辑发的是同一个对象。不接这个字段的后果在
// 严格解码下是新建日程直接 400。
//
// 新建时它只能是 active(新建一个已取消的提醒没有意义),
// 但传 paused/cancelled 也不报错 —— 照字面履行比推回去更有用。
Status string `json:"status"`
}
if !DecodeBody(w, r, &req) {
return
@ -56,6 +63,14 @@ func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "Missing title")
return
}
if req.Status == "" {
req.Status = models.EventActive
}
if !validEventStatus(req.Status) {
Error(w, http.StatusBadRequest,
"status 必须是 active/paused/cancelled 之一")
return
}
if req.EventTime.IsZero() {
Error(w, http.StatusBadRequest, "Missing event_time")
return
@ -112,7 +127,7 @@ func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
RemindBefore: req.RemindBefore,
Recurrence: req.Recurrence,
RecurrenceEnd: req.RecurrenceEnd,
Status: "active",
Status: req.Status,
CreatedBy: user.Username,
}
@ -209,6 +224,16 @@ func UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
return
}
// status 直接写进库,所以必须先校验:一个拼错的值(比如 "pause")会变成
// 调度器不认识的状态 —— DueEvents 只查 active,那条提醒于是静默失效,
// 而界面下拉框里没有这个选项,人再也改不回来。
if req.Status == "" {
req.Status = models.EventActive
}
if !validEventStatus(req.Status) {
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled 之一")
return
}
recipients, badAddr := normalizeRecipients(req.Recipients)
if badAddr != "" {
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
@ -376,6 +401,12 @@ func DeleteCalendarAttachment(w http.ResponseWriter, r *http.Request) {
//
// 必须白名单而不是「未知值当 none」:把 `lunar_montly`(拼错)静默当成
// 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。
// validEventStatus 校验日历事件状态(包装 models.ValidEventStatus,与
// validRecurrence 保持同一种调用形状)。
func validEventStatus(s string) bool {
return models.ValidEventStatus(s)
}
func validRecurrence(r string) bool {
switch r {
case models.RecurNone, models.RecurDaily, models.RecurWeekly, models.RecurMonthly,