3 Commits
Author SHA1 Message Date
yml2213andCursor 8eef95fe97 style: 修复 affiliate-dash http-client Prettier 格式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:05:18 +08:00
yml2213andCursor 8e996e01fb fix(pdd): 停止旧单刷匹配日志,并修复订单列表商品显示为 0
同步时已有工单/匹配结果未变不再写日志;列表按 Number(order_id) 挂 items,并补齐 PDD goods_name 回退与嵌套商品解析。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:05:02 +08:00
yml2213andCursor 2e197f93e0 fix(claim): 透出 affiliate_dash 上游错误,便于排查提交 UID 失败
上游 4xx 不再一律打成 502 兜底文案;affiliate_dash 依赖错误优先展示真实 message。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:51:32 +08:00
10 changed files with 263 additions and 60 deletions
@@ -231,6 +231,27 @@ export async function createWorkProductMatchLog(input: {
return result.rows[0] || null
}
/** 同一订单项最近一条匹配日志(用于同步去重,避免旧单反复写入)。 */
export async function getLatestWorkProductMatchLogByOrderItemId(
orderItemId: number | string,
): Promise<WorkProductMatchLogRow | null> {
const id = Number(orderItemId)
if (!Number.isInteger(id) || id <= 0) {
return null
}
const result = await query<WorkProductMatchLogRow>(
`
SELECT *
FROM work_product_match_logs
WHERE order_item_id = $1
ORDER BY id DESC
LIMIT 1
`,
[id],
)
return result.rows[0] || null
}
/** 匹配日志分页查询:表本身全量保留,可按结果状态、关键词(商品/SKU/店铺)与日期范围筛选。 */
export async function listWorkProductMatchLogs({
page = 1,
@@ -101,6 +101,42 @@ test('mapAdminOrderListItem falls back to order raw_payload_json when orderItems
assert.equal(mapped.itemSummary, '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪 (安卓血宴1钻)')
})
test('mapAdminOrderListItem falls back to PDD goods_name/out_sku_sn when orderItems empty', () => {
const order: OrderListRow = {
id: 3,
provider: 'pdd',
platform: 'pdd',
shop_id: 'xiangrikui',
shop_name: '向日葵',
platform_order_id: '260915-72034516082531',
order_status: 'paid',
pay_status: 'paid',
buyer_id: '',
buyer_name: '',
receiver_contact: '',
total_amount: 34100,
currency: 'CNY',
raw_payload_json: {
order_sn: '260915-72034516082531',
goods_name: '和平精英地铁逃生苏尔南暗潮怒嚎血宴',
spec: '安卓怒嚎3钻',
out_sku_sn: '安卓怒嚎3钻',
goods_number: 1,
order_amount: 34100,
},
paid_at: '2026-09-15T02:48:00.000Z',
created_at: '2026-09-15T02:48:00.000Z',
updated_at: '2026-09-15T02:48:00.000Z',
}
const mapped = mapAdminOrderListItem(order, { orderItems: [], tasks: [] })
assert.equal(mapped.itemTitle, '和平精英地铁逃生苏尔南暗潮怒嚎血宴')
assert.equal(mapped.skuNick, '安卓怒嚎3钻')
assert.equal(mapped.itemCount, 1)
assert.equal(mapped.totalQuantity, 1)
})
test('summarizeOrderItems formats multiple items cleanly', () => {
const items: OrderItemRow[] = [
{
@@ -93,7 +93,7 @@ export async function getAdminOrders(
dateTo: normalizeDateQuery(query.dateTo, true),
})
const orderIds = items.map((item) => item.id)
const orderIds = items.map((item) => Number(item.id)).filter((id) => id > 0)
const oids = items.map((item) => item.platform_order_id).filter(Boolean)
const [orderItems, tasks, vouchers] = await Promise.all([
listOrderItemsByOrderIds(orderIds),
@@ -105,23 +105,29 @@ export async function getAdminOrders(
const vouchersByOid = groupRowsByOid(vouchers)
return {
items: items.map((item) =>
mapAdminOrderListItem(item, {
orderItems: orderItemsByOrderId.get(item.id) || [],
tasks: tasksByOrderId.get(item.id) || [],
items: items.map((item) => {
const orderId = Number(item.id)
return mapAdminOrderListItem(item, {
orderItems: orderItemsByOrderId.get(orderId) || [],
tasks: tasksByOrderId.get(orderId) || [],
vouchers: vouchersByOid.get(item.platform_order_id) || [],
}),
),
})
}),
pagination: { page, pageSize, total },
}
}
function groupRowsByOrderId<T extends { order_id: number }>(rows: T[]): Map<number, T[]> {
function groupRowsByOrderId<T extends { order_id: number | string }>(rows: T[]): Map<number, T[]> {
const grouped = new Map<number, T[]>()
for (const row of rows) {
const group = grouped.get(row.order_id) || []
// pg BIGINT 常以 string 返回;统一 Number 键,避免列表挂不上 order_items →「商品 0」。
const orderId = Number(row.order_id)
if (!Number.isFinite(orderId) || orderId <= 0) {
continue
}
const group = grouped.get(orderId) || []
group.push(row)
grouped.set(row.order_id, group)
grouped.set(orderId, group)
}
return grouped
}
@@ -462,6 +462,8 @@ export function resolveOrderItemTitle(
spec.goods_name,
spec.goodsName,
spec.productName,
snapshot.goods_name,
snapshot.goodsName,
snapshot.productName,
])
@@ -482,6 +484,13 @@ export function resolveOrderItemTitle(
orderPayload.productName,
body.title,
orderPayload.title,
// 拼多多 MMS 字段
body.goods_name,
orderPayload.goods_name,
body.goodsName,
orderPayload.goodsName,
body.spec,
orderPayload.spec,
])
if (fromOrder) {
return fromOrder
@@ -533,6 +542,12 @@ export function resolveOrderItemSkuNick(
snapshot.externalSkuName,
snapshot.externalSkuNameNormalized,
snapshot.skuNick,
spec.outSkuSn,
spec.out_sku_sn,
snapshot.outSkuSn,
snapshot.out_sku_sn,
spec.spec,
snapshot.spec,
spec.skuName,
])
@@ -552,7 +567,17 @@ export function resolveOrderItemSkuNick(
orderPayload.body && typeof orderPayload.body === 'object' ? orderPayload.body : orderPayload,
)
const ext = parseExtRecord(body.ext || orderPayload.ext)
const fromOrder = pickFirstNonEmpty([ext.skuNick, body.skuNick, orderPayload.skuNick])
const fromOrder = pickFirstNonEmpty([
ext.skuNick,
body.skuNick,
orderPayload.skuNick,
body.out_sku_sn,
orderPayload.out_sku_sn,
body.outSkuSn,
orderPayload.outSkuSn,
body.spec,
orderPayload.spec,
])
if (fromOrder) {
return fromOrder
}
@@ -581,8 +606,9 @@ export function resolveOrderItemDeliveryMode(
tasks: TaskLike[] | null | undefined,
orderItemId: number | string,
): string {
const normalizedOrderItemId = Number(orderItemId)
const task = (Array.isArray(tasks) ? tasks : []).find(
(item) => item.order_item_id === orderItemId,
(item) => Number(item.order_item_id) === normalizedOrderItemId,
)
if (!task) {
@@ -95,18 +95,19 @@ export async function affiliateDashRequest(input: {
logExternalHttpPacket('[affiliate-dash/http]', 'HTTP 响应失败', responsePacket, {
level: 'warn',
})
throw createHttpError(
summarizeAffiliateDashMessage(json) || `affiliate-dash 请求失败: HTTP ${response.status}`,
{
statusCode: 502,
errorCode: 'affiliate_dash_http_failed',
context: {
status: response.status,
message: summarizeAffiliateDashMessage(json),
body: text,
},
const upstreamMessage =
summarizeAffiliateDashMessage(json) || `affiliate-dash 请求失败: HTTP ${response.status}`
// 上游 4xx 透传状态码,便于领取页展示真实原因;5xx 仍归一为 502。
const statusCode = response.status >= 400 && response.status < 500 ? response.status : 502
throw createHttpError(upstreamMessage, {
statusCode,
errorCode: 'affiliate_dash_http_failed',
context: {
status: response.status,
message: summarizeAffiliateDashMessage(json),
body: text,
},
)
})
}
if (!isAffiliateDashSuccessResponse(json)) {
@@ -99,3 +99,31 @@ test('mapPddOrderToSourceEvent prioritizes out_sku_sn as primary matching field'
assert.equal((item.spec as Record<string, unknown>).goodsName, '和平精英苏尔南手枪皮肤')
assert.equal((item.spec as Record<string, unknown>).spec, '暗潮1钻')
})
test('mapPddOrderToSourceEvent reads nested order_goods when top-level goods fields missing', () => {
const nestedOnly: PddRawOrderItem = {
order_sn: '260915-111',
order_status: 2,
order_status_str: '已发货',
order_amount: 9900,
goods_id: '',
goods_name: '',
order_goods: [
{
goods_id: 555,
goods_name: '套装-浪漫天命',
spec: '默认',
goods_number: 2,
out_sku_sn: 'suit_romantic',
},
],
} as PddRawOrderItem
const event = mapPddOrderToSourceEvent(nestedOnly, { shopId: 's1', shopName: '店1' })
const item = event.items[0]!
assert.equal(item.skuCode, 'suit_romantic')
assert.equal(item.skuName, 'suit_romantic')
assert.equal(item.quantity, 2)
assert.equal((item.spec as Record<string, unknown>).goodsName, '套装-浪漫天命')
assert.equal((item.spec as Record<string, unknown>).goodsId, '555')
})
@@ -38,6 +38,13 @@ export interface MapPddOrderOptions {
shopName: string
}
type PddGoodsLine = {
goodsId: string
goodsName: string
spec: string
quantity: number
}
/**
* Extract external SKU code (out_sku_sn) from raw PDD order payload.
* 从拼多多原始报文中提取外部商家编码 out_sku_sn(作为派发工单及商品匹配的关键字段)。
@@ -53,28 +60,71 @@ export function extractPddOutSkuSn(rawOrder: PddRawOrderItem): string {
return direct.trim()
}
for (const entry of listPddNestedGoodsEntries(rawOrder)) {
const nestedSn =
entry?.out_sku_sn ||
entry?.outer_id ||
entry?.out_goods_sn ||
entry?.outSkuSn ||
entry?.outerId
if (typeof nestedSn === 'string' && nestedSn.trim()) {
return nestedSn.trim()
}
}
return ''
}
/**
* 从顶层或嵌套商品行提取 goods_id / goods_name / spec / quantity。
* 部分 MMS 报文商品字段只在 order_goods / item_list 内。
*/
export function extractPddGoodsLine(rawOrder: PddRawOrderItem): PddGoodsLine {
const top = {
goodsId: String(rawOrder.goods_id || '').trim(),
goodsName: String(rawOrder.goods_name || '').trim(),
spec: String(rawOrder.spec || '').trim(),
quantity: Math.max(1, Number(rawOrder.goods_number || 1) || 1),
}
if (top.goodsId || top.goodsName || top.spec) {
return top
}
for (const entry of listPddNestedGoodsEntries(rawOrder)) {
const goodsId = String(entry?.goods_id || entry?.goodsId || entry?.sku_id || '').trim()
const goodsName = String(entry?.goods_name || entry?.goodsName || entry?.name || '').trim()
const spec = String(entry?.spec || entry?.sku_spec || entry?.skuSpec || '').trim()
const quantity = Math.max(
1,
Number(entry?.goods_number || entry?.goods_count || entry?.quantity || 1) || 1,
)
if (goodsId || goodsName || spec) {
return { goodsId, goodsName, spec, quantity }
}
}
return top
}
function listPddNestedGoodsEntries(rawOrder: PddRawOrderItem): Array<Record<string, any>> {
const nestedCandidates = [
(rawOrder as any).order_items,
(rawOrder as any).order_goods,
(rawOrder as any).items,
(rawOrder as any).order_item_list,
(rawOrder as any).item_list,
]
const entries: Array<Record<string, any>> = []
for (const list of nestedCandidates) {
if (Array.isArray(list)) {
for (const entry of list) {
const nestedSn =
entry?.out_sku_sn ||
entry?.outer_id ||
entry?.out_goods_sn ||
entry?.outSkuSn ||
entry?.outerId
if (typeof nestedSn === 'string' && nestedSn.trim()) {
return nestedSn.trim()
}
if (!Array.isArray(list)) {
continue
}
for (const entry of list) {
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
entries.push(entry as Record<string, any>)
}
}
}
return ''
return entries
}
/**
@@ -114,11 +164,12 @@ export function mapPddOrderToSourceEvent(
const timestampSec = rawOrder.confirm_time || rawOrder.order_time || Math.floor(Date.now() / 1000)
const paidAt = new Date(timestampSec * 1000).toISOString()
// Build items array / 构建订单商品明细
const goodsIdStr = String(rawOrder.goods_id || '')
const goodsName = String(rawOrder.goods_name || '').trim()
const spec = String(rawOrder.spec || '').trim()
const quantity = Math.max(1, Number(rawOrder.goods_number || 1))
// Build items array / 构建订单商品明细(顶层缺失时回退嵌套商品行)
const goodsLine = extractPddGoodsLine(rawOrder)
const goodsIdStr = goodsLine.goodsId
const goodsName = goodsLine.goodsName
const spec = goodsLine.spec
const quantity = goodsLine.quantity
// 提取商家外部编码 out_sku_sn(如 "安卓血宴3钻"),作为核心匹配字段
const outSkuSn = extractPddOutSkuSn(rawOrder)
@@ -9,6 +9,7 @@ import {
createWorkProductMatchLog,
createWorkOrder,
createWorkOrderEvent,
getLatestWorkProductMatchLogByOrderItemId,
getWorkOrderByOrderItemId,
listWorkOrdersByPlatformOrderId,
listWorkProductRuleMappings,
@@ -68,8 +69,30 @@ export async function syncWorkerOrdersForSourceOrder(
const skuNick =
matchContext.skuNick || String(outSkuSn || snapshot.spec || item.sku_name || '').trim()
// Record product match diagnostics for all platform orders (including PDD)
if (sellerId || relItemId || itemTitle) {
// 已有工单:跳过匹配日志,避免 PDD 定时同步对旧单反复刷「命中」日志。
const existingWorkOrder = await getWorkOrderByOrderItemId(item.id)
if (existingWorkOrder) {
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
decisions.push(
buildWorkerConsumerDecision(item, {
result: 'existing',
reason: 'already_exists',
ruleId: match.rule?.id,
ruleKey: match.rule?.rule_key,
}),
)
continue
}
const latestLog = await getLatestWorkProductMatchLogByOrderItemId(item.id)
const sameMatchOutcome =
latestLog &&
String(latestLog.match_status || '') === match.reason &&
Number(latestLog.rule_id || 0) === Number(match.rule?.id || 0) &&
Number(latestLog.mapping_id || 0) === Number(match.mappingId || 0)
// 匹配结果未变时不重复写诊断日志(旧单未建工单也会被定时同步反复扫到)。
if (!sameMatchOutcome && (sellerId || relItemId || itemTitle)) {
await createWorkProductMatchLog({
orderId: Number(order.id),
orderItemId: Number(item.id),
@@ -122,18 +145,6 @@ export async function syncWorkerOrdersForSourceOrder(
)
continue
}
if (await getWorkOrderByOrderItemId(item.id)) {
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
decisions.push(
buildWorkerConsumerDecision(item, {
result: 'existing',
reason: 'already_exists',
ruleId: rule.id,
ruleKey: rule.rule_key,
}),
)
continue
}
// 订单号跨订单唯一:同一订单的多个 SKU 件允许共享订单号(按件建单是既有设计),
// 但订单号已挂在其他订单下时跳过,避免自动同步产生撞号工单。
const samePlatformOrders = await listWorkOrdersByPlatformOrderId(order.platform_order_id)
+20 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveRouteErrorLogLevel } from './http.js'
import { buildErrorPayload, createHttpError, resolveRouteErrorLogLevel } from './http.js'
test('expected worker session expiry is debug-level', () => {
assert.equal(
@@ -14,3 +14,22 @@ test('expected worker session expiry is debug-level', () => {
)
assert.equal(resolveRouteErrorLogLevel({ statusCode: 500, errorCode: 'internal_error' }), 'error')
})
test('affiliate_dash errors surface upstream message even when mapped to 502', () => {
const error = createHttpError('乐玩开放平台 AccessKey 或 SecretKey 未配置', {
statusCode: 502,
errorCode: 'affiliate_dash_http_failed',
})
const payload = buildErrorPayload(error, '提交 UID 失败')
assert.equal(payload.msg, '乐玩开放平台 AccessKey 或 SecretKey 未配置')
assert.equal(payload.errorCode, 'affiliate_dash_http_failed')
})
test('generic 5xx still uses fallback message', () => {
const error = createHttpError('secret internal boom', {
statusCode: 500,
errorCode: 'internal_error',
})
const payload = buildErrorPayload(error, '服务内部错误')
assert.equal(payload.msg, '服务内部错误')
})
+9 -5
View File
@@ -29,12 +29,16 @@ export function buildSuccessPayload(data: unknown, msg: string = 'ok') {
export function buildErrorPayload(error: unknown, fallbackMessage: string) {
const classification = classifyRouteError(error)
const message =
classification.statusCode >= 500
const errorMessage = error instanceof Error ? String(error.message || '').trim() : ''
// 依赖方错误(如 affiliate_dash)即使映射为 5xx,也优先透出上游可读原因。
const preferUpstreamMessage =
errorMessage &&
(classification.statusCode < 500 || classification.errorCode.startsWith('affiliate_dash'))
const message = preferUpstreamMessage
? errorMessage
: classification.statusCode >= 500
? fallbackMessage || '服务内部错误'
: error instanceof Error
? error.message
: fallbackMessage
: errorMessage || fallbackMessage
return {
code: 1,