From ecc8ffaee470d7911038b44420c44311b4ed7035 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 14:47:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(douyin-mashida):=20=E5=87=BA=E7=BD=91?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E6=8C=89=E5=A4=A7=E6=95=B4=E6=95=B0=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=A7=A3=E6=9E=90=EF=BC=88product=5Fid=20=E8=A2=AB?= =?UTF-8?q?=E5=8E=8B=E7=B2=BE=E5=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实联调发现:抖店大量 id 是 19 位数字,超过 Number.MAX_SAFE_INTEGER, 入站推送我们用 express.text() 收原文保住了精度,但**出网响应是 JSON.parse 出来的**, 19 位数字在解析瞬间就被压成相邻 double: 平台真值 product_id = 3847255376114352567 我们存到的 = 3847255376114352600 后果:拿这个 product_id 去调 product.detail 报「商品不存在」(实测), 任何按商品ID做的映射/查询都会错。 修复:抖音出网响应改用共享的 parseJsonObject(..., { preserveLargeIntegers: true })。 utils/json.ts 里其实早有 wrapLargeIntegerLiterals(≥16 位/超 MAX_SAFE_INTEGER 才包裹, 只处理值位置的整数字面量,不误伤字符串内数字与短数字),但全仓没有调用方 —— 现在接上。 实测:同一笔订单 product_id 已精确为 …567,product.detail 返回 code=10000 并拿到商品图。 补了 json 解析的大整数单测(含「字符串里的数字、短数字不受影响」的反例)。 --- .../platforms/douyin-mashida/http-client.ts | 26 ++++++++----- apps/backend/src/utils/json.test.ts | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/services/platforms/douyin-mashida/http-client.ts b/apps/backend/src/services/platforms/douyin-mashida/http-client.ts index 47218d66..ec5ee295 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/http-client.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/http-client.ts @@ -1,6 +1,7 @@ -import { asJsonObject, isPlainObject, type JsonObject } from '../../../types/json.js' +import { asJsonObject, type JsonObject } from '../../../types/json.js' import { createHttpError } from '../../../utils/http.js' +import { parseJsonObject } from '../../../utils/json.js' import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js' import { assertDouyinMashidaConfig, @@ -94,7 +95,7 @@ export async function douyinMashidaGatewayRequest(input: { clearTimeout(timeout) } - const parsed = parseJsonObject(responseText) + const parsed = parseGatewayResponseJson(responseText) const code = String(parsed?.code ?? '').trim() const message = String(parsed?.message ?? '').trim() @@ -136,18 +137,25 @@ export async function douyinMashidaGatewayRequest(input: { return { code, message, data: parsed.data, raw: parsed } } -function parseJsonObject(text: string): JsonObject | null { +/** + * 解析网关响应。 + * + * ⚠️ **必须开启 `preserveLargeIntegers`**:抖店大量 id 是 19 位数字,超过 + * `Number.MAX_SAFE_INTEGER`,直接 `JSON.parse` 会把它压成相邻的 double —— + * 实测 `product_id` 真值 `3847255376114352567` 被存成 `3847255376114352600`, + * 之后拿它去调 `product.detail` 就会报「商品不存在」。 + * 开启后这类字面量会先被转成字符串,全程精确。 + * + * 返回 null 表示「不是可用的 JSON 对象」(空响应/解析失败),由调用方报错。 + */ +function parseGatewayResponseJson(text: string): JsonObject | null { const normalized = String(text || '').trim() if (!normalized) { return null } - try { - const parsed: unknown = JSON.parse(normalized) - return isPlainObject(parsed) ? parsed : null - } catch { - return null - } + const parsed = parseJsonObject(normalized, { preserveLargeIntegers: true }) + return Object.keys(parsed).length > 0 ? parsed : null } function isAbortError(error: unknown): boolean { diff --git a/apps/backend/src/utils/json.test.ts b/apps/backend/src/utils/json.test.ts index e05837b6..6c7da2b2 100644 --- a/apps/backend/src/utils/json.test.ts +++ b/apps/backend/src/utils/json.test.ts @@ -5,7 +5,9 @@ import { sanitizeWellFormedUnicode, deepSanitizeUnicode, safeJsonStringify, + wrapLargeIntegerLiterals, } from './json.js' +import type { JsonObject } from '../types/json.js' test('sanitizeWellFormedUnicode handles null bytes and lone surrogates', () => { // Null byte @@ -53,3 +55,38 @@ test('safeJsonStringify produces valid JSON safe for postgres jsonb', () => { assert.equal(parsed.nickname, '\uFFFD\uFFFD*buyer') assert.equal(parsed.nullByte, 'abcdef') }) + +test('preserveLargeIntegers:19 位 id 不被压精度(真实踩坑)', () => { + // 真实案例:抖店 product_id 真值 …567,直接 JSON.parse 会变成 …600 + const raw = + '{"code":"10000","data":{"sku_order_list":[{"code":"幸运币2个","product_id":3847255376114352567,"sku_id":3383719439799589,"pay_amount":100}]}}' + + assert.equal(JSON.parse(raw).data.sku_order_list[0].product_id, 3847255376114352600) + + const safe = parseJsonObject(raw, { preserveLargeIntegers: true }) + const row = (safe.data as JsonObject).sku_order_list as JsonObject[] + assert.equal(row[0]?.product_id, '3847255376114352567') + // 16 位但未超 MAX_SAFE_INTEGER 的数字保持不变(不误伤) + assert.equal(row[0]?.sku_id, 3383719439799589) + // 小整数保持数字类型 + assert.equal(row[0]?.pay_amount, 100) +}) + +test('preserveLargeIntegers:不动字符串里的数字,也不动短数字', () => { + const raw = + '{"order_id":"6930318258716179555","note":"单号6930318258716179555","pay_time":1791521705}' + const safe = parseJsonObject(raw, { preserveLargeIntegers: true }) + + assert.equal(safe.order_id, '6930318258716179555') + assert.equal(safe.note, '单号6930318258716179555') + assert.equal(safe.pay_time, 1791521705) +}) + +test('wrapLargeIntegerLiterals 只包裹超大整数字面量', () => { + assert.equal(wrapLargeIntegerLiterals('{"a":123}'), '{"a":123}') + assert.equal(wrapLargeIntegerLiterals('{"a":3847255376114352567}'), '{"a":"3847255376114352567"}') + assert.equal( + wrapLargeIntegerLiterals('{"a":"3847255376114352567"}'), + '{"a":"3847255376114352567"}', + ) +})