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:
@ -333,32 +333,53 @@ func bootstrapAdmin(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// sweepOrphanAttachments 周期清理「已上传但从未随邮件发出」的附件。
|
||||
// sweepOrphanAttachments 周期清理无人引用的附件。
|
||||
//
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下孤立记录与文件。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程,也不至于让废弃文件长期占盘。
|
||||
// 两个方向,缺一不可:
|
||||
//
|
||||
// 1. **正向**(repo.SweepOrphanAttachments):库里还有记录但从未挂到邮件上。
|
||||
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下这类记录。
|
||||
// 保留 24 小时再清:足以覆盖一次正常的写信过程。
|
||||
//
|
||||
// 2. **反向**(repo.SweepUnreferencedBlobs):磁盘上有文件但库里连记录都没有。
|
||||
// 一旦记录本身消失(清库、手工 DELETE、迁移),正向那条 SQL 就永远看不见它——
|
||||
// 本机实测磁盘 8 个 blob 里 7 个属于这种,全部来自一次清库,之后一直占着盘。
|
||||
//
|
||||
// 反向清理的年龄下限取得比正向更宽(48 小时):它删的是「库里查无此物」的文件,
|
||||
// 判据比正向弱,多留一天换取更小的误删面。上传窗口(落盘与入库之间)也靠它兜住。
|
||||
func sweepOrphanAttachments(blobs *blob.Store) {
|
||||
const (
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
interval = 1 * time.Hour
|
||||
keepFor = 24 * time.Hour
|
||||
keepUnlinked = 48 * time.Hour
|
||||
)
|
||||
|
||||
sweep := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 正向失败不能 return:反向那一步与它相互独立,
|
||||
// 一层坏掉不该让另一层也停工。
|
||||
sums, err := repo.SweepOrphanAttachments(ctx, keepFor)
|
||||
if err != nil {
|
||||
log.Printf("附件 GC 失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, sum := range sums {
|
||||
if err := blobs.Remove(sum); err != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], err)
|
||||
} else {
|
||||
for _, sum := range sums {
|
||||
if rErr := blobs.Remove(sum); rErr != nil {
|
||||
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], rErr)
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立记录", len(sums))
|
||||
}
|
||||
}
|
||||
if len(sums) > 0 {
|
||||
log.Printf("附件 GC 清理了 %d 个孤立文件", len(sums))
|
||||
|
||||
// 反向:库里查无此物的磁盘文件。与正向分开报数——
|
||||
// 两个数字的含义不同,合成一个会让「哪一层在漏」看不出来。
|
||||
if n, uErr := repo.SweepUnreferencedBlobs(ctx, blobs, keepUnlinked); uErr != nil {
|
||||
log.Printf("附件反向 GC 失败: %v", uErr)
|
||||
} else if n > 0 {
|
||||
log.Printf("附件反向 GC 清理了 %d 个无引用文件", n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
gateway/internal/blob/list_test.go
Normal file
176
gateway/internal/blob/list_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package blob
|
||||
|
||||
// List() 的测试 —— 反向 GC 的前提。
|
||||
//
|
||||
// 事故背景:附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦
|
||||
// 记录本身消失(清库、手工 DELETE、迁移),对应文件就永远脱离了视野。本机实测
|
||||
// 磁盘 8 个 blob 里 7 个没有任何库记录,全部来自一次清库,之后一直占着盘。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestListReturnsStoredContents(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
|
||||
sumA, _, err := s.Put(bytes.NewReader([]byte("alpha")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put a: %v", err)
|
||||
}
|
||||
sumB, _, err := s.Put(bytes.NewReader([]byte("beta")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put b: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("应列出 2 个内容,实际 %d:%v", len(got), got)
|
||||
}
|
||||
for _, sum := range []string{sumA, sumB} {
|
||||
mod, ok := got[sum]
|
||||
if !ok {
|
||||
t.Errorf("缺少 %s", sum[:8])
|
||||
continue
|
||||
}
|
||||
if mod.IsZero() {
|
||||
t.Errorf("%s 的修改时间为零值 —— GC 靠它判断「是否可能正在上传」", sum[:8])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEmptyStore(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("空库不该报错: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("空库应返回空,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **关键用例**:`.upload-*` 临时文件绝不能进列表。
|
||||
//
|
||||
// 报给调用方会让 GC 去删一个正在写入的文件 —— 上传是「先落盘再入库」,
|
||||
// 那一瞬间的临时文件既没有库记录也不是合法 sha256 命名。
|
||||
func TestListSkipsTempUploads(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("real")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// 模拟一个正在进行的上传
|
||||
tmp, err := os.CreateTemp(root, ".upload-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmp.WriteString("half written")
|
||||
tmp.Close()
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("只该列出 1 个真实内容,实际 %d:%v", len(got), got)
|
||||
}
|
||||
if _, ok := got[sum]; !ok {
|
||||
t.Errorf("真实内容 %s 应在列表里", sum[:8])
|
||||
}
|
||||
for k := range got {
|
||||
if strings.HasPrefix(k, ".upload-") {
|
||||
t.Errorf("临时文件 %q 不该出现在列表里 —— GC 会删掉一个正在写入的文件", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非 sha256 命名的异物一律忽略(人手工丢进去的、别的程序留下的)。
|
||||
func TestListSkipsForeignFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if _, _, err := s.Put(bytes.NewReader([]byte("real")), 0); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
// 放几个不合法命名的文件在两级目录里
|
||||
for _, name := range []string{"README", "ABCDEF", "notasha256"} {
|
||||
dir := filepath.Join(root, "ab", "cd")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("异物不该被列出,实际 %d:%v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 修改时间必须是真实的文件时间 —— GC 用它跳过「可能正在上传」的文件。
|
||||
func TestListReportsRealModTime(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
before := time.Now().Add(-time.Second)
|
||||
sum, _, err := s.Put(bytes.NewReader([]byte("timed")), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
after := time.Now().Add(time.Second)
|
||||
|
||||
got, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
mod := got[sum]
|
||||
if mod.Before(before) || mod.After(after) {
|
||||
t.Fatalf("修改时间 %v 不在 [%v, %v] 内", mod, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 根目录不存在时必须报错,不能返回空列表。
|
||||
//
|
||||
// 返回空会让调用方以为「库里什么都没有」—— 那个判断会传导到 GC 的计数上,
|
||||
// 让运维以为磁盘是干净的。
|
||||
func TestListFailsOnMissingRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatalf("rm root: %v", err)
|
||||
}
|
||||
if _, err := s.List(); err == nil {
|
||||
t.Fatal("根目录不存在时必须报错,返回空列表会让调用方以为库是空的")
|
||||
}
|
||||
}
|
||||
@ -16,9 +16,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store 是附件的磁盘存储。
|
||||
@ -133,6 +135,46 @@ func (s *Store) Exists(sum string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// List 枚举库中全部内容文件的 sha256 与修改时间。
|
||||
//
|
||||
// # 为什么需要它
|
||||
//
|
||||
// 附件 GC 原先只从**库记录**出发(`WHERE mail_id IS NULL`),于是一旦记录本身
|
||||
// 消失(清库、手工 DELETE、迁移),对应文件就永远脱离了视野:本机实测磁盘 8 个
|
||||
// blob 里 7 个没有任何库记录,全部来自 09-03 那次清库,之后一直占着盘。
|
||||
//
|
||||
// 反向清理必须能枚举磁盘,因此这个方法是 `repo.SweepUnreferencedBlobs` 的前提。
|
||||
//
|
||||
// 只认文件名是合法 sha256 的项:`.upload-*` 临时文件不属于内容库,
|
||||
// 把它们报给调用方会让 GC 去删一个正在写入的文件。
|
||||
func (s *Store) List() (map[string]time.Time, error) {
|
||||
out := map[string]time.Time{}
|
||||
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// 单个子目录读不了不该让整次枚举失败 —— 但**根目录**读不了必须报:
|
||||
// 那时返回空 map 会让调用方以为「库里什么都没有」,
|
||||
// 于是把仍被引用的文件当成孤儿(这里不会删,但计数会骗人)。
|
||||
if path == s.root {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !sha256Re.MatchString(d.Name()) {
|
||||
return nil // .upload-* 临时文件与其他异物
|
||||
}
|
||||
info, iErr := d.Info()
|
||||
if iErr != nil {
|
||||
return nil
|
||||
}
|
||||
out[d.Name()] = info.ModTime()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blob: 枚举 %s: %w", s.root, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Remove 删除某内容。
|
||||
//
|
||||
// 注意:内容寻址意味着多条附件记录可能指向同一个文件,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
@ -25,9 +27,170 @@ func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Decode 从请求体解析 JSON
|
||||
// Decode 从请求体解析 JSON。**拒绝未知字段。**
|
||||
//
|
||||
// # 为什么必须严格
|
||||
//
|
||||
// 宽容解码把「字段名写错」变成一种**静默成功**:请求返回 200,服务端却什么都
|
||||
// 没收到。生产实测过最坏的一种形状 —— homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`:
|
||||
//
|
||||
// $ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}'
|
||||
// HTTP 200 {"mail_id":"2a64fdc8…", …}
|
||||
// $ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'"
|
||||
// 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 在库里活了很久 ——
|
||||
// **正因为没人会去核对一个返回 200 的请求**。
|
||||
//
|
||||
// 严格解码把它变成一个当场可见的 400。这是 `I-5`(失败必须当场可见)在
|
||||
// 请求解析层的落点:宁可让调用方收到一句「字段 X 不认识」,
|
||||
// 也不要让它以为自己传的东西生效了。
|
||||
//
|
||||
// 需要宽容的地方只有一处(心跳,见 DecodeLenient),且必须显式说明理由。
|
||||
func Decode(r *http.Request, v interface{}) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(v)
|
||||
}
|
||||
|
||||
// DecodeLenient 解析请求体但**容忍未知字段**,同时把认不出的字段名报回来。
|
||||
//
|
||||
// 只给心跳用,理由是那条路径的职责是「我还活着」:插件比服务端新、多带了一个
|
||||
// 服务端还不认识的字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。
|
||||
//
|
||||
// 但**容忍不等于咽下去**。返回的 unknown 列表必须被调用方回报给插件
|
||||
// (心跳响应里的 `unknown_fields`),否则又变成一次静默忽略 —— 那正是
|
||||
// `attachments` vs `attachment_ids` 能拖那么久的原因。
|
||||
//
|
||||
// 实现上要解两遍(宽容一遍取值、严格一遍找未知字段),所以先把 body 读进内存。
|
||||
func DecodeLenient(r *http.Request, v interface{}) (unknown []string, err error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxLenientBodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 取值这一遍必须宽容:未知字段不能让整个心跳体作废。
|
||||
if uErr := json.Unmarshal(raw, v); uErr != nil {
|
||||
return nil, uErr
|
||||
}
|
||||
|
||||
// 再严格解一遍**只为找出未知字段**。json 每遇到一个未知字段就立即返回,
|
||||
// 所以要循环剥:不循环的话「多带了三个字段」只会报出第一个。
|
||||
probeType := reflect.TypeOf(v)
|
||||
for probeType != nil && probeType.Kind() == reflect.Ptr {
|
||||
probeType = probeType.Elem()
|
||||
}
|
||||
if probeType == nil {
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < maxUnknownFieldsReported; i++ {
|
||||
probe := reflect.New(probeType).Interface()
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
dErr := dec.Decode(probe)
|
||||
if dErr == nil {
|
||||
break
|
||||
}
|
||||
name := unknownFieldName(dErr)
|
||||
// 不是未知字段错误(宽容那遍已经成功,所以这里本不应出现其他错),
|
||||
// 或者同一个名字又出现一次 —— 都说明剥不下去了,停。
|
||||
if name == "" || seen[name] {
|
||||
break
|
||||
}
|
||||
seen[name] = true
|
||||
unknown = append(unknown, name)
|
||||
stripped, sErr := stripTopLevelKey(raw, name)
|
||||
if sErr != nil {
|
||||
break
|
||||
}
|
||||
raw = stripped
|
||||
}
|
||||
return unknown, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// maxLenientBodyBytes 是心跳体的读取上限。会话快照 200 条 + 模型目录 300 条,
|
||||
// 每条百来字节,2MB 有充足余量;超出的部分被截断后 json 解析会报错,
|
||||
// 那正是我们想要的(一个畸形巨大的心跳体不该被当成有效上报)。
|
||||
maxLenientBodyBytes = 2 << 20
|
||||
// maxUnknownFieldsReported 是回报的未知字段数上限。
|
||||
// 报头几个足够定位问题,无上限循环会让一个塞满垃圾键的请求变成 CPU 消耗。
|
||||
maxUnknownFieldsReported = 8
|
||||
)
|
||||
|
||||
// stripTopLevelKey 从一个 JSON 对象里删掉一个顶层键。
|
||||
//
|
||||
// 只动顶层:未知字段错误报的就是顶层键名。嵌套结构里的未知字段报的名字
|
||||
// 在顶层找不到,这里返回错误,循环随即停下 —— 那个字段仍会被报出来。
|
||||
func stripTopLevelKey(raw []byte, key string) ([]byte, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := m[key]; !ok {
|
||||
return nil, errors.New("key not at top level")
|
||||
}
|
||||
delete(m, key)
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// unknownFieldName 从 encoding/json 的未知字段错误里取出那个字段名。
|
||||
//
|
||||
// json 包没有为这种错误定义类型(返回的是 *errors.errorString),
|
||||
// 只能按文本匹配 `json: unknown field "xxx"`。
|
||||
// 匹配不上时返回空串,调用方回落到笼统文案。
|
||||
func unknownFieldName(err error) string {
|
||||
const prefix = `json: unknown field "`
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, prefix)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := msg[i+len(prefix):]
|
||||
j := strings.IndexByte(rest, '"')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// jsonFieldNames 反射列出一个请求结构体接受的 JSON 键。
|
||||
//
|
||||
// 用途是把「字段 X 不认识」补成「应为 a / b / c 之一」——
|
||||
// 少了这半句,调用方只知道自己错了,仍要去翻服务端源码才知道对的是什么。
|
||||
// 那正是 `attachments` vs `attachment_ids` 当初拖了那么久的原因。
|
||||
func jsonFieldNames(v interface{}) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
for t != nil && t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue // 非导出字段不参与 JSON
|
||||
}
|
||||
name := f.Tag.Get("json")
|
||||
if idx := strings.IndexByte(name, ','); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeBody 解析请求体,失败时直接写 400 并返回 false。
|
||||
@ -38,7 +201,7 @@ func Decode(r *http.Request, v interface{}) error {
|
||||
// 就是那句固定文案,只能靠翻服务端结构体才发现。第三方客户端没有这个条件。
|
||||
func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
if err := Decode(r, v); err != nil {
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err))
|
||||
Error(w, http.StatusBadRequest, decodeErrMsg(err, v))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@ -48,10 +211,21 @@ func DecodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
//
|
||||
// 刻意不回显 json 包的原文:它带 Go 的类型名(如 models.Workspace),
|
||||
// 那是本侧的实现细节,对调用方没有意义,也不该出现在公开 API 的响应里。
|
||||
func decodeErrMsg(err error) string {
|
||||
func decodeErrMsg(err error, target interface{}) string {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "请求体为空"
|
||||
}
|
||||
|
||||
// 未知字段:把认识的键一并列出来。只说「不认识 x」的话,调用方还得去翻
|
||||
// 服务端源码才知道对的拼法 —— 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
//(宽容解码时它连报错都没有,见 Decode 的注释)。
|
||||
if bad := unknownFieldName(err); bad != "" {
|
||||
msg := "不认识的字段 \"" + bad + "\""
|
||||
if names := jsonFieldNames(target); len(names) > 0 {
|
||||
msg += ";本端点接受:" + strings.Join(names, " / ")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 截断的 JSON 走的不是 SyntaxError 而是 ErrUnexpectedEOF ——
|
||||
// 不单独处理的话会落到最后那句笼统的兜底文案里
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
@ -141,7 +315,7 @@ func writeKeyErr(w http.ResponseWriter, err error) {
|
||||
} else {
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
||||
@ -217,3 +391,20 @@ func agentLimiterKey(isAgent bool, actor string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// validPermissionModeInput 校验人显式指定的权限档位。
|
||||
//
|
||||
// 与 repo 层的 Normalize 分工不同:**人显式传了一个认不出的档位时必须报错**,
|
||||
// 不能静默用默认档。他以为自己给了 plan,实际拿到 workspace —— 那是比报错
|
||||
// 更坏的结果(他会以为自己收紧了)。
|
||||
//
|
||||
// 而 repo 层的 Normalize 面向的是「库里的历史脏数据」与「省略该字段」,
|
||||
// 那两种情形下静默回落到默认档才是对的。
|
||||
func validPermissionModeInput(w http.ResponseWriter, mode string) bool {
|
||||
if mode == "" || models.ValidPermissionMode(mode) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusBadRequest,
|
||||
"permission_mode 非法:"+mode+"(应为 plan / workspace / full)")
|
||||
return false
|
||||
}
|
||||
|
||||
247
gateway/internal/handler/strictdecode_test.go
Normal file
247
gateway/internal/handler/strictdecode_test.go
Normal file
@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
// 严格解码的回归测试。
|
||||
//
|
||||
// 事故背景(生产实测):homeagent 插件的 send_mail 传的是
|
||||
// `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`。
|
||||
// 宽容解码让这变成一种**静默成功**:
|
||||
//
|
||||
// POST /mail/send {…,"attachments":[{"attachment_id":"598f100e…"}]}
|
||||
// → HTTP 200 {"mail_id":"2a64fdc8…"}
|
||||
// → SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…' → 0
|
||||
//
|
||||
// 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 活了很久,
|
||||
// 正因为没人会去核对一个返回 200 的请求。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这就是那次事故的最小复现:把 attachment_ids 写成 attachments。
|
||||
func TestStrictDecodeRejectsMisspelledField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","subject":"x","attachments":[{"attachment_id":"598f100e"}]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if DecodeBody(w, r, &req) {
|
||||
t.Fatal("拼错的字段名必须被拒绝 —— 否则又是一次静默成功")
|
||||
}
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("状态码应为 400,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v", err)
|
||||
}
|
||||
msg := resp["error"]
|
||||
|
||||
// 报出错的字段名
|
||||
if !strings.Contains(msg, "attachments") {
|
||||
t.Errorf("信息里应指出 attachments,实际 %q", msg)
|
||||
}
|
||||
// **并且**列出对的拼法 —— 少了这半句,调用方仍要去翻服务端源码,
|
||||
// 而拼错字段名恰恰是最容易犯、最难自查的错
|
||||
if !strings.Contains(msg, "attachment_ids") {
|
||||
t.Errorf("信息里应列出正确字段 attachment_ids,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 负向对照:合法字段必须原样通过,不能被严格解码误伤。
|
||||
func TestStrictDecodeAcceptsCorrectField(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(
|
||||
`{"to":"jianf","attachment_ids":["a","b"]}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("合法请求体被拒了:%s", w.Body.String())
|
||||
}
|
||||
if w.Code != http.StatusOK { // recorder 默认 200,即「没写过响应」
|
||||
t.Fatalf("不该写任何响应,实际状态码 %d", w.Code)
|
||||
}
|
||||
if len(req.AttachmentIDs) != 2 {
|
||||
t.Fatalf("附件 id 应解出 2 个,实际 %#v", req.AttachmentIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// 省略可选字段仍然合法 —— 严格针对的是「多」而不是「少」。
|
||||
func TestStrictDecodeAllowsOmittedFields(t *testing.T) {
|
||||
type sendReq struct {
|
||||
To string `json:"to"`
|
||||
CC string `json:"cc"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(`{"to":"jianf"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
var req sendReq
|
||||
if !DecodeBody(w, r, &req) {
|
||||
t.Fatalf("省略可选字段被拒了:%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DecodeLenient(心跳唯一的例外)───
|
||||
|
||||
func TestDecodeLenientKeepsKnownFieldsAndReportsUnknown(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":["a"],"mode_enforcement":"native","futureField":1}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("心跳体不该整体作废: %v", err)
|
||||
}
|
||||
// 已知字段必须照常取到 —— 这正是心跳要宽容的理由:
|
||||
// 插件比服务端新时,代价不该是会话快照与模型目录一起丢掉
|
||||
if len(req.Models) != 1 || req.ModeEnforcement != "native" {
|
||||
t.Fatalf("已知字段应正常解析,实际 %#v", req)
|
||||
}
|
||||
// 但**必须报出来**,否则又是一次静默忽略
|
||||
if len(unknown) != 1 || unknown[0] != "futureField" {
|
||||
t.Fatalf("未知字段应报 [futureField],实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 多个未知字段要全报出来。
|
||||
//
|
||||
// json 每遇到一个未知字段就立刻返回,所以实现里必须循环剥 ——
|
||||
// 不循环的话「多带了三个字段」只会报出第一个,而人改完那一个又撞上下一个。
|
||||
func TestDecodeLenientReportsAllUnknownFields(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(
|
||||
`{"models":[],"aaa":1,"bbb":2,"ccc":3}`))
|
||||
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
if len(unknown) != 3 {
|
||||
t.Fatalf("应报出 3 个未知字段,实际 %#v", unknown)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, u := range unknown {
|
||||
got[u] = true
|
||||
}
|
||||
for _, want := range []string{"aaa", "bbb", "ccc"} {
|
||||
if !got[want] {
|
||||
t.Errorf("未报出 %q(实际 %#v)", want, unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientCleanBodyReportsNothing(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":["a"]}`))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("不该报错: %v", err)
|
||||
}
|
||||
// 正常心跳的响应里不该多一个空数组 —— 调用方据此决定是否带 unknown_fields
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("干净的体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLenientEmptyBody(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(``))
|
||||
var req hb
|
||||
unknown, err := DecodeLenient(r, &req)
|
||||
if err != nil {
|
||||
t.Fatalf("空体应静默通过(心跳可以不带 body): %v", err)
|
||||
}
|
||||
if len(unknown) != 0 {
|
||||
t.Fatalf("空体不该报未知字段,实际 %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 语法错误仍然要报 —— 宽容的是「多字段」,不是「烂 JSON」。
|
||||
func TestDecodeLenientStillRejectsMalformedJSON(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("截断的 JSON 必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 类型不对也要报:`models` 要的是数组,给字符串说明插件写错了结构,
|
||||
// 那不是「服务端还不认识的新字段」。
|
||||
func TestDecodeLenientStillRejectsWrongType(t *testing.T) {
|
||||
type hb struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":"oops"}`))
|
||||
var req hb
|
||||
if _, err := DecodeLenient(r, &req); err == nil {
|
||||
t.Fatal("类型不匹配必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── jsonFieldNames ───
|
||||
|
||||
func TestJSONFieldNamesListsAcceptedKeys(t *testing.T) {
|
||||
type req struct {
|
||||
To string `json:"to"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
Skipped string `json:"-"`
|
||||
NoTag string
|
||||
unexported string //nolint:unused // 刻意保留:验证非导出字段不进清单
|
||||
}
|
||||
|
||||
names := jsonFieldNames(&req{})
|
||||
joined := strings.Join(names, ",")
|
||||
|
||||
for _, want := range []string{"to", "attachment_ids"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("应含 %q,实际 %q", want, joined)
|
||||
}
|
||||
}
|
||||
// json:"-" 的字段不该出现在「本端点接受」的清单里 —— 它确实不接受
|
||||
if strings.Contains(joined, "Skipped") || strings.Contains(joined, "-") {
|
||||
t.Errorf("json:\"-\" 的字段不该列出,实际 %q", joined)
|
||||
}
|
||||
// 无 tag 时用字段名(json 包也是这么匹配的)
|
||||
if !strings.Contains(joined, "NoTag") {
|
||||
t.Errorf("无 tag 字段应按字段名列出,实际 %q", joined)
|
||||
}
|
||||
// 非导出字段 json 根本不看
|
||||
if strings.Contains(joined, "unexported") {
|
||||
t.Errorf("非导出字段不该列出,实际 %q", joined)
|
||||
}
|
||||
}
|
||||
@ -80,6 +80,32 @@ func IsLunarRecurrence(r string) bool {
|
||||
return r == RecurLunarMonthly || r == RecurLunarYearly
|
||||
}
|
||||
|
||||
// 事件状态常量。
|
||||
//
|
||||
// 此前这三个值只以裸字符串形式散落在 handler、scheduler 与前端里,而更新端点
|
||||
// 把 `status` 原样写进库 —— 于是一个拼错的值(比如 "pause")会变成一个
|
||||
// **调度器不认识的状态**:DueEvents 只查 status='active',那条提醒于是静默失效。
|
||||
// 人以为自己只是暂停了它,实际上再也恢复不了(界面的下拉框里没有这个选项)。
|
||||
//
|
||||
// 提成常量后,handler.validEventStatus 能对着这一份清单校验。
|
||||
const (
|
||||
// EventActive 生效中:到点会触发提醒。
|
||||
EventActive = "active"
|
||||
// EventPaused 暂停:保留事件与重复规则,但不触发。
|
||||
EventPaused = "paused"
|
||||
// EventCancelled 已取消:保留历史记录,不再触发也不再推进重复。
|
||||
EventCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// ValidEventStatus 判断状态取值是否合法。
|
||||
func ValidEventStatus(s string) bool {
|
||||
switch s {
|
||||
case EventActive, EventPaused, EventCancelled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 投递模式常量。
|
||||
const (
|
||||
DeliverSeparate = "separate"
|
||||
|
||||
@ -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) {
|
||||
|
||||
181
gateway/internal/repo/defaultsession_test.go
Normal file
181
gateway/internal/repo/defaultsession_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这一组测试钉住「省略 session 位复用默认会话」与「真的新建会话」必须可区分。
|
||||
//
|
||||
// 事故背景:handler 层曾用 `parentMailID == nil` 判断「是不是新建会话」,
|
||||
// 据此决定要不要写往返预算与权限档位。但省略 session 位复用默认会话时
|
||||
// parentMailID 也是 nil —— 于是每一封续谈的信都会把这两个字段重置成默认值。
|
||||
//
|
||||
// 线上实测(修复前):
|
||||
//
|
||||
// 第一封 to=pi@/tmp/budgetprobe max_rounds=7 → budget_max 7
|
||||
// 第二封 to=pi@/tmp/budgetprobe(省略该字段) → budget_max 20 ← 被静默改写
|
||||
//
|
||||
// 而那段代码的注释本身正在论证这不该发生(「续谈已有会话若也接受这个字段,
|
||||
// 每封新信都会悄悄改掉对方正在遵守的预算」)—— 意图是对的,守卫写错了。
|
||||
//
|
||||
// 修法:FindOrCreateDefaultSessionCreated 额外返回 created,
|
||||
// handler 改用它而不是 parentMailID。
|
||||
|
||||
func TestDefaultSessionFirstCallCreates(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("从未通信过的 name@path,第一次必须报告 created=true")
|
||||
}
|
||||
if id.String() == "" {
|
||||
t.Fatal("应返回有效会话 id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSessionReuseReportsNotCreated(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 复用的前提是这条会话里有该收件人参与过的邮件(EXISTS 子查询)
|
||||
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatalf("第二封应复用同一条默认会话:first=%s second=%s", first, second)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用已有默认会话时 created 必须为 false —— 这正是预算被冲掉的根因")
|
||||
}
|
||||
}
|
||||
|
||||
// 这条是上面那个线上事故的最小复现:走 created 判据时预算不被改写。
|
||||
func TestBudgetSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
// 模拟 handler:只有 created 为真才设预算
|
||||
if _, err := SetSessionBudget(ctx, id, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
// 若这里为真,handler 就会重设预算 —— 事故重现
|
||||
t.Fatal("复用时 created 为真会让 handler 重设预算")
|
||||
}
|
||||
|
||||
b, err := GetSessionBudget(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Max != 7 {
|
||||
t.Fatalf("续谈不得改写预算:want 7, got %d", b.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// 档位与预算同一个判据,一起钉住:plan 档不能因为第二封信而升成 workspace。
|
||||
func TestPermissionModeSurvivesDefaultSessionReuse(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首封应新建:created=%v err=%v", created, err)
|
||||
}
|
||||
if _, err := SetSessionPermissionMode(ctx, id, "plan"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("复用时 created 为真会让 handler 把档位重置成默认档")
|
||||
}
|
||||
|
||||
if got := SessionPermissionMode(ctx, id); got != "plan" {
|
||||
t.Fatalf("续谈不得改写档位:want plan, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 不同工作目录是不同的默认会话,两边各自新建。
|
||||
// 这条防的是「把 created 实现成一个全局标志」之类的偷懒写法。
|
||||
func TestDefaultSessionPerWorkspace(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
a, createdA, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w1", "jianf", "甲")
|
||||
if err != nil || !createdA {
|
||||
t.Fatalf("/w1 应新建:%v %v", createdA, err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, a, nil, "jianf", "", "pi", "/w1", "甲", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, createdB, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w2", "jianf", "乙")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !createdB {
|
||||
t.Fatal("/w2 是另一个工作目录,应当另建一条默认会话")
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("不同工作目录不该共用同一条默认会话")
|
||||
}
|
||||
}
|
||||
|
||||
// 旧签名仍在别处被调用,保持行为不变(只是丢掉 created)。
|
||||
func TestLegacyWrapperStillWorks(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedAgent(t, "pi", 20)
|
||||
|
||||
id, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "首封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateMail(ctx, id, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := FindOrCreateDefaultSession(ctx, "pi", "/w", "jianf", "第二封")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != id {
|
||||
t.Fatalf("包装函数应与原行为一致:%s vs %s", id, again)
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,9 @@ const threadCols = `m.mail_id, m.session_id, m.parent_mail_id,
|
||||
m.cc_list, m.subject, m.body, m.mail_type,
|
||||
COALESCE(m.permission_result,'') AS permission_result,
|
||||
m.status, m.created_at, s.session_alias, s.workspace,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count`
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.mail_id = m.mail_id) AS attach_count,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
|
||||
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human`
|
||||
|
||||
// ThreadRootOf 沿 parent_mail_id 上溯到线索的根,返回根的 mail_id 与锚点到根的层数。
|
||||
//
|
||||
@ -202,7 +204,8 @@ func scanTreeRows(rows interface {
|
||||
if err := rows.Scan(&t.ID, &t.SessionID, &t.ParentMailID,
|
||||
&t.FromName, &t.FromWorkspace, &t.ToName, &t.ToWorkspace,
|
||||
&ccJSON, &t.Subject, &t.Body, &t.MailType, &t.PermResult,
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount, &lvl); err != nil {
|
||||
&t.Status, &t.CreatedAt, &alias, &t.SessionWorkspace, &t.AttachmentCount,
|
||||
&t.FromHuman, &t.ToHuman, &lvl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ccJSON) > 0 {
|
||||
|
||||
@ -425,22 +425,6 @@ func UserCanAccessSession(ctx context.Context, u *models.User, sessionID uuid.UU
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// FirstAdminUsername 返回最早创建的可用管理员用户名(用于无归属会话的兜底决策人)
|
||||
func FirstAdminUsername(ctx context.Context) (string, error) {
|
||||
var name string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
SELECT username FROM users
|
||||
WHERE role = 'admin' AND status = 'active'
|
||||
ORDER BY created_at ASC LIMIT 1`).Scan(&name)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// RandomPassword 生成一个随机初始密码(首次启动无 ADMIN_PASSWORD 时使用)
|
||||
func RandomPassword(n int) string {
|
||||
const charset = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
Reference in New Issue
Block a user