import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js' import { getOrderItemById } from '../../repositories/order-item-repo.js' import { getOrderById } from '../../repositories/order-repo.js' import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' 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 type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' import { asJsonObject, type JsonObject } from '../../types/json.js' export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED]) type ClaimContext = { claimToken: ClaimTokenRow task: TaskRow order: OrderRow orderItem: OrderItemRow } export async function getClaimContext(token: unknown) { const normalized = String(token || '').trim() if (!normalized) { throw createHttpError('缺少领取 token', { statusCode: 400, errorCode: 'missing_claim_token', }) } const claimToken = await findClaimTokenByToken(normalized) if (!claimToken) { throw createHttpError('领取链接无效或不存在', { statusCode: 404, errorCode: 'claim_token_not_found', }) } const task = await findTaskByClaimTokenId(claimToken.id) if (!task) { throw createHttpError('领取任务不存在', { statusCode: 404, errorCode: 'claim_task_not_found', }) } if (claimToken.status !== 'active') { throw createHttpError('领取链接当前不可用', { statusCode: 410, errorCode: 'claim_token_inactive', }) } if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { const expiredContext = await expireClaimContext(claimToken, task) throw createHttpError('领取链接已过期', { statusCode: 410, errorCode: 'claim_token_expired', context: expiredContext, }) } const [order, orderItem] = await Promise.all([ getOrderById(task.order_id), getOrderItemById(task.order_item_id), ]) if (!order || !orderItem) { throw createHttpError('领取任务关联订单不完整', { statusCode: 500, errorCode: 'claim_order_incomplete', }) } return { claimToken, task, order, orderItem, } } export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { if (String(task.executor_key || '').trim() === 'kuaishou_feifei') { return buildKuaishouFeifeiClaimDetailPayload({ 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, } } function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { 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), }], } return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), flowType: 'kuaishou_feifei', 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: product.title, quantity: orderItem.quantity, }, product, 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, }, 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), }] return { title: displaySkuName, skuCode: String(orderItem.sku_code || '').trim(), quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), isBundle: normalizedDeliveryItems.length > 1, items: normalizedDeliveryItems, } } function mergeClaimProductItems( items: Array<{ cloudSkuId: number; name: string; quantity: number }>, ) { const merged = new Map() 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 }) } return Array.from(merged.values()) } function resolveClaimKuaishouCloudSource(task: TaskRow): JsonObject { if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { return {} } 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') { 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(), }, } } 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() 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, { status: TASK_STATUS.EXPIRED, updated_at: now, }) let nextTask = task if (!isTaskFinalStatus(task.task_status)) { nextTask = await updateTask(task.id, { task_status: TASK_STATUS.EXPIRED, user_action_status: TASK_STATUS.EXPIRED, last_error: '领取链接已过期', updated_at: now, }) || task } return { claimToken: nextClaimToken, task: nextTask, } }