Files
order_site/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts
T
yml2213 541b458040 修复 affiliate-dash 提交成功(delivered)后结果页数秒后才显示成功
- syncAffiliateDashTaskStatus 新增 snapshotOnly:submit 拿到 delivered 后直接用本地快照收敛任务为 REDEEMED,不等平台回调(此前要等 delivered notify 落地,约 9 秒)
- 新增终态保护:已 REDEEMED/MANUAL_REVIEW/CLOSED/FAILED 的任务,非 delivered 状态不再降级(轮询时平台详情短暂返回 paid/delivering 会把成功任务打回 REDEEMING)
- submitAffiliateDashClaim:result.status=delivered 时重新读任务并 snapshotOnly 收敛
2026-08-05 20:20:10 +08:00

648 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof buildClaimDetailPayload>
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<T>(
result: Promise<T | null> | T | null,
errorMessage: string,
errorCode: string,
): Promise<NonNullable<T>> {
const value = await result
if (!value) {
throw createHttpError(errorMessage, {
statusCode: 409,
errorCode,
})
}
return value
}
function isKuaishouCloudBindingReady(flow: ReturnType<typeof normalizeKuaishouCloudFlow>) {
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<TaskRow> {
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<ReturnType<typeof getClaimContext>>,
now: string,
): Promise<ClaimDetailPayload> {
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<ClaimDetailPayload> {
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<ClaimDetailPayload> {
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<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 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_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.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<TaskRow> | 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,
}
}