diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index 58827034..feaea394 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -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, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 43323cee..ef668d31 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -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) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index a5fb267b..ad79bd77 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -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 { + 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_binding(link_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 { + 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) diff --git a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts index ab0465c1..26de4122 100644 --- a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts +++ b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts @@ -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, diff --git a/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx b/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx new file mode 100644 index 00000000..6e993a79 --- /dev/null +++ b/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx @@ -0,0 +1,161 @@ +import { CheckCircleOutlined, LinkOutlined } from '@ant-design/icons' +import { Alert, Button, Card, Typography } from 'antd' +import type { + ClaimAffiliateDashFlowInfo, + ClaimOrderInfo, + ClaimProductInfo, +} from '@/types/claim' +import type { ClaimSnapshot } from './claim-snapshot' +import { InfoTile, UidEditRow } from './claim-shared' + +export function AffiliateDashClaimPanel({ + affiliateDash, + expectedUid, + order, + product, + taskLastError, + qrCodeDataUrl, + submittingUid, + submittingSubmit, + uidInput, + onUidChange, + onResubmitUid, + onSubmit, + onOpenBindUrl, +}: { + affiliateDash: ClaimAffiliateDashFlowInfo + expectedUid: string + order: ClaimOrderInfo | null + product: ClaimProductInfo | null + taskLastError?: string + qrCodeDataUrl: string + submittingUid: boolean + submittingSubmit: boolean + uidInput: string + onUidChange: (value: string) => void + onResubmitUid: () => void + onSubmit: () => void + onOpenBindUrl: () => void +}) { + const bindReady = Boolean(affiliateDash.bindUrl || affiliateDash.qrUrl) + const bound = Boolean(affiliateDash.bound) + const bindMismatch = Boolean(affiliateDash.bindMismatch) + + return ( + +
+ affiliate-dash + + {bound ? '第 3 步:确认并提交发货' : '第 2 步:绑定领取账号'} + +

{bound ? '账号绑定成功,确认无误后提交发货' : '打开绑定链接完成绑定,系统将自动确认'}

+
+ + + +
+ + + + + +
+ + {taskLastError ? ( + + ) : null} + + {bindMismatch ? ( + + ) : null} + + {bound ? ( + } + message={`已绑定账号:${affiliateDash.boundAccount || affiliateDash.gameAccount || '-'}`} + description={affiliateDash.gameChannel ? `渠道:${affiliateDash.gameChannel}` : undefined} + style={{ marginBottom: 16 }} + /> + ) : null} + + {!bound ? ( +
+ {qrCodeDataUrl ? ( + 绑定二维码 + ) : null} + {bindReady ? ( + + ) : ( + 绑定链接生成中,请稍候自动刷新… + )} +
+ ) : ( + + )} +
+ ) +} + +export function AffiliateDashResultStep({ + snapshot, +}: { + snapshot: ClaimSnapshot +}) { + const affiliateDash = snapshot.affiliateDash + + return ( + +
{snapshot.resultTitle}
+
{snapshot.resultDescription}
+ + {affiliateDash ? ( +
+ + + + + +
+ ) : null} +
+ ) +} diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index f5f56469..d1fcce1a 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -10,10 +10,12 @@ import { fetchClaimDetail, rebindKuaishouCloudClaimRole, redeemKuaishouCloudClaim, + submitAffiliateDashClaim, submitClaimUid, } from '@/services/claim' import type { ClaimDetailData } from '@/types/claim' +import { AffiliateDashClaimPanel, AffiliateDashResultStep } from './ClaimAffiliateDashSteps' import { FeifeiClaimPanel, FeifeiResultStep } from './ClaimFeifeiSteps' import { ClaimHeaderCard } from './ClaimHeaderCard' import { ClaimResultStep, KuaishouCloudClaimSteps } from './ClaimLewanSteps' @@ -32,6 +34,7 @@ export default function ClaimPage() { const [confirmingRole, setConfirmingRole] = useState(false) const [rebindingRole, setRebindingRole] = useState(false) const [redeeming, setRedeeming] = useState(false) + const [submittingSubmit, setSubmittingSubmit] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [detail, setDetail] = useState(null) const [qrCodeDataUrl, setQrCodeDataUrl] = useState('') @@ -81,7 +84,11 @@ export default function ClaimPage() { setUidInput(expectedUid) } await generateQRCode( - String(nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || '').trim(), + String( + nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || + nextDetail.affiliateDash?.bindUrl || + '', + ).trim(), ) }, [generateQRCode], @@ -172,6 +179,28 @@ export default function ClaimPage() { } } + async function handleSubmitAffiliateDash() { + const affiliateDash = snapshot.affiliateDash + const uid = uidInput.trim() || affiliateDash?.gameAccount || '' + const bindUuid = affiliateDash?.bindUuid || '' + + if (!uid || !bindUuid) { + showError('绑定尚未完成,请先绑定账号') + return + } + + setSubmittingSubmit(true) + try { + const response = await submitAffiliateDashClaim(token, { gameAccount: uid, bindUuid }) + await applyDetail(response.data) + showSuccess('发货已提交') + } catch (error) { + showError(error instanceof Error ? error.message : '提交发货失败') + } finally { + setSubmittingSubmit(false) + } + } + async function refreshRole() { setRefreshingRole(true) try { @@ -263,7 +292,9 @@ export default function ClaimPage() { } function openBindUrl(useCurrentPage = false) { - const bindUrl = String(snapshot.flow?.binding.bindUrl || '').trim() + const bindUrl = String( + snapshot.flow?.binding.bindUrl || snapshot.affiliateDash?.bindUrl || '', + ).trim() if (!bindUrl) { showError('绑定链接还没准备好,请稍后刷新') return @@ -337,9 +368,27 @@ export default function ClaimPage() { ) : snapshot.hasRedeemResult ? ( snapshot.isFeifeiFlow ? ( + ) : snapshot.isAffiliateDashFlow ? ( + ) : ( ) + ) : snapshot.isAffiliateDashFlow && snapshot.affiliateDash ? ( + void handleSubmitUid()} + onSubmit={() => void handleSubmitAffiliateDash()} + onOpenBindUrl={openBindUrl} + /> ) : snapshot.isFeifeiFlow && snapshot.feifei ? ( (`/api/v1/claim/${token}/uid`, { uid }) } +export function submitAffiliateDashClaim( + token: string, + payload: { gameAccount: string; bindUuid: string }, +) { + return apiPost(`/api/v1/claim/${token}/affiliate-dash/submit`, payload) +} + export function confirmKuaishouCloudClaimRole(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) } diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 65d517a5..2c8ec02e 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -162,10 +162,37 @@ export interface ClaimKuaishouFeifeiFlowInfo { lastSyncedAt: string | null } +export interface ClaimAffiliateDashFlowInfo { + 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 + bindUuid: string + bindUrl: string + qrUrl: string + gameAccount: string + expectedGameAccount: string + bindMismatch: boolean + bound: boolean + boundAccount: string + gameChannel: string + submitStatus: string + consumeStatus: string + lastSyncedAt: string | null +} + export interface ClaimDetailData { tokenStatus: ClaimTokenStatus claimUrl: string - flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {}) + flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | 'affiliate_dash' | (string & {}) claimIdentity: ClaimIdentityInfo | null task: ClaimTaskInfo order: ClaimOrderInfo @@ -174,5 +201,6 @@ export interface ClaimDetailData { session: unknown | null kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null kuaishouFeifei: ClaimKuaishouFeifeiFlowInfo | null + affiliateDash: ClaimAffiliateDashFlowInfo | null result: ClaimResultInfo | null } diff --git a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md index 71adf1cd..9fe7d3f5 100644 --- a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md +++ b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md @@ -493,3 +493,33 @@ order_site 处理要求: - 未命中映射的商品 → 不选 affiliate_dash,回退 ✅ **验证**:前后端 typecheck 通过;后端全量 212 测试通过。 + +--- + +## 18. 阶段 4 落地记录(v2.4 · 已完成) + +产出文件: + +**后端(`apps/backend/src/`)**: + +| 文件 | 内容 | +| --- | --- | +| `services/claim/kuaishou-cloud-claim-context.ts` | `buildClaimDetailPayload` 加 `affiliate_dash` 分支 + `buildAffiliateDashClaimDetailPayload`(flowType/affiliateDash 块/product/task/order/orderItem/result) | +| `services/claim/kuaishou-cloud-claim-service.ts` | `getKuaishouCloudClaimDetail` affiliate_dash 分支:`syncAffiliateDashTaskStatus` + `refreshAffiliateDashBindState`(有 bindUuid 时拉 bind-result 实时刷新 bound/账号/mismatch,可重入);`submitClaimUid` affiliate_dash 分支 → `bindAffiliateDashClaimForTask`(bind 拿 bindUuid/二维码,task → WAITING_BINDING);新增 `submitAffiliateDashClaim`(submit → REDEEMING + task event) | +| `services/fulfillment/affiliate-dash/index.ts` | `AffiliateDashFlow` 加 `bound/boundAccount/gameChannel` 字段(bind-result 状态) | +| `routes/claims.ts` | 新增 `POST /:token/affiliate-dash/submit`(claimWriteRateLimit) | + +**前端(`apps/frontend/src/`)**: + +| 文件 | 内容 | +| --- | --- | +| `types/claim.ts` | `ClaimAffiliateDashFlowInfo` + `ClaimDetailData.affiliateDash` | +| `services/claim.ts` | `submitAffiliateDashClaim(token, {gameAccount, bindUuid})` | +| `pages/claim/claim-snapshot.ts` | `isAffiliateDashFlow`、hasRedeemResult/currentStep/progressText/resultTitle/resultDescription 的 affiliate_dash 分支(step: 无UID=1/绑定中=2/已绑定待提交=3/结果=4) | +| `pages/claim/claim-poll.ts` | affiliate_dash 轮询(未绑定用 BINDING_PREPARE_POLL_MS,已绑定用 FEIFEI_POLL_MS) | +| `pages/claim/ClaimAffiliateDashSteps.tsx`(新) | `AffiliateDashClaimPanel`(二维码/绑定链接、bound 状态、mismatch 警告、提交发货按钮)+ `AffiliateDashResultStep` | +| `pages/claim/ClaimPage.tsx` | 分发分支 + `handleSubmitAffiliateDash` + `openBindUrl`/二维码生成支持 affiliateDash.bindUrl | + +**验证**:前后端 typecheck 通过;后端全量 212 测试通过;claim 详情分流脚本验证(flowType=affiliate_dash、affiliateDash 块完整、product 正确、其他 flow 为 null)。 + +**领取闭环状态迁移(与 §8 状态机一致)**:输 UID → submitClaimUid(bind 触发,task `link_generated → waiting_binding`)→ 轮询详情(bind-result 刷新 bound)→ 提交发货(submitAffiliateDashClaim,task `waiting_binding → redeeming`)→ 回调 delivered → redeemed + 核销。