import { createTaskEvent } from '../../repositories/task-event-repo.js' import { getTaskById, updateTask } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { nowIso } from '../../utils/time.js' import type { JsonObject } from '../../types/json.js' import { TASK_STATUS } from '../../domain/task-status.js' import { confirmFulfillmentRole, prepareFulfillmentBinding, rebindFulfillmentRole, redeemFulfillmentTask, } from '../fulfillment/executors/registry.js' import { isKuaishouCloudMockTask, 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, getClaimIdentityFromTask, } from './claim-identity.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import type { TaskRow } from '../../types/repository/rows.js' type ClaimDetailPayload = ReturnType function assertLewanClaimTask(task: TaskRow) { if (String(task.executor_key || '').trim() !== 'kuaishou_ct_assisted') { throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', { statusCode: 409, errorCode: 'claim_not_kuaishou_cloud', }) } } async function requireExecutorAction( result: Promise | T | null, errorMessage: string, errorCode: string, ): Promise> { const value = await result if (!value) { throw createHttpError(errorMessage, { statusCode: 409, errorCode, }) } return value } function isKuaishouCloudBindingReady(flow: ReturnType) { return flow.binding.prepareStatus === 'ready' && Boolean(String(flow.binding.bindUrl || '').trim()) } /** * 确保 Cloud 绑定资源已准备(取号 + 绑链)。 * 提前核销后仍可能未 prepare,不能因 consume.success 跳过。 */ async function ensureKuaishouCloudBindingPrepared( task: TaskRow, options: { source: string; actorSource?: string } = { source: 'system_auto_prepare_binding' }, ): Promise { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) if (isKuaishouCloudBindingReady(flow)) { return task } try { const prepared = await prepareFulfillmentBinding(task, { source: options.source, actor: { source: options.actorSource || options.source }, }) return prepared?.task || task } catch { return task } } async function verifyIndustryVoucherTicket( context: Awaited>, now: string, ): Promise { const taskContext = parseTaskContext(context.task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) // 提前核销后 consume 已是 success,但仍可能未准备绑定资源;禁止整段短路。 // 注意:不要再调 getKuaishouCloudClaimDetail,避免 prepare 失败时递归。 if (flow.consume.status === 'success') { const task = await ensureKuaishouCloudBindingPrepared(context.task, { source: 'claim_after_early_consume_prepare', actorSource: 'system', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, }) } const industryContext = typeof taskContext === 'object' ? taskContext : {} const voucherContext = normalizeIndustryVoucherContext(industryContext.kuaishouIndustryVoucher) if (voucherContext.status === 'DESTROYED') { throw createHttpError('当前电子凭证已销毁,无法继续领取', { statusCode: 409, errorCode: 'claim_kuaishou_industry_voucher_destroyed', }) } const token = String(voucherContext.token || industryContext.token || '').trim() const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim() const certExpireType = Number(industryContext.certExpireType || 0) const certActualStartTime = Number( voucherContext.validStartTime || industryContext.certActualStartTime || 0, ) const certActualEndTime = Number( voucherContext.validEndTime || industryContext.certActualEndTime || 0, ) const nextContext = { ...taskContext, kuaishouIndustryVoucher: { ...voucherContext, oid: voucherContext.oid || context.order.platform_order_id, token, eticketId: voucherCode, voucherCode, status: voucherContext.status || 'UNUSED', verifiedAt: voucherContext.verifiedAt || now, }, kuaishouCloudFulfillment: { ...flow, ticket: { ...flow.ticket, code: voucherCode, status: 'verified', capturedAt: flow.ticket.capturedAt || now, capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' }, verifiedAt: now, oid: voucherContext.oid || context.order.platform_order_id, formToken: token, leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1, goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '', }, consume: { ...flow.consume, status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending', shopId: context.order.shop_id, shopName: context.order.shop_name, autoConsumeEnabled: true, consumedAt: voucherContext.status === 'CONSUMED' ? voucherContext.consumedAt || flow.consume.consumedAt || now : flow.consume.consumedAt, }, certInfo: { certExpireType, certActualStartTime, certActualEndTime, }, }, } const taskWithTicket = (await updateTask(context.task.id, { claim_token: context.claimToken.token, claim_expires_at: context.claimToken.expired_at, context_json: JSON.stringify(nextContext), claimed_at: context.task.claimed_at || now, task_status: TASK_STATUS.WAITING_BINDING, user_action_status: 'pending_claim', last_error: '', updated_at: now, })) || { ...context.task, context_json: JSON.stringify(nextContext), claimed_at: context.task.claimed_at || now, task_status: TASK_STATUS.WAITING_BINDING, user_action_status: 'pending_claim', last_error: '', updated_at: now, } const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment) let taskAfterPrepare = taskWithTicket if (!isKuaishouCloudBindingReady(preparedFlow)) { taskAfterPrepare = await ensureKuaishouCloudBindingPrepared(taskWithTicket, { source: 'send_code_callback', actorSource: 'send_code_callback', }) } await createTaskEvent( context.task.id, 'kuaishou_cloud_ticket_verified', { ticketCodeMasked: '', source: 'send_code_callback', industryVoucher: true, verifiedAt: now, }, now, ) return buildClaimDetailPayload({ claimToken: context.claimToken, task: taskAfterPrepare, order: context.order, orderItem: context.orderItem, }) } export async function getKuaishouCloudClaimDetail(token: unknown): Promise { const context = await getClaimContext(token) let task = context.task const executorKey = String(task.executor_key || '').trim() if (executorKey === 'kuaishou_feifei') { 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 绑定资源 if (flow.consume.status !== 'success' || !isKuaishouCloudBindingReady(flow)) { const now = nowIso() const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now) .catch(() => null) if (prepared) { return prepared } } } if (executorKey === 'kuaishou_ct_assisted') { const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const needsIndustryVoucherPrepare = hasUsableIndustryVoucher(taskContext) && ( flow.ticket.status !== 'verified' || !isKuaishouCloudBindingReady(flow) ) if (needsIndustryVoucherPrepare) { const now = nowIso() const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now) .catch(() => null) if (prepared) { return prepared } } // 兜底:不依赖 UID;凭证已确认但绑定未就绪时自动补 prepare(覆盖提前核销场景) if (!isKuaishouCloudMockTask(task)) { const latestFlow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) if ( (latestFlow.ticket.status === 'verified' || hasUsableIndustryVoucher(parseTaskContext(task))) && !isKuaishouCloudBindingReady(latestFlow) ) { task = await ensureKuaishouCloudBindingPrepared(task, { source: 'claim_page_auto_binding_prepare', actorSource: 'system', }) } } } // 已提交 UID 的 lewan 任务:详情查询时顺带刷新角色,便于前端轮询匹配状态 if ( executorKey === 'kuaishou_ct_assisted' && !isKuaishouCloudMockTask(task) && getClaimIdentityFromTask(task).expectedUid ) { task = (await syncKuaishouCloudRoleInfo(task)) || task } return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, }) } /** * 统一领取 Step1:提交游戏 UID。 */ export async function submitClaimUid( token: unknown, payload: { uid?: unknown } = {}, ): Promise { const context = await getClaimContext(token) const expectedUid = assertValidClaimUid(payload.uid) const now = nowIso() if (!canUpdateClaimUid(context.task)) { throw createHttpError('当前任务状态不可修改 UID', { statusCode: 409, errorCode: 'claim_uid_locked', }) } const taskContext = parseTaskContext(context.task) const previous = getClaimIdentityFromTask(context.task) const nextContext = { ...taskContext, claimIdentity: { expectedUid, submittedAt: previous.expectedUid === expectedUid && previous.submittedAt ? previous.submittedAt : now, source: 'claim_page', }, } let updatedTask = (await updateTask(context.task.id, { context_json: JSON.stringify(nextContext), claimed_at: context.task.claimed_at || now, last_error: '', updated_at: now, })) || { ...context.task, context_json: JSON.stringify(nextContext), claimed_at: context.task.claimed_at || now, updated_at: now, } if (previous.expectedUid !== expectedUid) { await createTaskEvent( context.task.id, 'claim_uid_submitted', { expectedUid, previousUid: previous.expectedUid || '', source: 'claim_page', }, now, ) } const executorKey = String(updatedTask.executor_key || '').trim() if (executorKey === 'kuaishou_ct_assisted' && !isKuaishouCloudMockTask(updatedTask)) { try { const prepared = await prepareFulfillmentBinding(updatedTask, { source: 'claim_page_submit_uid', actor: { source: 'claim_page' }, }) if (prepared?.task) { updatedTask = prepared.task } } catch { // 绑定资源准备失败时仍保留 UID,详情页可继续重试/轮询 } } 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 { 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 isMock = Boolean(flow.mock?.enabled) const result = isMock ? { status: 'delivered', message: '开发 mock 已模拟提交发货', providerOrderNo: `MOCKAD-${flow.orderNo}`, } : 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, ) // 平台已返回 delivered:立即用本地快照收敛任务为成功,不等平台回调(避免结果页等数秒)。 // delivered 回调到达时 sync 幂等短路(已 REDEEMED 且 delivered 仍会刷新,无副作用)。 if (result.status === 'delivered') { const refreshedTask = await getTaskById(task.id) if (refreshedTask) { await syncAffiliateDashTaskStatus(refreshedTask, { snapshotOnly: true }) } } 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.mock?.enabled) { // dev-mock:不请求 affiliate-dash 平台,绑定状态以 context 为准 return task } 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) await requireExecutorAction( rebindFulfillmentRole(context.task, { source: 'claim_page_rebind_role', actor: { source: 'claim_page' }, }), '当前领取链接不支持换绑角色', 'claim_rebind_not_supported', ) return getKuaishouCloudClaimDetail(token) } export async function confirmKuaishouCloudClaimRole(token: unknown) { const context = await getClaimContext(token) assertLewanClaimTask(context.task) await requireExecutorAction( confirmFulfillmentRole(context.task, { source: 'claim_page_role_confirm', actor: { source: 'claim_page' }, errorCodePrefix: 'claim_kuaishou_cloud', forceProbe: true, }), '当前领取链接不支持确认角色', 'claim_confirm_not_supported', ) return getKuaishouCloudClaimDetail(token) } /** * 领取页一键兑换:鉴权后交给 lewan 履约引擎 redeem 用例。 */ export async function redeemKuaishouCloudClaim(token: unknown) { const context = await getClaimContext(token) assertLewanClaimTask(context.task) await requireExecutorAction( redeemFulfillmentTask(context.task, { source: 'claim_page_redeem', actor: { source: 'claim_page' }, autoFinalize: true, errorCodePrefix: 'claim_kuaishou_cloud_redeem', }), '当前领取链接不支持一键兑换', 'claim_redeem_not_supported', ) return getKuaishouCloudClaimDetail(token) } function parseTaskContext(task: Partial | null | undefined): JsonObject { return parseTaskContextValue(task) } function hasUsableIndustryVoucher(context: JsonObject = {}) { const voucher = normalizeIndustryVoucherContext(context.kuaishouIndustryVoucher) if (!voucher.voucherCode && !voucher.eticketId) { return false } return voucher.status !== 'DESTROYED' } function normalizeIndustryVoucherContext(value: unknown): JsonObject { const source = value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} const status = String(source.status || 'UNUSED').trim().toUpperCase() return { ...source, oid: String(source.oid || '').trim(), token: String(source.token || '').trim(), eticketId: String(source.eticketId || source.voucherCode || '').trim(), voucherCode: String(source.voucherCode || source.eticketId || '').trim(), status: status === 'CONSUMED' || status === 'DESTROYED' ? status : 'UNUSED', validStartTime: Number(source.validStartTime || 0) || 0, validEndTime: Number(source.validEndTime || 0) || 0, verifiedAt: source.verifiedAt || null, consumedAt: source.consumedAt || null, } }