新增 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,
}
}