新增 affiliate_dash 统一领取页流程(阶段 4)

- 后端:claim 详情分流 affiliate_dash 分支 + 详情轮询刷新 bind-result
- 输 UID 触发 bind(拿 bindUuid/二维码, task→waiting_binding)
- 新增 POST /claim/:token/affiliate-dash/submit(submit→redeeming + 事件)
- 前端:ClaimAffiliateDashSteps(二维码/绑定状态/mismatch警告/提交发货)
- claim-snapshot/claim-poll 按 flowType 分流(step2 绑定中/step3 已绑定待提交/step4 结果)
- 前后端 typecheck + 后端 212 测试通过
This commit is contained in:
yml2213
2026-08-05 15:56:51 +08:00
parent 3284fed7a1
commit 5ef1ed6018
11 changed files with 614 additions and 9 deletions
+11
View File
@@ -6,6 +6,7 @@ import {
getKuaishouCloudClaimDetail,
rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim,
submitAffiliateDashClaim,
submitClaimUid,
} from '../services/claim/kuaishou-cloud-claim-service.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
@@ -43,6 +44,16 @@ router.post(
}),
)
router.post(
'/:token/affiliate-dash/submit',
claimWriteRateLimit,
createRouteHandler((req) => submitAffiliateDashClaim(req.params.token, req.body || {}), {
successMessage: '发货已提交',
errorMessage: '提交发货失败',
scope: '[claims/:token/affiliate-dash/submit]',
}),
)
router.post(
'/:token/kuaishou-cloud/confirm-role',
claimWriteRateLimit,
@@ -10,6 +10,7 @@ import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
import { buildClaimUrl } from './claim-service.js'
import { buildClaimIdentityPayload, getClaimIdentityFromContext } from './claim-identity.js'
import { resolveKuaishouFeifeiH5UrlWithUid } from '../fulfillment/kuaishou-feifei/index.js'
import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
@@ -91,6 +92,10 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem })
}
if (String(task.executor_key || '').trim() === 'affiliate_dash') {
return buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderItem })
}
const taskContext = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(taskContext.claimIdentity)
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
@@ -152,6 +157,76 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
}
}
function buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const flow = normalizeAffiliateDashFlow(context.affiliateDash)
const productTitle = String(
flow.productName || orderItem.sku_name || orderItem.sku_code || '',
).trim()
const product = {
title: productTitle,
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [{
cloudSkuId: 0,
name: productTitle,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
}],
}
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: 'affiliate_dash',
claimIdentity,
task: {
taskId: task.id,
taskNo: task.task_no,
status: task.task_status,
executorKey: task.executor_key || '',
requiresSupportReview: false,
expiresAt: claimToken.expired_at,
claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at,
redeemedAt: task.redeemed_at,
loginType: task.login_type,
lastError: task.last_error,
runtimeSessionId: task.runtime_session_id,
},
order: {
orderId: order.id,
platform: order.platform,
platformOrderId: order.platform_order_id,
payStatus: order.pay_status,
orderStatus: order.order_status,
totalAmount: formatFenToAmount(order.total_amount),
totalAmountFen: normalizeFen(order.total_amount),
currency: order.currency,
},
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: productTitle,
quantity: orderItem.quantity,
},
product,
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: null as null,
affiliateDash: flow,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
}
function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
@@ -16,6 +16,15 @@ import {
normalizeKuaishouCloudFlow,
} from '../fulfillment/kuaishou-cloud/index.js'
import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js'
import {
normalizeAffiliateDashFlow,
syncAffiliateDashTaskStatus,
} from '../fulfillment/affiliate-dash/index.js'
import {
bindAffiliateDashDelivery,
getAffiliateDashBindResult,
submitAffiliateDashDelivery,
} from '../platforms/affiliate-dash/order-service.js'
import {
assertValidClaimUid,
canUpdateClaimUid,
@@ -221,6 +230,11 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
task = (await syncKuaishouFeifeiTaskStatus(task)) || task
}
if (executorKey === 'affiliate_dash') {
task = (await syncAffiliateDashTaskStatus(task)) || task
task = (await refreshAffiliateDashBindState(task)) || task
}
if (executorKey === 'kuaishou-industry') {
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
// 已核销也要走 verify:内部会补 prepare 绑定资源
@@ -357,9 +371,173 @@ export async function submitClaimUid(
}
}
if (executorKey === 'affiliate_dash') {
updatedTask = (await bindAffiliateDashClaimForTask(updatedTask, expectedUid)) || updatedTask
}
return getKuaishouCloudClaimDetail(token)
}
/**
* affiliate_dash 领取 Step2:提交发货(绑定完成后)。
*/
export async function submitAffiliateDashClaim(
token: unknown,
payload: { gameAccount?: unknown; bindUuid?: unknown } = {},
): Promise<ClaimDetailPayload> {
const context = await getClaimContext(token)
const task = context.task
const executorKey = String(task.executor_key || '').trim()
if (executorKey !== 'affiliate_dash') {
throw createHttpError('当前领取链接不是 affiliate-dash 领取流程', {
statusCode: 409,
errorCode: 'claim_not_affiliate_dash',
})
}
const taskContext = parseTaskContextValue(task)
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
if (!flow.orderNo) {
throw createHttpError('affiliate-dash 订单尚未创建', {
statusCode: 409,
errorCode: 'affiliate_dash_order_missing',
})
}
const gameAccount = String(payload.gameAccount || flow.gameAccount || '').trim()
const bindUuid = String(payload.bindUuid || flow.bindUuid || '').trim()
if (!gameAccount || !bindUuid) {
throw createHttpError('缺少绑定账号或 bindUuid', {
statusCode: 400,
errorCode: 'affiliate_dash_submit_missing',
})
}
const now = nowIso()
const result = await submitAffiliateDashDelivery({
orderNo: flow.orderNo,
gameAccount,
bindUuid,
})
const nextFlow = {
...flow,
gameAccount,
bindUuid,
submitStatus: result.status,
orderStatus: result.status || flow.orderStatus,
}
await updateTask(task.id, {
task_status: TASK_STATUS.REDEEMING,
context_json: JSON.stringify({
...taskContext,
affiliateDash: nextFlow,
}),
result_code: 'affiliate_dash_submitted',
result_message: result.message || 'affiliate-dash 已提交发货',
updated_at: now,
})
await createTaskEvent(
task.id,
'affiliate_dash_submitted',
{
orderNo: flow.orderNo,
submitStatus: result.status,
providerOrderNo: result.providerOrderNo,
},
now,
)
return getKuaishouCloudClaimDetail(token)
}
/**
* 提交 UID 后触发 affiliate-dash 绑定(bind),把 bind_uuid / 二维码写回上下文,
* task → waiting_bindinglink_generated → waiting_binding 合法)。
*/
async function bindAffiliateDashClaimForTask(task: TaskRow, gameAccount: string) {
const taskContext = parseTaskContextValue(task)
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
if (!flow.orderNo || !gameAccount) {
return task
}
const now = nowIso()
const bindResult = await bindAffiliateDashDelivery({
orderNo: flow.orderNo,
gameAccount,
})
const nextFlow = {
...flow,
bindUuid: bindResult.bindUuid,
bindUrl: bindResult.bindUrl,
qrUrl: bindResult.qrUrl,
gameAccount,
}
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.WAITING_BINDING,
context_json: JSON.stringify({
...taskContext,
affiliateDash: nextFlow,
}),
updated_at: now,
})
await createTaskEvent(
task.id,
'affiliate_dash_bind_created',
{
orderNo: flow.orderNo,
bindUuid: bindResult.bindUuid,
gameAccount,
},
now,
)
return updatedTask || task
}
/**
* 轮询绑定状态:详情查询时若有 bind_uuid,拉取 affiliate-dash bind-result 实时刷新
* bound / 绑定账号 / mismatch 到上下文(可重入,拉取失败不阻塞详情)。
*/
async function refreshAffiliateDashBindState(task: TaskRow): Promise<TaskRow | null> {
const taskContext = parseTaskContextValue(task)
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
if (!flow.orderNo || !flow.bindUuid) {
return task
}
try {
const result = await getAffiliateDashBindResult({
orderNo: flow.orderNo,
bindUuid: flow.bindUuid,
})
const nextFlow = {
...flow,
bound: result.bound,
boundAccount: result.gameAccount || flow.boundAccount,
gameChannel: result.gameChannel || flow.gameChannel,
bindMismatch: result.mismatch,
}
const now = nowIso()
return (
(await updateTask(task.id, {
context_json: JSON.stringify({
...taskContext,
affiliateDash: nextFlow,
}),
updated_at: now,
})) || task
)
} catch {
// 绑定状态拉取失败:保持现状,前端可继续轮询
return task
}
}
export async function rebindKuaishouCloudClaimRole(token: unknown) {
const context = await getClaimContext(token)
assertLewanClaimTask(context.task)
@@ -240,6 +240,10 @@ export type AffiliateDashFlow = {
gameAccount: string
expectedGameAccount: string
bindMismatch: boolean
/** 是否已在 affiliate-dash 侧完成绑定(bind-result.bound */
bound: boolean
boundAccount: string
gameChannel: string
submitStatus: string
consumeStatus: string
lastSyncedAt: unknown
@@ -270,6 +274,9 @@ export function normalizeAffiliateDashFlow(value: unknown): AffiliateDashFlow {
gameAccount: String(source.gameAccount || '').trim(),
expectedGameAccount: String(source.expectedGameAccount || '').trim(),
bindMismatch: Boolean(source.bindMismatch),
bound: Boolean(source.bound),
boundAccount: String(source.boundAccount || '').trim(),
gameChannel: String(source.gameChannel || '').trim(),
submitStatus: String(source.submitStatus || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(),
lastSyncedAt: source.lastSyncedAt || null,