API 长数字 ID 到前端后变了?JavaScript 精度丢失与字符串传递排查
任务编号末位变化、查询 404 或账单 ID 被合并?沿 JSON 文本、解析、存储和再次请求定位精度丢失,说明为何 String 和 BigInt 可能补救太晚,并用本地样本验证字符串 ID 的完整往返。
API 返回一个很长的任务编号,复制到前端后末位却变了;再用它查询任务得到 404,或者两条账单被归成一条。先检查编号在哪一层变成了 JavaScript Number。JSON 能解析、HTTP 返回 200,都不能证明编号被完整保留。
本文只讨论原本用于标识任务、请求或账单的长数字 ID,不讨论模型生成数字是否正确,也不把所有 404 都归因于精度。示例为自定义协议和本地模拟,没有调用真实收费 API。
1. 用两条假编号复现,避免拿真实任务试错
在 Node.js 中运行下面的片段即可观察问题:
const source = '{"task_id":9007199254740993}';
const parsed = JSON.parse(source);
console.log(String(parsed.task_id)); // 9007199254740992
console.log(Number.isSafeInteger(parsed.task_id)); // false
console.log(Number('9007199254740992') ===
Number('9007199254740993')); // true
JavaScript 的 Number 使用双精度浮点数,安全整数上限 Number.MAX_SAFE_INTEGER 为 9007199254740991,即 2 的 53 次方减 1。[1][2] 超出范围不意味着每个整数都会改变,但不能再保证相邻整数都能被准确区分。这里两个不同编号转换后相等,就可能导致查询目标、Map 键或去重结果出错。
RFC 8259 第 6 节允许 JSON 实现对数值范围和精度设限,并指出上述安全整数区间具有广泛的精确互操作性。[1] 因此“服务端输出的是合法 JSON 数字”和“所有客户端都能无损读取”是两回事。不要把一个合法的大整数响应直接说成服务器违反 JSON 规范。
2. 沿数据路径找第一次变化
先核对接口文档:该字段究竟是 JSON string,还是 JSON number?它是否允许前导零、字母前缀,是否需要保持原样?任务 ID、请求 ID 和模型 ID 不是同一字段,不要混用。
用人工样本逐层比较,记录字段的类型和完整值:
| 检查位置 | 要比较的内容 | 典型线索 |
|---|---|---|
| 收到的 JSON 文本 | 数字是否带双引号,原始末位是什么 | 已是错误值时,继续查更上游 |
| JSON 解析之后 | typeof、字符串形式、安全整数检查 | 文本正确而对象变了,定位到解析或数值转换 |
| 业务映射之后 | 是否调用 Number、parseInt 或一元加号 | 字符串在适配层被自动转成数值 |
| 存储与读回 | 字段类型、驱动返回类型、读回值 | 写前正确而读后变化,检查存储及驱动转换 |
| 再次序列化与请求地址 | 输出的 JSON 类型、查询参数或路径值 | 转回字符串时已经带着错误末位 |
网络面板的 Preview 往往已经是解析后的视图。优先检查受控保存的响应文本或原始响应视图,不能仅凭 Preview 证明线上传输了错误数字。真实排错记录只保留必要字段并限制访问,不公开密钥、完整响应、客户资料或带签名的链接。
即使 ID 完全一致,404 仍可能来自账户、环境、权限、路径或保留期。按异步任务状态排查继续核对原任务,不因查询失败再次提交收费任务。
3. 把编号当字符串贯穿整个链路
对自己能控制的接口,通常更稳妥的约定是:
{"task_id":"9007199254740993"}
让生成响应的一端从准确的数据库值或原始文本生成字符串,再由客户端、存储、表单、队列和下一次请求原样传递。不能先在服务端转成不安全的 Number,再给结果加引号;那样只是把已经损坏的值包装成字符串。
已有外部协议不能随意改字段类型。先核实服务是否提供字符串字段或无损读取方式,再在你控制的边界做明确的适配,并测试所有消费者。前端表单若承载编号,应检查组件有没有自动数值化;存储端也要验证实际驱动的输入输出类型,而非只看数据库列名。
对于有前导零的标识,字符串 "0007" 与 "7" 可能是两个不同资源。不要擅自 trim、转大写或删零来“规范化”。是否等价由协议决定,不能由客户端猜测。金额、Token 数量等需要计算的字段另按其类型和精度处理,本建议不是把所有数字都改成字符串。
4. 一个拒绝静默转换的 Node.js 接口边界
将下列代码保存为 task-id.mjs。它只演示一个约定:根对象的 task_id 必须是 1 至 128 位 ASCII 数字组成的字符串,允许并保留前导零。这个长度和格式是教学协议,不是任何平台的限制;UUID 或带前缀的 ID 需要改用对应协议的校验规则。
export function readTaskId(source) {
if (typeof source !== 'string') throw new TypeError('TEXT_REQUIRED');
if (Buffer.byteLength(source, 'utf8') > 65536) {
throw new RangeError('BODY_TOO_LARGE');
}
let data;
try { data = JSON.parse(source); }
catch { throw new Error('INVALID_JSON'); }
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('OBJECT_REQUIRED');
}
if (!Object.hasOwn(data, 'task_id')) throw new Error('MISSING_ID');
const id = data.task_id;
if (typeof id !== 'string' || id.length < 1 || id.length > 128 || /[^0-9]/.test(id)) {
throw new Error('STRING_ID_REQUIRED');
}
return id;
}
然后可从同目录的另一个 .mjs 文件调用:
import { readTaskId } from './task-id.mjs';
const id = readTaskId('{"task_id":"0009007199254740993"}');
console.log(JSON.stringify({ task_id: id }));
// {"task_id":"0009007199254740993"}
STRING_ID_REQUIRED 表示输入不符合字符串契约。即便输入是安全的小整数 7,这个函数也拒绝;它不会用 String(value) 悄悄迁就类型变化。调用方应将异常送到协议错误分支,保留脱敏证据,停止本次查询或写入,不自动重新创建任务。
函数限制已收到文本的 UTF-8 字节数为 65,536,检查根类型、缺失字段和编号格式,返回值仅为 ID,额外字段不参与处理。它没有实现 HTTP、流式接收限制、鉴权或数据库写入;接收层仍需在完整加载前限制响应体,并核对状态和媒体类型。它也不检测 JSON 重复键,不是通用严格 JSON 校验器。这里正则只检查已解析的单个字符串,不拿正则改写原始 JSON。
5. 为什么补一个 BigInt 仍可能太晚
MDN 的 JSON.parse 文档明确说明,reviver 在解析之后运行,数字可能已经损失精度。[3] 因此 String(parsed.task_id)、BigInt(parsed.task_id),以及在普通 reviver 中执行 BigInt(value),都不能恢复被舍入掉的原始末位。
BigInt('9007199254740993') 从准确的整数文本构造可以保留这个整数;但把 BigInt 再转成 Number 又会失去精度。此外 BigInt 会丢掉标识中的前导零,且默认 JSON.stringify 遇到 BigInt 会抛 TypeError。[4] 只做身份传递时,保留字符串通常更直接;若确需整数运算,运算后序列化仍必须符合下游协议。
若上游只能返回 JSON number,解决办法必须从原始 JSON 文本开始:采用经过验证的无损解析方案,或在支持的运行环境中按字段使用 JSON.parse reviver 的 context.source。后者提供原始值文本,但必须核对运行时支持,并处理指数、小数和负号是否符合该字段协议。[3] 本文示例没有实现这条兼容分支,不能把普通 JSON.parse 包装一下就宣称无损。
不要用正则给整份 JSON 中的数字批量加引号。它可能改坏字符串内部文本、金额或嵌套结构。已经只剩不安全 Number、没有准确原文时,应从原始存档、平台记录或明确支持的只读查询恢复;不要猜末位、批量修补历史编号或重新提交任务。
6. 用本地往返检查验证修复
验收至少包含安全整数边界两侧、相邻长 ID、前导零、空值、缺失字段、错误类型、超长编号和响应体大小边界。核心条件是:源文本里的字符串 ID,经过解析、业务处理、JSON.stringify 和再次解析后仍逐字符相等。
本次在 Node.js 上进行离线检查,验证了两个长数字的 Number 碰撞、转换后无法恢复、字符串往返、模拟 Map 查询中不同 ID 不合并,以及错误输入被拒绝。两种语言展示的函数与受测源码逐字比对。模拟未覆盖真实数据库驱动、浏览器表单、网关部署或服务商计费,实际接入还要在自己的测试环境验证这些边界。
修复后不要立即用新编号覆盖全部历史记录。先找可信来源确认映射,区分“原始 ID”“已损坏的本地值”“恢复证据”,再决定单条修复与回滚方式。账单中两个编号变成一个时,也不能据此自动删掉一笔费用;继续按账单导出与请求编号对账核对来源关系。
需要支持人员协助时,提供带时区的时间、字段协议、客户端版本、人工复现样本及首次发生变化的层级。使用最小复现与脱敏工单模板,不要发完整 Key 或含隐私的响应截图。
7. 资料与适用范围
- [1] RFC 8259,第 6 节 Numbers:JSON 数值精度与互操作边界。
- [2] MDN:Number.MAX_SAFE_INTEGER:安全整数范围与相邻整数比较。
- [3] MDN:JSON.parse:reviver 时机和 context.source。
- [4] MDN:BigInt:类型转换与 JSON 序列化限制。
资料核对日期为 2026-11-12。本文针对编号传递的精度故障,不证明任何具体平台存在该问题,也不替代目标接口的字段契约。