fix(order-service): 修复包含 Emoji 和孤立代理字符的订单在 PostgreSQL jsonb 下报错导致死循环重试进单的问题

1. 在 utils/json.ts 新增 sanitizeWellFormedUnicode、deepSanitizeUnicode 和 safeJsonStringify,自动清理空字节 (\0) 并将畸变孤立 UTF-16 代理字符 (Lone Surrogates) 安全转换为规范字符,杜绝 PostgreSQL jsonb 解析错误;
2. 拼多多订单映射器与通用进单服务 (order-service) 全面接入 Unicode 深度清洗与安全序列化,确保买家昵称/留言中的各类特殊 Emoji 和生僻字符平滑落库;
3. 数据库仓储层 (order-repo / order-item-repo) 针对 raw_payload_json、spec_json、item_snapshot_json 等 jsonb 字段增加兜底安全序列化保护;
4. 补充 utils/json.test.ts 与 pdd-order-mapper.test.ts 单元测试,覆盖畸变字符与空字节场景。
This commit is contained in:
yml2213
2026-09-19 11:40:02 +08:00
parent 9a342e7ea6
commit 5e59a5fdb1
7 changed files with 178 additions and 28 deletions
@@ -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,
})),
@@ -100,6 +100,33 @@ test('mapPddOrderToSourceEvent prioritizes out_sku_sn as primary matching field'
assert.equal((item.spec as Record<string, unknown>).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',
@@ -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,