新增 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
|
||||
}
|
||||
|
||||
test('getFulfillmentExecutor 映射 lewan / industry / feifei / manual', () => {
|
||||
test('getFulfillmentExecutor 映射 lewan / industry / feifei / affiliate_dash / manual', () => {
|
||||
assert.equal(
|
||||
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD)?.key,
|
||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||
@@ -34,6 +34,10 @@ test('getFulfillmentExecutor 映射 lewan / industry / feifei / manual', () => {
|
||||
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI)?.key,
|
||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
||||
)
|
||||
assert.equal(
|
||||
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH)?.key,
|
||||
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||
)
|
||||
assert.equal(
|
||||
getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH)?.key,
|
||||
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
@@ -41,6 +45,16 @@ test('getFulfillmentExecutor 映射 lewan / industry / feifei / manual', () => {
|
||||
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 暴露完整履约动作', () => {
|
||||
const executor = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD)
|
||||
assert.ok(executor)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isPaidPreparationStableStatus } from '../../../domain/task-status.js'
|
||||
import { kuaishouCloudExecutor } from './kuaishou-cloud-executor.js'
|
||||
import { kuaishouFeifeiExecutor } from './kuaishou-feifei-executor.js'
|
||||
import { manualDispatchExecutor } from './manual-executor.js'
|
||||
import { affiliateDashExecutor } from './affiliate-dash-executor.js'
|
||||
import {
|
||||
FULFILLMENT_EXECUTOR_KEYS,
|
||||
isManualDispatchExecutor,
|
||||
@@ -17,6 +18,7 @@ const EXECUTORS = new Map<string, FulfillmentExecutor>([
|
||||
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, kuaishouCloudExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY, kuaishouCloudExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, kuaishouFeifeiExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, affiliateDashExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor],
|
||||
])
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const FULFILLMENT_EXECUTOR_KEYS = {
|
||||
KUAISHOU_CLOUD: 'kuaishou_ct_assisted',
|
||||
KUAISHOU_INDUSTRY: 'kuaishou-industry',
|
||||
KUAISHOU_FEIFEI: 'kuaishou_feifei',
|
||||
AFFILIATE_DASH: 'affiliate_dash',
|
||||
} as const
|
||||
|
||||
export type FulfillmentExecutorKey =
|
||||
@@ -111,6 +112,10 @@ export function isKuaishouFeifeiExecutor(value: unknown): boolean {
|
||||
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 {
|
||||
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'
|
||||
|
||||
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 AffiliateDashBindResult = ReturnType<typeof mapAffiliateDashBindResult>
|
||||
export type AffiliateDashWallet = ReturnType<typeof mapAffiliateDashWallet>
|
||||
@@ -35,7 +38,12 @@ export async function createAffiliateDashOrder(input: {
|
||||
pathname: ORDERS_PATH,
|
||||
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) {
|
||||
@@ -137,6 +145,7 @@ export function mapAffiliateDashOrder(value: unknown) {
|
||||
createdAt: asString(source.created_at),
|
||||
deliveredAt: asString(source.delivered_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` 一致。
|
||||
|
||||
**联调中发现并修复**:签名 `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