一个系统发送订单号 9007199254740993,另一个系统却收到 9007199254740992;两边都可能认为 JSON 已正常解析。问题发生在模型输出之后、业务校验之前,也会发生在普通 API、消息队列与浏览器之间。本文以构造报文说明如何把保真检查前移,不把类型校验成功误当作原始值未变。
把解析边界单独列入接口契约
结构化输出的入库校验解决了完整响应、字段约束、业务一致性与证据问题。本篇补上更早的边界:原始 JSON 文本转换成字典或对象时,哪些信息已经消失?校验器通常只能看到解析后的值;如果两个字段已合并,或数字已舍入,再严格的字段范围检查也不能恢复原文。
建议画清一条实际数据路径:生产者对象、序列化文本、网关或消息代理、消费者解析器、业务类型、数据库、再次导出。逐点记录语言、库版本和配置。不能只测试最终服务中的解析函数,因为 SDK、网关插件或前端可能已经提前完成一次有损转换。
本文提出的接入契约要求对象内名称唯一,标识符使用字符串,精确金额采用约定的十进制表示,并显式拒绝超出业务范围的数值。这是面向跨系统交换的工程选择,不是声称 JSON 标准要求所有数字都写成字符串。
检查分两层:在原始报文上确认边界、编码、重复键和数字词法;在类型化结果上确认必填、单位、范围和业务关联。两层结果都留存规则版本。若只有后者通过,回执只能说业务对象通过了这些检查,不能扩大为全链路保真。
重复键必须在变成普通对象之前拦截
RFC 8259 对对象名称唯一使用 SHOULD,并说明重复名称会产生互操作性差异:接收端可能保留最后一个值、报错或保留全部。[1] 因而不能笼统说重复键一定是语法非法,也不能因某个解析器允许就认为上下游理解一致。
构造报文 {"amount":100,"amount":900} 有两个同名成员。若解析后只剩 amount=900,后续检查金额大于零、类型为整数都可能通过。即使两个值相同,也建议按重复名称规则拒绝,以免生产者错误长期被掩盖。这里拒绝是接入政策,而非对业务金额的推断。
实现时让解析器在每个对象范围内交出成员序列,检查解码后的键是否重复,再构造映射。不能用正则在整份文本里搜索名称:嵌套对象可以各自拥有 amount,字符串内容也可能含有类似字段片段。名称中的 JSON 转义必须先解码,因此字面键 a 与转义形式 \u0061 应被视作同名。
Python json 默认保留同名成员的最后一个值;object_pairs_hook 则允许在成员对仍可见时接管对象构造。[2] 可用它为每个对象创建 seen 集合,重复即报错,再返回字典。object_hook 接收到的已是字典,不能承担这项保真检查。检查器不应顺便去空格、改大小写或做 Unicode 规范化,否则会额外改变字段身份。
大整数能写进 JSON,不代表每个消费者都能精确读取
RFC 8259 允许实现限制数字范围与精度,并指出使用常见 binary64 能力时,绝对值不超过 2 的 53 次方减一的整数可获得一致精确表示。[1] 超过这个边界不意味着每个整数都会错,而是不能继续假设连续整数都可区分。
构造订单 A 的编号为 9007199254740992,订单 B 为 9007199254740993。它们作为十进制文本不同,但后者转换为常见 binary64 数值会舍入到前者。若消费者把解析结果作为去重键,可能把两笔订单合并;把舍入后的值再转成字符串并不能找回 B。
对不参与算术的订单号、客户号和长编码,建议从生产端开始使用 JSON 字符串,并约定字符集、长度、前导零及大小写规则。不能让某些记录发送 number、另一些发送 string,再由消费者宽松转换。若历史接口只能发数字,应明确允许范围并在有损转换前检查原始数字,而非转换后才判断。
数值字段也不能一律改成字符串后结束设计。金额字符串需要币种、小数位、范围和舍入契约,再直接进入十进制类型;数量若采用整数,要覆盖所有消费者的范围。科学计数法是否允许必须明确:数值上相等不代表词法形式符合接口要求。
用严格解析与显式类型转换组成两道门
第一道门接收完整且大小受限的报文,按契约解码,拒绝重复名称、额外尾随内容和未获允许的非标准数值。大数组、深层嵌套和超长数字还需资源上限;不要把解析器能接收多少当作业务应该接收多少。原始报文在受控位置按需保留,普通日志只记定位信息。
Python 可组合 object_pairs_hook、parse_float 和 parse_constant:成员对钩子检查重复;parse_float 可从数字原文直接构造 Decimal;parse_constant 可拒绝 NaN、Infinity 等扩展。[2] 不能先转 float 再构造 Decimal 来宣称保留了原始十进制精度。整数采用明确范围规则,不能因 Python int 可表示较大数就推断浏览器和其他语言同样安全。
第二道门执行逐字段业务转换。字符串编号必须仍为字符串;金额检查位数与币种;数量检查整数性和上下限;布尔字段不接受数字 0、1 冒充。缺失、null、空字符串和零分别处理,不用统一默认值把它们合并。技术实现是否隐式转换,需要用实际请求路径验证。
仅设置一个名为 strict 的选项并不足够。Python json 的 strict 主要涉及字符串中的控制字符,不代表自动拒绝所有重复键或特殊数字。[2] 应为每一项政策找到明确执行点,并用反例证明它在首次有损解析之前生效。无法保留词法形式的接口,不能承诺事后判断原始写法。
数据库应保存业务值,同时保留必要的来源证据
PostgreSQL 18 文档区分 json 与 jsonb:前者保留输入文本及重复成员,处理函数采用最后值;后者不保留重复键,只留下最后值。[3] 因而把原始报文直接写入 jsonb,再去查有没有重复名称,检查已经太迟。数据库类型选择不能替代入口政策。
若需要调查原始输入,可以在受控证据区保留原字节或文本及其摘要,在正式表保存已经验证的业务字段。json 类型保留输入文本不代表它会为业务拒绝重复键,更不证明上游字节从未变过。原始证据与类型化查询结果各有用途,不能互相冒充。
数据库支持精确数值也无法修复先前已被客户端舍入的数据。验收要把值从数据库读出,经实际驱动、API 和浏览器再次传输,确认标识符仍完整。只验证“写入 numeric 成功”或查看格式化表格,不足以覆盖返回路径。
如果对原始报文有签名或摘要约定,应先按既定协议验证相应字节,不能擅自解析、重排字段后再计算,并称其为原文校验。语义等价与字节相同是不同要求。本文不定义新的签名规范;需要规范化签名时应单独选择明确的协议和实现。
用一组小反例穿过每个真实运行时
最小验收集先覆盖名称:同一对象重复键、相同值重复键、嵌套对象内重复键、转义后同名键,以及不同对象合法同名键。前四类按本契约拒绝,最后一类接受。检查拒绝发生在哪一层、是否已有副作用,并记录可定位的路径或解析错误,不把整份敏感报文直接写进错误信息。
数值组包含 9007199254740991、9007199254740992、9007199254740993,既测数字形式也测字符串形式。字符串编号应逐字符保持;数字形式按约定范围接受或拒绝。另测 12.30、指数形式、超范围金额、NaN、Infinity、空值和缺失字段。预期结果需逐项指定,不能仅要求“没有异常”。
双路径对照尤其重要:一条把相同原始文本直接送入服务,另一条经过真实网关、SDK、队列及前端。比较各节点的类型和值、接受或拒绝状态及最终数据库记录。若直接路径正确、真实路径错误,先定位首次转换点;不要在最后一层增加模糊容差来掩盖损失。
重放测试再覆盖提交前中断、数据库已提交但回执丢失,以及同一业务请求再次到达。解析器政策改变后,需要以新规则重新验证原始输入,并记录处理版本。重新验证不自动授权再次执行付款、发货等动作;副作用仍须受原业务幂等与授权机制约束。
把故障停在可恢复的位置
发现重复键时拒绝或隔离该报文,由生产者确认唯一值;不要默认取大值、最后值或让模型猜正确值。发现已舍入编号时,回到权威来源补回原值,并检查是否已经发生错误关联。把当前数值改成字符串只能阻止下一次转换,不能修复过去丢失的信息。
落地交付可以很具体:一份字段表示契约、一套在首次解析边界执行的检查器、上述跨路径反例,以及带原文引用和规则版本的隔离记录。最终回执区分接收成功、解析保真通过、业务校验通过与正式提交成功,不用一个 HTTP 200 覆盖全部状态。
本文依据 RFC 8259、Python 3.12 与 PostgreSQL 18 文档,核查于 2026 年 10 月 26 日。编号与报文均为构造样例,验收流程是待在实际链路中实施的方法,不代表已经完成企业生产系统测试。前移检查的价值在于保留判断所需的信息,而非承诺所有跨语言差异都能由一个解析选项解决。
