手上有个自建服务的内容管理接口,没有文档,只有几条能跑通的示例请求。要在它上面做点稍微越界的事情,就只能黑盒试。这篇记录我怎么用一条一次性的探针记录把它的行为摸清楚——以及为什么 {"success":true} 是这类接口里最不可信的一句话。

🕳 前提:能读到的只有行为

接口大概是 POST /api/items 建、GET /api/items/:id 读、PUT /api/items/:id 改这么一套,域名就当它是 example.com。没有 OpenAPI,没有字段表,服务端日志我能看到但看不全。这种情况下唯一的知识来源就是我发一个请求、它给一个反应

所以问题不是「怎么查文档」,而是怎么设计实验。而实验有个前提条件:得有一个可以随便弄坏的对象。


💥 坑一:只传想改的字段,直接 500

第一件事很朴素:把某条记录的 statuspublished 改成 draft。按直觉只传要改的那个字段:

curl -X PUT https://example.com/api/items/42 \
  -H 'Content-Type: application/json' \
  -d '{"status":"draft"}'

500。服务端日志里是这么一句:

Cannot read properties of undefined

这个报错很有信息量——它不是「参数校验失败」,而是服务端在读一个它以为一定存在的东西。翻译过来就是:这个 PUT 是全量替换,不是局部合并。它拿我的 body 当作新的完整对象,逐个字段去取,取到 undefined 就炸。把 titlecontentslugstatus 一起带上,立刻就通了。

由此得到第一条普适判断:PUT 和 PATCH 的语义差别,在没有文档的时候必须靠实验确定,不能靠 RFC 的理想状态去推。规范说 PUT 是替换,但现实里一半的实现是合并,另一半是替换,还有一部分两个动词行为完全一样。

更要紧的是,「少传字段」有两种坏结果:

  • 报错——这算好的,它至少告诉了你真相;
  • 静默把字段清空——这是坏的。同样一个全量替换语义的接口,如果实现得更「宽容」一点(缺失字段填默认值),那么你一次「只改 status」的请求会顺手把 content 清成空字符串,而且返回 200。

第二种情况下,你不读回来就永远不知道。想区分这两者只要一个动作:给探针记录填上四个互不相同、好认的值,然后只 PUT 其中一个字段,再整条读回来,看另外三个还在不在。在不在,比返回什么码重要得多。

顺手也值得试试 PATCH 同一个地址。有的实现根本没注册这个方法(405 或 404),有的注册了但行为跟 PUT 一模一样,还有的确实是合并语义——这三种情况的应对方式完全不同,而它们同样只能试出来。


🌀 坑二:21 次 success,时间纹丝不动

第二件事更折腾:我想把一条记录的创建时间回填成一个历史日期。字段名不知道叫什么,格式也不知道要哪种,于是我开始穷举。

字段名试了七个:

created_at  published_at  publish_date  post_date  date  createdAt  publishedAt

时间格式试了三种:2026-01-02 03:04:05、RFC-1123 的 Fri, 02 Jan 2026 03:04:05 GMT、ISO-8601 带毫秒的 2026-01-02T03:04:05.000Z

七乘三,二十一次请求,每一次都返回 {"success":true}

然后我 GET 回来看——时间纹丝不动,一直是记录被创建的那一刻。

结论是这个字段只在创建时可写,之后就是只读的;而服务端对未知或只读字段的处理方式是直接扔掉,不报错。二十一个 success 里,没有一个 success 是在回答我的问题。


🔬 破解办法:故意传一个不存在的字段

「忽略未知字段」是相当常见的宽容设计,它对正常调用者很友好,对探测的人很致命:你分不清「字段名猜错了」和「字段是只读的」——两者的返回值一模一样。

破法很简单,做一个阴性对照:故意传一个绝对不可能存在的字段名。

curl -X PUT https://example.com/api/items/42 \
  -H 'Content-Type: application/json' \
  -d '{"title":"t","content":"c","slug":"s","status":"draft","zzz_not_a_real_field":"x"}'

如果这个也返回 success,那么结论就定了:这个接口对未知字段一律静默忽略,此前所有的 success 都不能作为证据。我省下的是继续猜第八个、第九个字段名的时间。

这一步就是排查里最值钱的动作:先自证你的观测手段有分辨能力,再拿它去下结论。


🧪 十几行的假服务端,把这套行为演一遍

上面这些行为不神秘,写个玩具服务端就能复刻。核心是一个可写字段白名单、一个全量替换、一个服务端自己说了算的时间戳:

WRITABLE = ("title", "content", "slug", "status")

def do_PUT(self):
    body = json.loads(self.rfile.read(n) or b"{}")
    try:
        new = {k: body[k].strip() for k in WRITABLE}   # 缺一个就炸
    except KeyError as e:
        self._json(500, {"error": "Cannot read properties of undefined (reading %s)" % e})
        return
    new["created_at"] = item["created_at"]              # 只读字段
    item.clear(); item.update(new)
    self._json(200, {"success": True})

四次请求跑下来,真实输出是这样:

--- 1. 只传一个字段 ---
500
{"error": "Cannot read properties of undefined (reading 'title')"}
--- 2. 全量字段 + 想改 created_at ---
{"success": true}
--- 3. 读回来 ---
{"title": "hello", "content": "body", "slug": "hello", "status": "draft", "created_at": "2026-08-12 10:00:00"}
--- 4. 对照组:一个绝对不存在的字段 ---
{"success": true}

第 2 步和第 4 步的返回值完全相同,而第 3 步证明第 2 步什么都没做成。写下来才发现,二十行代码就能造出一个让人查半天的接口,这类行为在真实服务里有多普遍可想而知。


🧷 探针资源:别拿真实数据去赌

所有这些实验都有破坏性——尤其在「全量替换」语义下,一次手滑就能把一条记录的正文清空。所以正确姿势是先造一个一次性的实验对象

# 1. 建一条探针记录,标题带上好认的前缀
curl -s -X POST https://example.com/api/items \
  -H 'Content-Type: application/json' \
  -d '{"title":"zzz-probe-01","content":"probe","slug":"zzz-probe-01","status":"draft"}'

拿到 id 之后:

  • 所有破坏性实验都只对这条记录做;
  • 一次只改一个变量——换字段名就别同时换格式,否则失败了你不知道该怪谁;
  • 每次写完立刻 GET 回来比对,或者去抓渲染页面里的时间戳,不看返回值;
  • 实验结束 DELETE 掉,别留垃圾;
  • 把结论写成一份笔记,标清版本和日期,避免下次从头再试一遍。

有个风险提示:探针也要挑影响面小的资源类型。别拿会触发通知推送、扣费、发邮件、写审计流水的接口做实验——那种接口的副作用出了进程边界就收不回来了,删掉记录也没用。这类接口如果非试不可,先找有没有沙箱环境或 dry-run 参数。


✅ 可以照抄的探测清单

  1. 先建探针,再动手。 一次性资源,好认的前缀,用完删掉。
  2. 先测 PUT 的语义。 只传一个字段:报错 = 全量替换;成功 = 可能是合并,也可能是替换加默认值,去读回来看别的字段有没有被清空。
  3. 每次写后必读。 {"success":true} 只代表请求被受理了,不代表你要的事情发生了。
  4. 一次只动一个变量。 字段名、格式、值,分开试。
  5. 做阴性对照。 传一个绝对不存在的字段,看它是报错还是 success。返回 success,说明这个接口的 success 不含信息量。
  6. 避开有外部副作用的接口。 通知、计费、邮件、审计——这些不可回滚。
  7. 把结论落成笔记。 黑盒知识是纯手工挖出来的,不记下来等于没挖。

黑盒探测的本质不是猜得准,而是让每一次失败都能排除掉一种可能。一个把什么都答 success 的接口,正是在剥夺你这个能力——所以第一件事永远是找回观测的分辨力,再去问你真正想问的问题。