diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 3834fab1..2fa902a7 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -3,6 +3,7 @@ import type { PoolClient, QueryResult } from 'pg' import { query, withTransaction } from '../db/client.js' import type { OrderItemReplaceInput } from '../types/repository/inputs.js' import type { OrderItemRow } from '../types/repository/rows.js' +import { safeJsonStringify } from '../utils/json.js' export type QueryExecutor = (text: string, params?: unknown[]) => Promise> @@ -71,8 +72,8 @@ export async function replaceOrderItemsWithExecutor( update.item.skuCode, update.item.skuName, update.item.quantity, - update.item.specJson || '{}', - update.item.itemSnapshotJson || update.item.specJson || '{}', + safeJsonStringify(update.item.specJson || '{}'), + safeJsonStringify(update.item.itemSnapshotJson || update.item.specJson || '{}'), update.item.updatedAt, update.orderItemId, ], @@ -98,8 +99,8 @@ export async function replaceOrderItemsWithExecutor( create.skuCode, create.skuName, create.quantity, - create.specJson || '{}', - create.itemSnapshotJson || create.specJson || '{}', + safeJsonStringify(create.specJson || '{}'), + safeJsonStringify(create.itemSnapshotJson || create.specJson || '{}'), create.createdAt, create.updatedAt, ], diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index f6976f07..3a8b5de9 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -8,6 +8,7 @@ import type { } from '../types/repository/inputs.js' import type { OrderListQueryResult, OrderListRow, OrderRow } from '../types/repository/rows.js' import { isPlatformOrderIdLookupKeyword } from '../utils/platform-order-id.js' +import { safeJsonStringify, sanitizeWellFormedUnicode } from '../utils/json.js' export type OrderQueryExecutor = ( text: string, @@ -147,12 +148,12 @@ export async function createOrderWithExecutor( input.platformOrderId, input.orderStatus, input.payStatus, - input.buyerId, - input.buyerName, - input.receiverContact, + sanitizeWellFormedUnicode(input.buyerId), + sanitizeWellFormedUnicode(input.buyerName), + sanitizeWellFormedUnicode(input.receiverContact), input.totalAmount, input.currency, - input.rawPayloadJson || '{}', + safeJsonStringify(input.rawPayloadJson || '{}'), input.paidAt || null, input.createdAt, input.updatedAt, @@ -202,12 +203,12 @@ export async function updateOrderWithExecutor( input.shopName, input.orderStatus, input.payStatus, - input.buyerId, - input.buyerName, - input.receiverContact, + sanitizeWellFormedUnicode(input.buyerId), + sanitizeWellFormedUnicode(input.buyerName), + sanitizeWellFormedUnicode(input.receiverContact), input.totalAmount, input.currency, - input.rawPayloadJson || '{}', + safeJsonStringify(input.rawPayloadJson || '{}'), input.paidAt || null, input.updatedAt, Number(orderId), diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 376ce79a..1c30dc9c 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -19,6 +19,7 @@ import { import { nowIso } from '../../utils/time.js' import { logDebug, logInfo, logIntegration, logWarn } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' +import { safeJsonStringify, sanitizeWellFormedUnicode } from '../../utils/json.js' import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' type SourceOrderItem = { @@ -138,12 +139,12 @@ export async function upsertOrderFromSource( platformOrderId: event.platformOrderId, orderStatus: event.orderStatus, payStatus: event.payStatus, - buyerId: event.buyerId, - buyerName: event.buyerName, - receiverContact: event.receiverContact, + buyerId: sanitizeWellFormedUnicode(event.buyerId), + buyerName: sanitizeWellFormedUnicode(event.buyerName), + receiverContact: sanitizeWellFormedUnicode(event.receiverContact), totalAmount: event.totalAmount, currency: event.currency, - rawPayloadJson: JSON.stringify(event.rawPayload), + rawPayloadJson: safeJsonStringify(event.rawPayload), ...(event.paidAt !== undefined ? { paidAt: event.paidAt } : {}), } const mergedPayload = mergeSourceOrderState(existing, basePayload) @@ -172,11 +173,11 @@ export async function upsertOrderFromSource( executor, order.id, resolvedFulfillmentItems.map((item) => ({ - skuCode: item.skuCode, - skuName: item.skuName, + skuCode: sanitizeWellFormedUnicode(item.skuCode), + skuName: sanitizeWellFormedUnicode(item.skuName), quantity: item.quantity, - specJson: JSON.stringify(item.spec || {}), - itemSnapshotJson: JSON.stringify(item.snapshot || item.spec || {}), + specJson: safeJsonStringify(item.spec || {}), + itemSnapshotJson: safeJsonStringify(item.snapshot || item.spec || {}), createdAt: now, updatedAt: now, })), diff --git a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts index be2a510a..3e391ad7 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts @@ -100,6 +100,33 @@ test('mapPddOrderToSourceEvent prioritizes out_sku_sn as primary matching field' assert.equal((item.spec as Record).spec, '暗潮1钻') }) +test('mapPddOrderToSourceEvent sanitizes lone unicode surrogates and null bytes in buyer nickname/memo', () => { + const pddOrderWithSurrogates: PddRawOrderItem = { + order_sn: '260919-616772441623292', + order_status: 2, + order_status_str: '已发货', + order_amount: 10000, + goods_id: 888, + goods_name: '测试商品', + nickname: '\ud835*VIP\ud83c', // Unpaired lone surrogates + buyer_memo: '游戏ID: 12345\0null', // Null byte + } + + const event = mapPddOrderToSourceEvent(pddOrderWithSurrogates, { + shopId: 'xiangrikui', + shopName: '向日葵网络游戏专营店', + }) + + assert.equal(event.buyerName, '\uFFFD*VIP\uFFFD') + assert.equal(event.buyerId, '\uFFFD*VIP\uFFFD') + assert.equal(event.receiverContact, '游戏ID: 12345null') + + // JSON stringification should be 100% valid without lone surrogates or null bytes + const serialized = JSON.stringify(event.rawPayload) + assert.ok(!serialized.includes('\0')) + assert.doesNotThrow(() => JSON.parse(serialized)) +}) + test('mapPddOrderToSourceEvent reads nested order_goods when top-level goods fields missing', () => { const nestedOnly: PddRawOrderItem = { order_sn: '260915-111', diff --git a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts index 45aaa6aa..571984b1 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts @@ -5,6 +5,7 @@ import type { SourceOrderEvent } from '../../order/order-service.js' import type { JsonObject } from '../../../types/json.js' +import { deepSanitizeUnicode, sanitizeWellFormedUnicode } from '../../../utils/json.js' export interface PddRawOrderItem { order_sn: string @@ -180,9 +181,13 @@ export function mapPddOrderToSourceEvent( const externalSkuCode = skuCode const externalSkuName = skuName + const cleanRawOrder = deepSanitizeUnicode(rawOrder) + const cleanNickname = sanitizeWellFormedUnicode(rawOrder.nickname || '') + const cleanBuyerMemo = sanitizeWellFormedUnicode(rawOrder.buyer_memo || '').trim() + // Retain the entire raw snapshot dictionary as requested / 保留原始报文完整快照 const itemSnapshot: JsonObject = { - ...(rawOrder as unknown as JsonObject), + ...(cleanRawOrder as unknown as JsonObject), goodsId: goodsIdStr, goodsName, spec, @@ -191,9 +196,9 @@ export function mapPddOrderToSourceEvent( quantity, orderAmount: Number(rawOrder.order_amount || 0), goodsAmount: Number(rawOrder.goods_amount || 0), - buyerMemo: String(rawOrder.buyer_memo || ''), - mallRemark: String(rawOrder.mall_remark || ''), - nickname: String(rawOrder.nickname || ''), + buyerMemo: cleanBuyerMemo, + mallRemark: sanitizeWellFormedUnicode(rawOrder.mall_remark || ''), + nickname: cleanNickname, orderTime: rawOrder.order_time, confirmTime: rawOrder.confirm_time, } @@ -206,14 +211,14 @@ export function mapPddOrderToSourceEvent( platformOrderId: orderSn, orderStatus, payStatus, - buyerId: String(rawOrder.nickname || ''), - buyerName: String(rawOrder.nickname || ''), + buyerId: cleanNickname, + buyerName: cleanNickname, // Receiver contact is often buyer's recharge game ID / 买家留言通常为充值游戏账号 - receiverContact: String(rawOrder.buyer_memo || '').trim(), + receiverContact: cleanBuyerMemo, totalAmount: Number(rawOrder.order_amount || 0), currency: 'CNY', paidAt, - rawPayload: rawOrder, + rawPayload: cleanRawOrder, items: [ { itemId: goodsIdStr, diff --git a/apps/backend/src/utils/json.test.ts b/apps/backend/src/utils/json.test.ts new file mode 100644 index 00000000..e05837b6 --- /dev/null +++ b/apps/backend/src/utils/json.test.ts @@ -0,0 +1,55 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + parseJsonObject, + sanitizeWellFormedUnicode, + deepSanitizeUnicode, + safeJsonStringify, +} from './json.js' + +test('sanitizeWellFormedUnicode handles null bytes and lone surrogates', () => { + // Null byte + assert.equal(sanitizeWellFormedUnicode('hello\0world'), 'helloworld') + + // Lone high surrogate \ud835 + const loneSurrogate = 'nickname\ud835test' + const cleaned = sanitizeWellFormedUnicode(loneSurrogate) + assert.equal(cleaned, 'nickname\uFFFDtest') + + // Valid emoji surrogate pair \uD83D\uDE00 (😀) should remain intact + const validEmoji = 'Hello 😀' + assert.equal(sanitizeWellFormedUnicode(validEmoji), 'Hello 😀') +}) + +test('deepSanitizeUnicode recursively cleans objects and arrays', () => { + const input = { + user: 'buyer\ud83c', + nested: { + comment: 'good\0deal', + tags: ['cool\ud835', 'ok'], + }, + } + + const output = deepSanitizeUnicode(input) + assert.deepEqual(output, { + user: 'buyer\uFFFD', + nested: { + comment: 'gooddeal', + tags: ['cool\uFFFD', 'ok'], + }, + }) +}) + +test('safeJsonStringify produces valid JSON safe for postgres jsonb', () => { + const input = { + orderSn: '260919-123456', + nickname: '\ud835\ud83c*buyer', + nullByte: 'abc\0def', + } + + const json = safeJsonStringify(input) + assert.ok(typeof json === 'string') + const parsed = JSON.parse(json) + assert.equal(parsed.nickname, '\uFFFD\uFFFD*buyer') + assert.equal(parsed.nullByte, 'abcdef') +}) diff --git a/apps/backend/src/utils/json.ts b/apps/backend/src/utils/json.ts index 4738ca5d..030a0707 100644 --- a/apps/backend/src/utils/json.ts +++ b/apps/backend/src/utils/json.ts @@ -64,6 +64,66 @@ function shouldPreserveIntegerToken(rawDigits: unknown): boolean { return unsigned > '9007199254740991' } +export function sanitizeWellFormedUnicode(value: unknown): string { + if (value === null || value === undefined) return '' + const str = String(value) + if (!str) return '' + // 1. Remove null bytes (\u0000) which PostgreSQL text/jsonb columns strictly forbid + const withoutNull = str.replace(/\0/g, '') + // 2. Convert lone surrogates into replacement character U+FFFD + const toWellFormedFn = (withoutNull as { toWellFormed?: () => string }).toWellFormed + return typeof toWellFormedFn === 'function' + ? toWellFormedFn.call(withoutNull) + : withoutNull.replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(value: T): T { + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string') { + return sanitizeWellFormedUnicode(value) as unknown as T + } + if (Array.isArray(value)) { + return value.map((item) => deepSanitizeUnicode(item)) as unknown as T + } + if (isPlainObject(value)) { + const res: Record = {} + for (const [k, v] of Object.entries(value)) { + const cleanKey = sanitizeWellFormedUnicode(k) + res[cleanKey] = deepSanitizeUnicode(v) + } + return res as unknown as T + } + return value +} + +export function safeJsonStringify(value: unknown, fallback = '{}'): string { + if (value === undefined || value === null) { + return fallback + } + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return fallback + try { + const parsed = JSON.parse(trimmed) + const sanitized = deepSanitizeUnicode(parsed) + return JSON.stringify(sanitized) + } catch { + return JSON.stringify(sanitizeWellFormedUnicode(value)) + } + } + try { + const sanitized = deepSanitizeUnicode(value) + return JSON.stringify(sanitized) + } catch { + return fallback + } +} + function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' }