refactor(fulfillment): 移除飞飞/乐玩通道,仅保留 affiliate_dash

新单只路由 affiliate_dash(未命中走 manual_dispatch);历史飞飞/乐玩任务保留只读 stub,避免订单重同步失败。同步清理 cloudtentacles 调度、Admin 写路径与相关 env。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-14 17:29:47 +08:00
co-authored by Cursor
parent 0cbcb89dd8
commit b3aad0044c
155 changed files with 661 additions and 23351 deletions
@@ -3,7 +3,6 @@ import test from 'node:test'
import {
appendUidToUrl,
assertLewanAutoFulfillmentUidReady,
assertValidClaimUid,
buildClaimIdentityAdminSummary,
isClaimUidMatched,
@@ -56,39 +55,6 @@ test('appendUidToUrl overwrites existing uid', () => {
assert.equal(new URL(next).searchParams.get('uid'), 'new-uid')
})
test('assertLewanAutoFulfillmentUidReady rejects legacy task without uid', () => {
assert.throws(
() =>
assertLewanAutoFulfillmentUidReady({
context_json: JSON.stringify({ kuaishouCloudFulfillment: {} }),
}),
/尚未填写游戏 UID/,
)
})
test('assertLewanAutoFulfillmentUidReady allows mock without uid', () => {
assert.equal(
assertLewanAutoFulfillmentUidReady({
context_json: JSON.stringify({
kuaishouCloudFulfillment: { mock: { enabled: true } },
}),
}),
'',
)
})
test('assertLewanAutoFulfillmentUidReady accepts submitted uid', () => {
assert.equal(
assertLewanAutoFulfillmentUidReady({
context_json: JSON.stringify({
claimIdentity: { expectedUid: '10001', submittedAt: '2026-07-10T00:00:00.000Z' },
kuaishouCloudFulfillment: {},
}),
}),
'10001',
)
})
test('buildClaimIdentityAdminSummary reports match state', () => {
const summary = buildClaimIdentityAdminSummary(
{
@@ -108,38 +108,6 @@ export function assertClaimExpectedUidReady(task: Partial<TaskRow> | null | unde
return expectedUid
}
/**
* lewan 自动发货前:必须已有 expectedUid(不再回落「仅确认角色」旧路径)。
* mock 任务可跳过。
*/
export function assertLewanAutoFulfillmentUidReady(
task: Partial<TaskRow> | null | undefined,
options: { allowMockSkip?: boolean; errorCodePrefix?: string } = {},
): string {
const context = parseTaskContext(task)
const flow =
context.kuaishouCloudFulfillment && typeof context.kuaishouCloudFulfillment === 'object'
? (context.kuaishouCloudFulfillment as JsonObject)
: {}
const mock = flow.mock && typeof flow.mock === 'object' ? (flow.mock as JsonObject) : null
if (options.allowMockSkip !== false && mock?.enabled === true) {
return getClaimIdentityFromTask(task).expectedUid
}
const expectedUid = getClaimIdentityFromTask(task).expectedUid
if (!expectedUid) {
const prefix = String(options.errorCodePrefix || 'kuaishou_cloud').trim() || 'kuaishou_cloud'
throw createHttpError(
'该 lewan 任务尚未填写游戏 UID,请用户打开领取页提交 UID 后再发货(旧单不再支持无 UID 自动发货)',
{
statusCode: 409,
errorCode: `${prefix}_expected_uid_required`,
},
)
}
return expectedUid
}
export function buildClaimIdentityAdminSummary(
context: unknown,
options: { flowLike?: unknown; taskRoleId?: unknown; taskRoleName?: unknown } = {},
@@ -8,13 +8,18 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json
import { nowIso } from '../../utils/time.js'
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 { buildClaimIdentityPayload } from './claim-identity.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'
import type { JsonObject } from '../../types/json.js'
export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED])
const RETIRED_CHANNEL_MESSAGE: Record<string, string> = {
kuaishou_feifei: '飞飞履约渠道已下线,本链接仅可查看历史信息,无法继续领取',
kuaishou_ct_assisted: '乐玩履约渠道已下线,本链接仅可查看历史信息,无法继续领取',
}
type ClaimContext = {
claimToken: ClaimTokenRow
task: TaskRow
@@ -88,73 +93,17 @@ export async function getClaimContext(token: unknown) {
}
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
if (String(task.executor_key || '').trim() === 'kuaishou_feifei') {
return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem })
}
const executorKey = String(task.executor_key || '').trim()
if (String(task.executor_key || '').trim() === 'affiliate_dash') {
if (executorKey === 'affiliate_dash') {
return buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderItem })
}
const taskContext = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(taskContext.claimIdentity)
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
const displaySkuName = resolveClaimOrderItemDisplaySkuName(
orderItem,
kuaishouCloudFulfillment,
kuaishouCloudSource,
)
const product = buildClaimProductPayload(displaySkuName, orderItem, kuaishouCloudSource)
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: 'kuaishou_cloud',
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: displaySkuName,
quantity: orderItem.quantity,
},
product,
session: null as null,
kuaishouCloudFulfillment,
kuaishouFeifei: null as null,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
if (executorKey === 'kuaishou_feifei' || executorKey === 'kuaishou_ct_assisted') {
return buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem })
}
return buildGenericClaimDetailPayload({ claimToken, task, order, orderItem })
}
function buildAffiliateDashClaimDetailPayload({
@@ -186,32 +135,12 @@ function buildAffiliateDashClaimDetailPayload({
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: 'affiliate_dash',
flowType: 'affiliate_dash' as const,
channelOffline: false,
channelOfflineMessage: '',
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,
},
task: buildClaimTaskPayload(task, claimToken),
order: buildClaimOrderPayload(order),
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
@@ -223,313 +152,140 @@ function buildAffiliateDashClaimDetailPayload({
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,
result: buildClaimResultPayload(task),
}
}
function buildKuaishouFeifeiClaimDetailPayload({
claimToken,
task,
order,
orderItem,
}: ClaimContext) {
function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const executorKey = String(task.executor_key || '').trim()
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const expectedUid = getClaimIdentityFromContext(context).expectedUid
const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei, expectedUid)
const product = {
title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [
{
cloudSkuId: 0,
name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
],
}
const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim()
const offlineMessage =
RETIRED_CHANNEL_MESSAGE[executorKey] || '该履约渠道已下线,本链接仅可查看历史信息'
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: 'kuaishou_feifei',
flowType: executorKey === 'kuaishou_feifei' ? ('kuaishou_feifei' as const) : ('kuaishou_cloud' as const),
channelOffline: true,
channelOfflineMessage: offlineMessage,
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,
},
task: buildClaimTaskPayload(task, claimToken),
order: buildClaimOrderPayload(order),
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: product.title,
skuName: productTitle,
quantity: orderItem.quantity,
},
product,
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),
},
],
},
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: flow,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
}
function mapClaimKuaishouFeifeiFulfillment(value: unknown, expectedUid = '') {
const source = isPlainObject(value) ? value : {}
const h5 = isPlainObject(source.h5) ? source.h5 : {}
const entryUrl = String(h5.entryUrl || '').trim()
const rechargeUrl = String(h5.rechargeUrl || '').trim()
const h5UrlWithUid = resolveKuaishouFeifeiH5UrlWithUid(source, expectedUid)
return {
flowType: 'kuaishou_feifei',
productCode: String(source.productCode || '').trim(),
productName: String(source.productName || '').trim(),
platformOrderNo: String(source.platformOrderNo || '').trim(),
orderNo: String(source.orderNo || '').trim(),
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
claimUrl: String(source.claimUrl || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(),
h5: {
entryUrl,
rechargeUrl,
kuaishouFeifei: null as null,
affiliateDash: null as null,
result: buildClaimResultPayload(task) || {
resultCode: 'channel_offline',
resultMessage: offlineMessage,
screenshotReady: false,
screenshotUrl: '',
},
h5UrlWithUid: expectedUid ? h5UrlWithUid : '',
lastSyncedAt: source.lastSyncedAt || null,
}
}
function resolveClaimOrderItemDisplaySkuName(
orderItem: OrderItemRow,
kuaishouCloudFulfillment: JsonObject | null,
kuaishouCloudSource: JsonObject = {},
) {
const fulfillment = isPlainObject(kuaishouCloudFulfillment) ? kuaishouCloudFulfillment : {}
const binding = isPlainObject(fulfillment.binding) ? fulfillment.binding : {}
const ticket = isPlainObject(fulfillment.ticket) ? fulfillment.ticket : {}
const skuCode = String(orderItem.sku_code || '').trim()
const rawSkuName = String(orderItem.sku_name || '').trim()
const candidates = [
kuaishouCloudSource.internalSkuName,
rawSkuName,
ticket.goodsTitle,
binding.skuName,
skuCode,
]
for (const value of candidates) {
const normalized = String(value || '').trim()
if (normalized && !isOrderItemIdentifierName(normalized, skuCode)) {
return normalized
}
}
return rawSkuName || skuCode
}
function buildClaimProductPayload(
displaySkuName: string,
orderItem: OrderItemRow,
kuaishouCloudSource: JsonObject = {},
) {
const binding = isPlainObject(kuaishouCloudSource.binding) ? kuaishouCloudSource.binding : {}
const deliveryItems = normalizeClaimDeliveryItems(kuaishouCloudSource.deliveryItems, binding)
.map((item) => ({
cloudSkuId: item.cloudSkuId,
name: item.cloudSkuName,
quantity: item.quantity,
}))
.filter((item) => item.name)
const normalizedDeliveryItems =
deliveryItems.length > 0
? mergeClaimProductItems(deliveryItems)
: [
{
cloudSkuId: 0,
name: displaySkuName,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
]
function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim()
return {
title: displaySkuName,
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: normalizedDeliveryItems.length > 1,
items: normalizedDeliveryItems,
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch',
channelOffline: false,
channelOfflineMessage: '',
claimIdentity,
task: buildClaimTaskPayload(task, claimToken),
order: buildClaimOrderPayload(order),
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: productTitle,
quantity: orderItem.quantity,
},
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),
},
],
},
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: null as null,
affiliateDash: null as null,
result: buildClaimResultPayload(task),
}
}
function mergeClaimProductItems(
items: Array<{ cloudSkuId: number; name: string; quantity: number }>,
) {
const merged = new Map<string, { cloudSkuId: number; name: string; quantity: number }>()
for (const item of items) {
const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${item.name}`
const existing = merged.get(key)
if (existing) {
existing.quantity += item.quantity
existing.name = existing.name || item.name
continue
}
merged.set(key, { ...item })
function buildClaimTaskPayload(task: TaskRow, claimToken: ClaimTokenRow) {
return {
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,
}
return Array.from(merged.values())
}
function resolveClaimKuaishouCloudSource(task: TaskRow): JsonObject {
if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
return {}
function buildClaimOrderPayload(order: OrderRow) {
return {
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,
}
const source = parseTaskContext(task).kuaishouCloudFulfillment
return isPlainObject(source) ? source : {}
}
export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) {
if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
function buildClaimResultPayload(task: TaskRow) {
if (!task.redeemed_at) {
return null
}
const source = parseTaskContext(task).kuaishouCloudFulfillment
if (!source || typeof source !== 'object') {
return null
}
const binding = asJsonObject(source.binding)
const role = asJsonObject(source.role)
const purchase = asJsonObject(source.purchase)
const ticket = asJsonObject(source.ticket)
const dispatch = asJsonObject(source.dispatch)
const returnNumber = asJsonObject(source.returnNumber)
const consume = asJsonObject(source.consume)
const roleName = String(role.name || binding.roleName || '').trim()
const roleId = String(role.rid || binding.roleId || '').trim()
const defaultRoleName = String(role.defaultName || role.defaultRoleName || '').trim()
const defaultRoleId = String(role.defaultRid || role.defaultRoleId || '').trim()
const isDefaultRole =
Boolean(roleName || roleId) &&
isSameClaimKuaishouCloudRole(
{ name: roleName, rid: roleId },
{ name: defaultRoleName, rid: defaultRoleId },
)
return {
flowType: 'kuaishou_cloud',
shopId: String(order?.shop_id || '').trim(),
shopName: String(order?.shop_name || '').trim(),
guideImages: [],
ticket: {
code: String(ticket.code || '').trim(),
status: String(ticket.status || 'pending').trim() || 'pending',
capturedAt: ticket.capturedAt || null,
verifiedAt: ticket.verifiedAt || null,
goodsTitle: String(ticket.goodsTitle || '').trim(),
leftCount: Number(ticket.leftCount || 0) || 0,
},
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
cloudSourceKeys: Array.isArray(binding.cloudSourceKeys)
? binding.cloudSourceKeys
.map((value: unknown) => String(value || '').trim())
.filter(Boolean)
: [],
resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(),
skuId: Number(binding.skuId || 0) || 0,
skuName: String(binding.skuName || '').trim(),
vnKey: String(binding.vnKey || '').trim(),
vnId: Number(binding.vnId || 0) || 0,
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
bindExpiresAt: binding.bindExpiresAt || null,
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
vnPhone: String(binding.vnPhone || '').trim(),
roleName: String(binding.roleName || '').trim(),
roleId: String(binding.roleId || '').trim(),
},
role: {
status: String(role.status || 'pending').trim() || 'pending',
name: roleName,
rid: roleId,
refreshedAt: role.refreshedAt || null,
errorMessage: String(role.errorMessage || '').trim(),
defaultName: defaultRoleName,
defaultRid: defaultRoleId,
defaultCapturedAt: role.defaultCapturedAt || null,
defaultCaptureStatus: String(role.defaultCaptureStatus || '').trim(),
defaultErrorMessage: String(role.defaultErrorMessage || '').trim(),
isDefaultRole,
},
purchase: {
autoBuyEnabled: purchase.autoBuyEnabled !== false,
minAssetReserve: Number(purchase.minAssetReserve || 0) || 0,
usedKnapsack: purchase.usedKnapsack === true,
purchaseTriggered: purchase.purchaseTriggered === true,
assetBefore: Number(purchase.assetBefore || 0) || 0,
assetAfter: Number(purchase.assetAfter || 0) || 0,
purchaseAt: purchase.purchaseAt || null,
},
dispatch: {
status: String(dispatch.status || 'pending').trim() || 'pending',
dispatchAt: dispatch.dispatchAt || null,
failedAt: dispatch.failedAt || null,
failedStage: String(dispatch.failedStage || '').trim(),
errorCode: String(dispatch.errorCode || '').trim(),
errorMessage: String(dispatch.errorMessage || '').trim(),
note: String(dispatch.note || '').trim(),
},
returnNumber: {
status: String(returnNumber.status || 'pending').trim() || 'pending',
returnedAt: returnNumber.returnedAt || null,
},
consume: {
status: String(consume.status || 'pending').trim() || 'pending',
shopId: String(consume.shopId || '').trim(),
shopName: String(consume.shopName || '').trim(),
consumedAt: consume.consumedAt || null,
errorMessage: String(consume.errorMessage || '').trim(),
},
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
}
@@ -537,115 +293,6 @@ function parseTaskContext(task: TaskRow): JsonObject {
return parseTaskContextValue(task)
}
function isOrderItemIdentifierName(value: string, skuCode: string) {
const normalized = String(value || '').trim()
if (!normalized) {
return true
}
if (isClaimPlaceholderSkuName(normalized)) {
return true
}
const normalizedSkuCode = String(skuCode || '').trim()
if (normalizedSkuCode && normalized === normalizedSkuCode && /^\d{8,}$/.test(normalizedSkuCode)) {
return true
}
return /^\d{8,}$/.test(normalized)
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
function isSameClaimKuaishouCloudRole(
left: { name?: unknown; rid?: unknown },
right: { name?: unknown; rid?: unknown },
) {
const leftRid = String(left.rid || '').trim()
const rightRid = String(right.rid || '').trim()
if (leftRid && rightRid) {
return leftRid === rightRid
}
const leftName = normalizeClaimRoleNameForCompare(left.name)
const rightName = normalizeClaimRoleNameForCompare(right.name)
return Boolean(leftName && rightName && leftName === rightName)
}
function normalizeClaimRoleNameForCompare(value: unknown) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
}
function normalizeClaimDeliveryItems(value: unknown, binding: JsonObject) {
const rawItems = Array.isArray(value) ? value : []
const items = rawItems
.map((item) => normalizeClaimDeliveryItem(item))
.filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } =>
Boolean(item),
)
if (items.length > 0) {
return mergeClaimDeliveryItems(items)
}
const cloudSkuId = Number(binding.skuId || 0) || 0
if (!cloudSkuId) {
return []
}
return [
{
cloudSkuId,
cloudSkuName: String(binding.skuName || '').trim(),
quantity: 1,
},
]
}
function normalizeClaimDeliveryItem(value: unknown) {
const source = isPlainObject(value) ? value : {}
const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0
if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) {
return null
}
const quantity = Number(source.quantity || 1) || 1
return {
cloudSkuId,
cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(),
quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
}
}
function mergeClaimDeliveryItems(
items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>,
) {
const merged = new Map<number, { cloudSkuId: number; cloudSkuName: string; quantity: number }>()
for (const item of items) {
const existing = merged.get(item.cloudSkuId)
if (existing) {
existing.quantity += item.quantity
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
continue
}
merged.set(item.cloudSkuId, { ...item })
}
return Array.from(merged.values())
}
function isClaimPlaceholderSkuName(value: string) {
return ['测试链接专用', '商品领取', '测试商品'].includes(String(value || '').trim())
}
async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) {
const now = nowIso()
const nextClaimToken = await updateClaimToken(claimToken.id, {
@@ -5,17 +5,6 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json
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,
@@ -31,79 +20,23 @@ import {
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',
})
}
const RETIRED_EXECUTOR_KEYS = new Set(['kuaishou_feifei', 'kuaishou_ct_assisted'])
function isRetiredExecutor(executorKey: unknown) {
return RETIRED_EXECUTOR_KEYS.has(String(executorKey || '').trim())
}
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
}
export async function getKuaishouCloudClaimDetail(token: unknown): Promise<ClaimDetailPayload> {
const context = await getClaimContext(token)
let task = context.task
const executorKey = String(task.executor_key || '').trim()
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',
})
// 已下线渠道:只读详情,不主动同步上游
if (isRetiredExecutor(executorKey)) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
@@ -112,127 +45,6 @@ async function verifyIndustryVoucherTicket(
})
}
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
if (shouldRefreshAffiliateDashBindState(task)) {
@@ -240,64 +52,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
}
}
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,
@@ -308,12 +62,22 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
/**
* 统一领取 Step1:提交游戏 UID。
* 已下线渠道仅允许查看,禁止再改 UID。
*/
export async function submitClaimUid(
token: unknown,
payload: { uid?: unknown } = {},
): Promise<ClaimDetailPayload> {
const context = await getClaimContext(token)
const executorKey = String(context.task.executor_key || '').trim()
if (isRetiredExecutor(executorKey)) {
throw createHttpError('该履约渠道已下线,无法继续提交 UID', {
statusCode: 409,
errorCode: 'claim_channel_offline',
})
}
const expectedUid = assertValidClaimUid(payload.uid)
const now = nowIso()
@@ -326,7 +90,6 @@ export async function submitClaimUid(
const taskContext = parseTaskContext(context.task)
const previous = getClaimIdentityFromTask(context.task)
const executorKey = String(context.task.executor_key || '').trim()
if (executorKey === 'affiliate_dash') {
return submitAffiliateDashClaimUid(context, expectedUid, previous, taskContext, now, token)
}
@@ -336,17 +99,12 @@ export async function submitClaimUid(
claimIdentity: buildClaimIdentityContext(previous, expectedUid, now),
}
let updatedTask = (await updateTask(context.task.id, {
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,
}
})
await createClaimUidSubmittedEventIfChanged(
context.task.id,
@@ -355,20 +113,6 @@ export async function submitClaimUid(
now,
)
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,详情页可继续重试/轮询
}
}
return getKuaishouCloudClaimDetail(token)
}
@@ -586,8 +330,6 @@ export async function submitAffiliateDashClaim(
const latestFlow = flow
if (!isMock && flow.bindUuid) {
// 提交前不再额外请求 bind-result:平台侧曾出现 GET 后 submit 的 bind_verify 异常。
// 这里仅使用详情轮询写入的本地快照做强拦截,真正提交仍交给平台二次校验。
if (isAffiliateDashBindSnapshotStale(flow, expectedGameAccount)) {
throw createHttpError('绑定二维码与当前 UID 不一致,请更新 UID 后重新绑定', {
statusCode: 409,
@@ -664,8 +406,6 @@ export async function submitAffiliateDashClaim(
now,
)
// 平台已返回 delivered:立即用本地快照收敛任务为成功,不等平台回调(避免结果页等数秒)。
// delivered 回调到达时 sync 幂等短路(已 REDEEMED 且 delivered 仍会刷新,无副作用)。
if (result.status === 'delivered') {
const refreshedTask = await getTaskById(task.id)
if (refreshedTask) {
@@ -789,7 +529,6 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise<TaskRow | n
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) {
@@ -817,8 +556,6 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise<TaskRow | n
bindUuid: flow.bindUuid,
})
const boundAccount = result.bound ? result.gameAccount || flow.boundAccount : ''
// 本地强制校验:绑定返回的游戏账号与第一步填写的 UID 不一致时置 mismatch
// (不依赖平台 mismatch 字段,防止平台未标记但实际账号不一致的情况)。
const localMismatch = Boolean(
boundAccount &&
expectedGameAccount &&
@@ -848,7 +585,6 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise<TaskRow | n
})) || task
)
} catch {
// 绑定状态拉取失败:保持现状,前端可继续轮询
return task
}
}
@@ -859,91 +595,6 @@ function normalizeUid(value: unknown) {
.replace(/\s+/g, '')
}
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,
}
}
@@ -1,58 +0,0 @@
import {
prepareFulfillmentBinding,
refreshFulfillmentRole,
} from '../fulfillment/executors/registry.js'
import {
isKuaishouCloudBindingMutationFrozen,
normalizeKuaishouCloudFlow,
} from '../fulfillment/kuaishou-cloud/index.js'
import type { TaskRow } from '../../types/repository/rows.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
export async function syncKuaishouCloudRoleInfo(task: TaskRow) {
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
// 已发货/兑换中:禁止 claim 轮询换号或覆盖角色(脏单 status 可能已回退 waiting_binding)
if (isKuaishouCloudBindingMutationFrozen(task, flow)) {
return task
}
if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.vnPhone) {
// ticket verified 或绑定尚未 ready:补 prepare(覆盖提前核销后未取号场景)
if (
flow.ticket.status === 'verified' ||
flow.binding.prepareStatus !== 'ready' ||
!flow.binding.bindUrl
) {
try {
const prepared = await prepareFulfillmentBinding(task, {
source: 'claim_page_retry_binding_prepare',
actor: { source: 'system' },
})
return prepared?.task || task
} catch {
return task
}
}
return task
}
try {
// 只走 refresh:内部会按间隔 probe 绑链,必要时再查 bind_info
// 不再 forceProbe + 前置 probe,避免同轮重复打上游
const result = await refreshFulfillmentRole(task, {
source: 'claim_page_polling',
actor: { source: 'system' },
recordEvent: false,
forceProbe: false,
})
return result?.task || task
} catch {
return task
}
}
function parseTaskContext(task: Partial<TaskRow> | null | undefined) {
return parseTaskContextValue(task)
}