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:
@@ -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<QueryResult<any>>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 = <T extends QueryResultRow = QueryResultRow>(
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
|
||||
'\uFFFD',
|
||||
)
|
||||
}
|
||||
|
||||
export function deepSanitizeUnicode<T>(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<string, unknown> = {}
|
||||
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]'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user