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

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,

View File

@ -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
}

View 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)
}
}