新增 affiliate_dash 履约 executor(阶段 2)

- executor 常量/守卫 + affiliate-dash-executor(preparePaidTask/resolveDeliveryLink)
- 履约业务实现:prepareAffiliateDashTask(幂等建单, client_order_no=task_no, data 透传 91单号/game_account)
  + syncAffiliateDashTaskStatus(状态合并: delivering→redeeming / delivered→核销→redeemed /
  ship_failed→retry_pending / cancelled→closed)
- registry 注册 + 测试(212 全绿)
- 真实建单联调通过(线上 skin.khhao.com,幂等不重复扣款)
- 修复 POST /orders 响应 data.order 映射层级
This commit is contained in:
yml2213
2026-08-05 14:56:36 +08:00
parent a6ba5f1c7f
commit 867ffd2f3c
7 changed files with 457 additions and 3 deletions
@@ -0,0 +1,329 @@
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { logIntegration } from '../../../utils/logger.js'
import { parseTaskContext } from '../../../utils/task-json.js'
import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { getClaimIdentityFromContext } from '../../claim/claim-identity.js'
import type { JsonObject } from '../../../types/json.js'
import {
createAffiliateDashOrder,
getAffiliateDashOrder,
type AffiliateDashOrder,
} from '../../platforms/affiliate-dash/order-service.js'
import { getAffiliateDashConfig } from '../../platforms/affiliate-dash/config.js'
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
import type { TaskRow } from '../../../types/repository/rows.js'
export function isAffiliateDashTask(task: Partial<TaskRow> | null | undefined) {
return String(task?.executor_key || '').trim() === 'affiliate_dash'
}
/**
* affiliate_dash 履约任务:下单建单(preparePaidTask)→ 用户在统一领取页完成
* delivery/bind/submit(阶段 4)→ 回调 order.shipping.updated 驱动状态(阶段 5)。
*/
export async function prepareAffiliateDashTask(task: TaskRow) {
if (!isAffiliateDashTask(task)) {
throw createHttpError('当前任务不是 affiliate-dash 履约任务', {
statusCode: 409,
errorCode: 'affiliate_dash_task_invalid',
})
}
const now = nowIso()
const taskContext = parseTaskContext(task)
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
// 幂等:已建单直接收敛到 link_generated(不重复扣款)
if (flow.orderNo) {
return updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED,
user_action_status: 'pending_claim',
context_json: JSON.stringify({
...taskContext,
affiliateDash: flow,
}),
updated_at: now,
})
}
if (!flow.sku) {
throw createHttpError('affiliate-dash 商品 sku 缺失,请配置 91 商品 → affiliate_dash sku 映射', {
statusCode: 409,
errorCode: 'affiliate_dash_sku_missing',
})
}
const clientOrderNo = buildAffiliateDashClientOrderNo(task)
const data = buildAffiliateDashOrderData(task, taskContext)
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
const order = await createAffiliateDashOrder({
clientOrderNo,
sku: flow.sku,
buyerReference: buyerReference || undefined,
data: Object.keys(data).length ? data : undefined,
})
const nextFlow = mergeAffiliateDashOrder(flow, order, {
clientOrderNo,
syncedAt: now,
})
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED,
user_action_status: 'pending_claim',
result_code: 'affiliate_dash_order_created',
result_message: 'affiliate-dash 订单已创建',
context_json: JSON.stringify({
...taskContext,
affiliateDash: nextFlow,
}),
updated_at: now,
})
await createTaskEvent(
task.id,
'affiliate_dash_order_created',
{
orderNo: order.orderNo,
clientOrderNo,
sku: flow.sku,
amount: order.amount,
currency: order.currency,
orderStatus: order.orderStatus,
canShip: order.canShip,
},
now,
)
logIntegration('[affiliate-dash]', 'affiliate-dash 订单已创建', {
taskId: task.id,
orderNo: order.orderNo,
clientOrderNo,
sku: flow.sku,
amount: order.amount,
currency: order.currency,
})
return updatedTask
}
/**
* 状态合并(回调 / 主动对账共用,可重入):
* paid → link_generateddelivering → redeeming
* delivered → 核销行业电子凭证 → redeemed / manual_review
* ship_failed → retry_pendingcancelled → closed。
*/
export async function syncAffiliateDashTaskStatus(task: TaskRow) {
if (!isAffiliateDashTask(task)) {
return task
}
const taskContext = parseTaskContext(task)
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
if (!flow.orderNo) {
return task
}
const now = nowIso()
const order = await getAffiliateDashOrder(flow.orderNo)
let nextTaskStatus = task.task_status
let deliveryStatus = task.delivery_status
let redeemedAt = task.redeemed_at
let resultCode = task.result_code
let resultMessage = task.result_message
let lastError = task.last_error
const nextFlow = mergeAffiliateDashOrder(flow, order, { syncedAt: now })
let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher
switch (order.orderStatus) {
case 'paid':
break
case 'delivering':
nextTaskStatus = TASK_STATUS.REDEEMING
deliveryStatus = 'delivering'
resultCode = 'affiliate_dash_delivering'
resultMessage = order.failureReason || 'affiliate-dash 发货处理中'
lastError = ''
break
case 'delivered': {
const consumeResult = await consumeKuaishouIndustryVouchersForTask(task, {
source: 'affiliate_dash_delivered',
consumeTime: Date.now(),
})
if (consumeResult.ok || consumeResult.vouchers.length === 0) {
nextTaskStatus = TASK_STATUS.REDEEMED
deliveryStatus = 'delivered'
redeemedAt = redeemedAt || now
resultCode = 'affiliate_dash_delivered'
resultMessage = 'affiliate-dash 履约成功'
lastError = ''
nextFlow.consumeStatus = consumeResult.vouchers.length > 0 ? 'success' : 'not_required'
logIntegration('[affiliate-dash]', 'affiliate-dash 履约完成,行业电子凭证核销完成', {
taskId: task.id,
orderNo: flow.orderNo,
voucherCount: consumeResult.vouchers.length,
consumedCount: consumeResult.consumed.length,
consumeStatus: nextFlow.consumeStatus,
})
} else {
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW
resultCode = 'affiliate_dash_industry_consume_failed'
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
lastError = resultMessage
nextFlow.consumeStatus = 'failed'
logIntegration('[affiliate-dash]', 'affiliate-dash 履约完成但核销失败', {
taskId: task.id,
orderNo: flow.orderNo,
errorMessage: resultMessage,
}, { level: 'warn' })
}
break
}
case 'ship_failed':
nextTaskStatus = TASK_STATUS.RETRY_PENDING
resultCode = 'affiliate_dash_ship_failed'
resultMessage = order.failureReason || 'affiliate-dash 发货失败,可重试'
lastError = resultMessage
break
case 'cancelled':
nextTaskStatus = TASK_STATUS.CLOSED
resultCode = 'affiliate_dash_cancelled'
resultMessage = order.failureReason || 'affiliate-dash 订单已取消'
lastError = ''
break
default:
break
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
delivery_status: deliveryStatus,
redeemed_at: redeemedAt,
result_code: resultCode,
result_message: resultMessage,
last_error: lastError,
context_json: JSON.stringify({
...taskContext,
affiliateDash: nextFlow,
kuaishouIndustryVoucher: nextIndustryVoucher,
}),
updated_at: now,
})
return updatedTask || task
}
export type AffiliateDashFlow = {
flowType: 'affiliate_dash'
sku: string
productName: string
orderNo: string
clientOrderNo: string
orderStatus: string
canShip: boolean
cannotShipReason: string
providerOrderNo: string
failureReason: string
amount: number
currency: string
/** 阶段 4 交付交互数据(bind/submit */
bindUuid: string
bindUrl: string
qrUrl: string
gameAccount: string
expectedGameAccount: string
bindMismatch: boolean
submitStatus: string
consumeStatus: string
lastSyncedAt: unknown
raw: unknown
}
export function normalizeAffiliateDashFlow(value: unknown): AffiliateDashFlow {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
return {
flowType: 'affiliate_dash',
sku: String(source.sku || '').trim(),
productName: String(source.productName || '').trim(),
orderNo: String(source.orderNo || '').trim(),
clientOrderNo: String(source.clientOrderNo || '').trim(),
orderStatus: String(source.orderStatus || '').trim(),
canShip: Boolean(source.canShip),
cannotShipReason: String(source.cannotShipReason || '').trim(),
providerOrderNo: String(source.providerOrderNo || '').trim(),
failureReason: String(source.failureReason || '').trim(),
amount: Number(source.amount || 0) || 0,
currency: String(source.currency || '').trim(),
bindUuid: String(source.bindUuid || '').trim(),
bindUrl: String(source.bindUrl || '').trim(),
qrUrl: String(source.qrUrl || '').trim(),
gameAccount: String(source.gameAccount || '').trim(),
expectedGameAccount: String(source.expectedGameAccount || '').trim(),
bindMismatch: Boolean(source.bindMismatch),
submitStatus: String(source.submitStatus || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(),
lastSyncedAt: source.lastSyncedAt || null,
raw: source.raw && typeof source.raw === 'object' ? source.raw : null,
}
}
/** client_order_no = task_no(每 task 唯一,幂等键;勿用 91 单号——拆单会冲突)。 */
export function buildAffiliateDashClientOrderNo(task: TaskRow) {
return String(task.task_no || `OS-AD-${task.id}`).trim()
}
function buildAffiliateDashOrderData(task: TaskRow, context: Record<string, unknown>): JsonObject {
const identity = getClaimIdentityFromContext(context)
const data: JsonObject = {}
const platformOrderId = String(task.platform_order_id || '').trim()
if (platformOrderId) {
data['91单号'] = platformOrderId
}
if (identity.expectedUid) {
data.game_account = identity.expectedUid
}
return data
}
function mergeAffiliateDashOrder(
flow: AffiliateDashFlow,
order: AffiliateDashOrder,
patch: {
clientOrderNo?: string
syncedAt: string
},
): AffiliateDashFlow {
return {
...flow,
sku: order.sku || flow.sku,
productName: order.productName || flow.productName,
orderNo: order.orderNo || flow.orderNo,
clientOrderNo: patch.clientOrderNo || flow.clientOrderNo,
orderStatus: order.orderStatus || flow.orderStatus,
canShip: order.canShip,
cannotShipReason: order.cannotShipReason || flow.cannotShipReason,
providerOrderNo: order.providerOrderNo || flow.providerOrderNo,
failureReason: order.failureReason || flow.failureReason,
amount: order.amount || flow.amount,
currency: order.currency || flow.currency,
lastSyncedAt: patch.syncedAt,
raw: order.raw || flow.raw,
}
}
/** 供阶段 4 领取页使用:读取 flowcontext_json #>> '{affiliateDash,...}')。 */
export function getAffiliateDashConfigSnapshot() {
const config = getAffiliateDashConfig()
return {
enabled: config.enabled,
baseUrl: config.baseUrl,
}
}
@@ -0,0 +1,70 @@
import { buildClaimUrl } from '../../claim/claim-service.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { prepareAffiliateDashTask } from '../affiliate-dash/index.js'
import {
FULFILLMENT_EXECUTOR_KEYS,
type FulfillmentDeliveryLink,
type FulfillmentExecutor,
type FulfillmentPrepareDeps,
} from './types.js'
import type { TaskRow } from '../../../types/repository/rows.js'
export const affiliateDashExecutor: FulfillmentExecutor = {
key: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
preparePaidTask,
resolveDeliveryLink,
}
async function preparePaidTask(
task: TaskRow,
deps: FulfillmentPrepareDeps,
): Promise<TaskRow | null> {
try {
let workingTask = task
if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) {
const claimToken = await deps.createTaskClaimToken(task.id)
workingTask =
(await deps.updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
updated_at: deps.nowIso(),
})) || task
}
return await prepareAffiliateDashTask(workingTask)
} catch (error) {
const message = error instanceof Error ? error.message : 'affiliate-dash 订单创建失败'
const updatedTask = await deps.updateTask(task.id, {
task_status: TASK_STATUS.MANUAL_REVIEW,
user_action_status: 'not_required',
last_error: message,
result_code: 'affiliate_dash_prepare_failed',
result_message: message,
updated_at: deps.nowIso(),
})
await deps.notifyTaskAutoManualReview({
task: updatedTask || task,
reason: message,
source: 'affiliate_dash_prepare_failed',
})
return updatedTask
}
}
/**
* 对外(91 等)统一返回本站领取链接(方案 B:自建领取页,不返回 affiliate_dash H5)。
*/
async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLink | null> {
const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim()
const expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim()
if (!primaryToken) {
return null
}
return {
claimUrl: buildClaimUrl(primaryToken),
expireTime,
}
}
@@ -21,7 +21,7 @@ function makeTask(executorKey: string): TaskRow {
} as TaskRow } as TaskRow
} }
test('getFulfillmentExecutor 映射 lewan / industry / feifei / manual', () => { test('getFulfillmentExecutor 映射 lewan / industry / feifei / affiliate_dash / manual', () => {
assert.equal( assert.equal(
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD)?.key, getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD)?.key,
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
@@ -34,6 +34,10 @@ test('getFulfillmentExecutor 映射 lewan / industry / feifei / manual', () => {
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI)?.key, getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI)?.key,
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
) )
assert.equal(
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH)?.key,
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
)
assert.equal( assert.equal(
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH)?.key, getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH)?.key,
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
@@ -41,6 +45,16 @@ test('getFulfillmentExecutor 映射 lewan / industry / feifei / manual', () => {
assert.equal(getFulfillmentExecutor('unknown'), null) assert.equal(getFulfillmentExecutor('unknown'), null)
}) })
test('affiliate_dash executor 暴露 preparePaidTask 与 resolveDeliveryLink', () => {
const executor = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH)
assert.ok(executor)
assert.equal(typeof executor.preparePaidTask, 'function')
assert.equal(typeof executor.resolveDeliveryLink, 'function')
// affiliate_dash 不需要 lewan 专属动作
assert.equal(executor.prepareBinding, undefined)
assert.equal(executor.redeemTask, undefined)
})
test('lewan executor 暴露完整履约动作', () => { test('lewan executor 暴露完整履约动作', () => {
const executor = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) const executor = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD)
assert.ok(executor) assert.ok(executor)
@@ -2,6 +2,7 @@ import { isPaidPreparationStableStatus } from '../../../domain/task-status.js'
import { kuaishouCloudExecutor } from './kuaishou-cloud-executor.js' import { kuaishouCloudExecutor } from './kuaishou-cloud-executor.js'
import { kuaishouFeifeiExecutor } from './kuaishou-feifei-executor.js' import { kuaishouFeifeiExecutor } from './kuaishou-feifei-executor.js'
import { manualDispatchExecutor } from './manual-executor.js' import { manualDispatchExecutor } from './manual-executor.js'
import { affiliateDashExecutor } from './affiliate-dash-executor.js'
import { import {
FULFILLMENT_EXECUTOR_KEYS, FULFILLMENT_EXECUTOR_KEYS,
isManualDispatchExecutor, isManualDispatchExecutor,
@@ -17,6 +18,7 @@ const EXECUTORS = new Map<string, FulfillmentExecutor>([
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, kuaishouCloudExecutor], [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, kuaishouCloudExecutor],
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY, kuaishouCloudExecutor], [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY, kuaishouCloudExecutor],
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, kuaishouFeifeiExecutor], [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, kuaishouFeifeiExecutor],
[FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, affiliateDashExecutor],
[FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor], [FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor],
]) ])
@@ -6,6 +6,7 @@ export const FULFILLMENT_EXECUTOR_KEYS = {
KUAISHOU_CLOUD: 'kuaishou_ct_assisted', KUAISHOU_CLOUD: 'kuaishou_ct_assisted',
KUAISHOU_INDUSTRY: 'kuaishou-industry', KUAISHOU_INDUSTRY: 'kuaishou-industry',
KUAISHOU_FEIFEI: 'kuaishou_feifei', KUAISHOU_FEIFEI: 'kuaishou_feifei',
AFFILIATE_DASH: 'affiliate_dash',
} as const } as const
export type FulfillmentExecutorKey = export type FulfillmentExecutorKey =
@@ -111,6 +112,10 @@ export function isKuaishouFeifeiExecutor(value: unknown): boolean {
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
} }
export function isAffiliateDashExecutor(value: unknown): boolean {
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
}
export function isManualDispatchExecutor(value: unknown): boolean { export function isManualDispatchExecutor(value: unknown): boolean {
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
} }
@@ -2,7 +2,10 @@ import type { JsonObject } from '../../../types/json.js'
import { affiliateDashRequest } from './http-client.js' import { affiliateDashRequest } from './http-client.js'
export type AffiliateDashOrder = ReturnType<typeof mapAffiliateDashOrder> export type AffiliateDashOrder = ReturnType<typeof mapAffiliateDashOrder> & {
/** 命中幂等(200 + idempotent=true)时为 true,仅创建订单响应存在 */
idempotent?: boolean
}
export type AffiliateDashDeliveryInfo = ReturnType<typeof mapAffiliateDashDeliveryInfo> export type AffiliateDashDeliveryInfo = ReturnType<typeof mapAffiliateDashDeliveryInfo>
export type AffiliateDashBindResult = ReturnType<typeof mapAffiliateDashBindResult> export type AffiliateDashBindResult = ReturnType<typeof mapAffiliateDashBindResult>
export type AffiliateDashWallet = ReturnType<typeof mapAffiliateDashWallet> export type AffiliateDashWallet = ReturnType<typeof mapAffiliateDashWallet>
@@ -35,7 +38,12 @@ export async function createAffiliateDashOrder(input: {
pathname: ORDERS_PATH, pathname: ORDERS_PATH,
payload, payload,
}) })
return mapAffiliateDashOrder(json.data) const data = asJsonObject(json.data)
const order = mapAffiliateDashOrder(data.order)
return {
...order,
idempotent: Boolean(data.idempotent),
}
} }
export async function getAffiliateDashOrder(orderNo: string) { export async function getAffiliateDashOrder(orderNo: string) {
@@ -137,6 +145,7 @@ export function mapAffiliateDashOrder(value: unknown) {
createdAt: asString(source.created_at), createdAt: asString(source.created_at),
deliveredAt: asString(source.delivered_at), deliveredAt: asString(source.delivered_at),
cancelledAt: asString(source.cancelled_at), cancelledAt: asString(source.cancelled_at),
raw: isJsonObject(source) ? source : {},
} }
} }
@@ -443,3 +443,28 @@ order_site 处理要求:
**真实联调验收**`listAffiliateDashProducts` 直连线上 `https://skin.khhao.com` → total=33 商品,字段映射正确,签名链路与 affiliate_dash `BuildOpenV1Sign` 一致。 **真实联调验收**`listAffiliateDashProducts` 直连线上 `https://skin.khhao.com` → total=33 商品,字段映射正确,签名链路与 affiliate_dash `BuildOpenV1Sign` 一致。
**联调中发现并修复**:签名 `path` 必须为**纯路径**(不含 query string);首次实现把 `/products?page=1&size=5` 整串参与签名导致线上 401「签名校验失败」,已改为 `URL.pathname` 参与签名(§3.1 表头 `path=<仅路径>` 属实)。 **联调中发现并修复**:签名 `path` 必须为**纯路径**(不含 query string);首次实现把 `/products?page=1&size=5` 整串参与签名导致线上 401「签名校验失败」,已改为 `URL.pathname` 参与签名(§3.1 表头 `path=<仅路径>` 属实)。
---
## 16. 阶段 2 落地记录(v2.2 · 已完成)
产出文件(`apps/backend/src/services/fulfillment/`):
| 文件 | 内容 |
| --- | --- |
| `executors/types.ts` | `FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH='affiliate_dash'` + `isAffiliateDashExecutor` 守卫 |
| `executors/affiliate-dash-executor.ts`(新) | `{ key, preparePaidTask, resolveDeliveryLink }` 仿 feifei:补 claim token → `prepareAffiliateDashTask`;失败降级 `MANUAL_REVIEW` + `notifyTaskAutoManualReview``resolveDeliveryLink` 返回本站统一 claimUrl |
| `executors/registry.ts` | `EXECUTORS` Map 注册 affiliateDashExecutor |
| `affiliate-dash/index.ts`(新) | `isAffiliateDashTask``normalizeAffiliateDashFlow`context_json `affiliateDash` 块)、`prepareAffiliateDashTask`(幂等建单、`client_order_no=task_no``data` 透传 91单号/game_account、写 `LINK_GENERATED` + task event)、`syncAffiliateDashTaskStatus`(状态合并:delivering→redeeming、delivered→核销→redeemed/manual_review、ship_failed→retry_pending、cancelled→closed)、`buildAffiliateDashClientOrderNo` |
| 测试 | `registry.test.ts` 新增 affiliate_dash 映射 + 动作暴露断言(4→4 项) |
**真实建单联调(线上 skin.khhao.com**
| 步骤 | 结果 |
| --- | --- |
| 建单 `lucky_coin_x2`20 积分) | `FO20260805145602108a92eac8d``paid``can_ship=true` ✅ |
| 同 client_order_no 幂等重试 | 同单号 + `idempotent=true`,不重复扣款 ✅ |
| GET /orders/{order_no} 查询 | `paid`,金额/状态正确 ✅ |
| GET /orders/{order_no}/delivery | `data` 透传(91单号 + game_account)正确 ✅ |
**联调中发现并修复**`POST /orders` 响应是 `data.order`(非 `data` 直接),首次实现 map 错了层级导致 orderNo 全空;已改为取 `data.order` 并暴露 `idempotent` 标志。幂等校验要求**同单号参数完全一致**,否则 400「参数与原订单不一致」——重试时必须复用原参数。