diff --git a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts new file mode 100644 index 00000000..565a2ff0 --- /dev/null +++ b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts @@ -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 | 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): 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, + } +} diff --git a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts new file mode 100644 index 00000000..7459a1ed --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts @@ -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 { + 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 { + 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, + } +} diff --git a/apps/backend/src/services/fulfillment/executors/registry.test.ts b/apps/backend/src/services/fulfillment/executors/registry.test.ts index dcace942..705a9ffe 100644 --- a/apps/backend/src/services/fulfillment/executors/registry.test.ts +++ b/apps/backend/src/services/fulfillment/executors/registry.test.ts @@ -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) diff --git a/apps/backend/src/services/fulfillment/executors/registry.ts b/apps/backend/src/services/fulfillment/executors/registry.ts index 3b00ab3f..1cdb3ce6 100644 --- a/apps/backend/src/services/fulfillment/executors/registry.ts +++ b/apps/backend/src/services/fulfillment/executors/registry.ts @@ -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([ [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], ]) diff --git a/apps/backend/src/services/fulfillment/executors/types.ts b/apps/backend/src/services/fulfillment/executors/types.ts index a64e4a88..d37e0982 100644 --- a/apps/backend/src/services/fulfillment/executors/types.ts +++ b/apps/backend/src/services/fulfillment/executors/types.ts @@ -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 } diff --git a/apps/backend/src/services/platforms/affiliate-dash/order-service.ts b/apps/backend/src/services/platforms/affiliate-dash/order-service.ts index 60040ff3..707a7280 100644 --- a/apps/backend/src/services/platforms/affiliate-dash/order-service.ts +++ b/apps/backend/src/services/platforms/affiliate-dash/order-service.ts @@ -2,7 +2,10 @@ import type { JsonObject } from '../../../types/json.js' import { affiliateDashRequest } from './http-client.js' -export type AffiliateDashOrder = ReturnType +export type AffiliateDashOrder = ReturnType & { + /** 命中幂等(200 + idempotent=true)时为 true,仅创建订单响应存在 */ + idempotent?: boolean +} export type AffiliateDashDeliveryInfo = ReturnType export type AffiliateDashBindResult = ReturnType export type AffiliateDashWallet = ReturnType @@ -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 : {}, } } diff --git a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md index 560c0a6e..b9d352f8 100644 --- a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md +++ b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md @@ -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「参数与原订单不一致」——重试时必须复用原参数。