新增 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:
@@ -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_generated;delivering → redeeming;
|
||||||
|
* delivered → 核销行业电子凭证 → redeemed / manual_review;
|
||||||
|
* ship_failed → retry_pending;cancelled → 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 领取页使用:读取 flow(context_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「参数与原订单不一致」——重试时必须复用原参数。
|
||||||
|
|||||||
Reference in New Issue
Block a user