import { closeTencentBrowserSession, createTencentBrowserSession, getTencentBrowserSession, getTencentBrowserSessionSummary, getTencentBrowserSessionScreenshotPath, reloadTencentBrowserSession, redeemTencentBrowserSession, } from '../session/session.js' import { classifyTencentRedeemResult, resolveTencentRedeemMessage } from '../session/session-redeem.js' import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js' import { getOrderById } from '../../repositories/order-repo.js' import { getOrderItemById } from '../../repositories/order-item-repo.js' import { createTaskEvent } from '../../repositories/task-event-repo.js' import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../repositories/task-repo.js' import { getInventoryItemById, invalidateReservedInventoryItem, markInventoryItemConsumed, markInventoryItemDelivered, releaseReservedInventoryItem, } from '../../repositories/inventory-repo.js' import { reserveInventoryForTask } from '../order/inventory-service.js' import { buildClaimUrl } from './claim-service.js' import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { nowIso } from '../../utils/time.js' const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) const REDEEM_REPLACEMENT_LIMIT = 10 const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' export async function getClaimDetail(token, { includeQrImage = true } = {}) { const context = await getClaimContext(token) let { task, session } = await loadTaskSession(context.task, { includeQrImage }) // 如果是快手 Cloud 流程,尝试同步角色信息 if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { task = await syncKuaishouCloudRoleInfo(task) } const syncedTask = session ? await syncTaskWithSession(task, session) : task return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) { const context = await getClaimContextByTaskId(taskId) const { task, session } = await loadTaskSession(context.task, { includeQrImage }) const syncedTask = session ? await syncTaskWithSession(task, session) : task return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function createClaimSession(token, payload = {}) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) const requestedLoginType = normalizeClaimLoginType(payload.loginType) const forceRecreate = Boolean(payload.forceRecreate) let task = context.task if (task.browser_session_id) { const existing = await loadTaskSession(task, { includeQrImage: true }) task = existing.task if (existing.session) { const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type) if (!forceRecreate && existingLoginType === requestedLoginType) { const syncedTask = await syncTaskWithSession(task, existing.session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session: existing.session, }) } try { await closeTencentBrowserSession(existing.session.sessionId) } catch (error) { if (!isRecoverableSessionError(error)) { throw error } } task = await clearTaskSession(task, { lastError: '', }) } } const session = await createTencentBrowserSession({ loginType: requestedLoginType, }) const updatedTask = await updateTask(task.id, { task_status: 'claimed', browser_session_id: session.sessionId, login_type: session.loginType, user_action_status: 'claimed', claimed_at: task.claimed_at || nowIso(), updated_at: nowIso(), last_error: '', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task: updatedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function createClaimSessionForAdminTask(taskId, payload = {}) { const context = await getClaimContextByTaskId(taskId) assertTaskCanProceed(context.task) const requestedLoginType = normalizeClaimLoginType(payload.loginType) const forceRecreate = Boolean(payload.forceRecreate) let task = context.task if (task.browser_session_id) { const existing = await loadTaskSession(task, { includeQrImage: true }) task = existing.task if (existing.session) { const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type) if (!forceRecreate && existingLoginType === requestedLoginType) { const syncedTask = await syncTaskWithSession(task, existing.session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session: existing.session, }) } try { await closeTencentBrowserSession(existing.session.sessionId) } catch (error) { if (!isRecoverableSessionError(error)) { throw error } } task = await clearTaskSession(task, { lastError: '', }) } } const session = await createTencentBrowserSession({ loginType: requestedLoginType, }) const updatedTask = await updateTask(task.id, { task_status: 'claimed', browser_session_id: session.sessionId, login_type: session.loginType, user_action_status: 'claimed', claimed_at: task.claimed_at || nowIso(), updated_at: nowIso(), last_error: '', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task: updatedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function getClaimSessionSummary(token) { const context = await getClaimContext(token) const { task, session } = await loadTaskSession(context.task, { includeQrImage: false, }) if (!session) { return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, session: null, }) } const syncedTask = await syncTaskWithSession(task, session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function getClaimSessionSummaryForAdminTask(taskId) { const context = await getClaimContextByTaskId(taskId) const { task, session } = await loadTaskSession(context.task, { includeQrImage: false, }) if (!session) { return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, session: null, }) } const syncedTask = await syncTaskWithSession(task, session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function reloadClaimSession(token) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) const active = await loadTaskSession(context.task, { includeQrImage: true, }) if (!active.session) { return buildClaimDetailPayload({ claimToken: context.claimToken, task: active.task, order: context.order, orderItem: context.orderItem, session: null, }) } const session = await reloadTencentBrowserSession(active.session.sessionId) const syncedTask = await syncTaskWithSession(active.task, session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function reloadClaimSessionForAdminTask(taskId) { const context = await getClaimContextByTaskId(taskId) assertTaskCanProceed(context.task) const active = await loadTaskSession(context.task, { includeQrImage: true, }) if (!active.session) { return buildClaimDetailPayload({ claimToken: context.claimToken, task: active.task, order: context.order, orderItem: context.orderItem, session: null, }) } const session = await reloadTencentBrowserSession(active.session.sessionId) const syncedTask = await syncTaskWithSession(active.task, session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function closeClaimSession(token) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) let task = context.task if (!task.browser_session_id) { return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, session: null, }) } try { await closeTencentBrowserSession(task.browser_session_id) } catch (error) { if (!isRecoverableSessionError(error)) { throw error } } task = await clearTaskSession(task, { lastError: '', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, session: null, }) } export async function closeClaimSessionForAdminTask(taskId) { const context = await getClaimContextByTaskId(taskId) assertTaskCanProceed(context.task) let task = context.task if (!task.browser_session_id) { return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, session: null, }) } try { await closeTencentBrowserSession(task.browser_session_id) } catch (error) { if (!isRecoverableSessionError(error)) { throw error } } task = await clearTaskSession(task, { lastError: '', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task, order: context.order, orderItem: context.orderItem, session: null, }) } export async function confirmClaimRole(token) { const context = await getClaimContext(token) assertPublicClaimActionAllowed(context.task, 'confirm') return finalizeClaimRoleConfirmation(context) } export async function confirmClaimRoleForAdminTask(taskId) { const context = await getClaimContextByTaskId(taskId) return finalizeClaimRoleConfirmation(context) } async function finalizeClaimRoleConfirmation(context) { if (!context.task.browser_session_id) { throw createHttpError('当前任务还没有创建浏览器会话', { statusCode: 409, errorCode: 'claim_session_not_created', }) } const session = await getTencentBrowserSession(context.task.browser_session_id) const activityInfo = session.activityInfo || null if (!activityInfo?.role?.ready) { throw createHttpError('当前角色信息还没有准备好', { statusCode: 409, errorCode: 'claim_role_not_ready', }) } const updatedTask = await updateTask(context.task.id, { task_status: 'role_confirmed', nickname: String(activityInfo.nickname || ''), role_id: String(activityInfo.role.roleId || ''), role_name: String(activityInfo.role.roleName || ''), area: String(activityInfo.role.area || ''), partition_name: String(activityInfo.role.partition || ''), user_action_status: 'role_confirmed', role_confirmed_at: nowIso(), updated_at: nowIso(), last_error: '', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task: updatedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function redeemClaimTask(token) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) assertPublicClaimActionAllowed(context.task, 'redeem') return finalizeClaimTaskRedeem(context) } export async function redeemClaimTaskForAdminTask(taskId) { const context = await getClaimContextByTaskId(taskId) assertTaskCanProceed(context.task) return finalizeClaimTaskRedeem(context) } async function finalizeClaimTaskRedeem(context) { if (!context.task.browser_session_id) { throw createHttpError('当前任务还没有创建浏览器会话', { statusCode: 409, errorCode: 'claim_session_not_created', }) } if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') { throw createHttpError('当前任务还未确认角色,不能开始兑换', { statusCode: 409, errorCode: 'claim_role_not_confirmed', }) } const inventoryItem = context.task.primary_inventory_item_id ? await getInventoryItemById(context.task.primary_inventory_item_id) : null if ( !inventoryItem || String(inventoryItem.status || '').trim() !== 'reserved' || !String(inventoryItem.display_value || '').trim() ) { throw createHttpError('当前任务没有可用的预占库存凭据', { statusCode: 409, errorCode: 'claim_inventory_not_reserved', }) } await updateTask(context.task.id, { task_status: 'redeeming', delivery_status: 'processing', updated_at: nowIso(), last_error: '', }) try { const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem) const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult const finalRedeem = session.redeem?.final?.redeem || null const finishedAt = nowIso() const updatedTask = await updateTask(context.task.id, { task_status: 'redeemed', inventory_status: 'consumed', delivery_status: 'delivered', result_code: resolveTencentRedeemResultCode(finalRedeem, classification), result_message: classification.message, screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', artifacts_json: JSON.stringify(session.artifacts || {}), context_json: JSON.stringify(mergeTaskContext(context.task, { redeemResolution: { status: 'success', attempts, replacementCount: Math.max(0, attempts.length - 1), finishedAt, }, })), redeemed_at: finishedAt, updated_at: finishedAt, last_error: '', }) await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt) await createTaskEvent(context.task.id, 'claim_redeem_completed', { inventoryItemId: deliveredInventoryItem.id, codeMasked: maskCode(deliveredInventoryItem.display_value), replacementCount: Math.max(0, attempts.length - 1), resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), resultMessage: classification.message, }, finishedAt) const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ order: context.order, task: updatedTask, trigger: 'claim_redeemed', }) return buildClaimDetailPayload({ claimToken: context.claimToken, task: autoDeliveryResult.task || updatedTask, order: context.order, orderItem: context.orderItem, session, }) } catch (error) { const now = nowIso() const nextRetryCount = Number(context.task.attempt_count || 0) + 1 const failureState = error?.redeemTaskState || { taskStatus: 'retry_pending', inventoryStatus: inventoryItem ? 'reserved' : 'pending', deliveryStatus: 'pending', lastError: error instanceof Error ? error.message : String(error || ''), attempts: [], classification: null, } const failedTask = await updateTask(context.task.id, { task_status: failureState.taskStatus, inventory_status: failureState.inventoryStatus, delivery_status: failureState.deliveryStatus, result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '', result_message: String(failureState.lastError || ''), attempt_count: nextRetryCount, last_error: String(failureState.lastError || ''), context_json: JSON.stringify(mergeTaskContext(context.task, { redeemResolution: { status: 'failed', taskStatus: failureState.taskStatus, attempts: failureState.attempts || [], replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1), finishedAt: now, }, })), updated_at: now, }) throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), { task: failedTask, }) } } export async function getClaimScreenshotPath(token) { const context = await getClaimContext(token) if (context.task.screenshot_path) { return context.task.screenshot_path } if (!context.task.browser_session_id) { throw createHttpError('当前任务还没有兑换截图', { statusCode: 404, errorCode: 'claim_screenshot_not_ready', }) } return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id) } export async function getClaimContext(token) { 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, } } async function getClaimContextByTaskId(taskId) { const task = await getTaskById(Number(taskId)) if (!task) { throw createHttpError('领取任务不存在', { statusCode: 404, errorCode: 'claim_task_not_found', }) } const claimTokenId = Number(task.primary_claim_token_id || 0) if (!claimTokenId) { throw createHttpError('当前任务还没有领取链接', { statusCode: 409, errorCode: 'claim_token_missing', }) } const claimToken = await getClaimTokenById(claimTokenId) if (!claimToken) { throw createHttpError('领取链接不存在', { statusCode: 404, errorCode: 'claim_token_not_found', }) } 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, } } function assertTaskCanProceed(task) { if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { throw createHttpError('当前任务已经结束,不能继续操作', { statusCode: 410, errorCode: 'claim_task_closed', }) } } function assertPublicClaimActionAllowed(task, action) { if (!isAssistedClaimTask(task)) { return } const message = action === 'confirm' ? '当前商品需要客服复核角色,请登录后联系人工继续' : '当前商品需要客服确认后再执行兑换,请联系人工继续' throw createHttpError(message, { statusCode: 409, errorCode: 'claim_support_review_required', }) } async function expireClaimContext(claimToken, task) { const now = nowIso() const nextClaimToken = await updateClaimToken(claimToken.id, { status: 'expired', updated_at: now, }) let nextTask = task if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { if (task.primary_inventory_item_id) { await releaseReservedInventoryItem(task.primary_inventory_item_id, now) } nextTask = await updateTask(task.id, { task_status: 'expired', inventory_status: 'pending', user_action_status: 'expired', last_error: '领取链接已过期,预占库存项已释放', updated_at: now, }) } return { claimToken: nextClaimToken, task: nextTask, } } async function loadTaskSession(task, { includeQrImage = false } = {}) { if (!task.browser_session_id) { return { task, session: null, } } try { const session = includeQrImage ? await getTencentBrowserSession(task.browser_session_id) : await getTencentBrowserSessionSummary(task.browser_session_id) return { task, session, } } catch (error) { if (!isRecoverableSessionError(error)) { throw error } const nextTask = await clearTaskSession(task, { lastError: '浏览器会话已失效,请重新初始化登录', }) return { task: nextTask, session: null, } } } async function syncTaskWithSession(task, session) { const activityInfo = session.activityInfo || null const patch = { login_type: String(session.loginType || task.login_type || ''), updated_at: nowIso(), } if (activityInfo?.nickname) { patch.nickname = String(activityInfo.nickname) } if (activityInfo?.role?.ready) { patch.role_id = String(activityInfo.role.roleId || '') patch.role_name = String(activityInfo.role.roleName || '') patch.area = String(activityInfo.role.area || '') patch.partition_name = String(activityInfo.role.partition || '') } if (task.task_status === 'link_generated') { patch.task_status = 'claimed' patch.user_action_status = 'claimed' patch.claimed_at = task.claimed_at || nowIso() } if (session.review?.capturedAt) { patch.state_json = JSON.stringify({ ...parseTaskState(task), reviewScreenshotReady: true, reviewCapturedAt: String(session.review.capturedAt || ''), reviewRoleId: String(session.review.roleId || ''), reviewRoleName: String(session.review.roleName || ''), }) } if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) { patch.screenshot_path = task.screenshot_path || '' } return updateTask(task.id, patch) } function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) { const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot) const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : '' const finalRedeem = session?.redeem?.final?.redeem || null const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim', task: { taskId: task.id, taskNo: task.task_no, status: task.task_status, executorKey: task.executor_key || '', requiresSupportReview: isAssistedClaimTask(task), 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, browserSessionId: task.browser_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: orderItem.sku_name, quantity: orderItem.quantity, }, session, kuaishouCloudFulfillment, result: task.redeemed_at || session?.status === 'redeemed' ? { resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''), screenshotReady, screenshotUrl, } : null, } } function mapClaimKuaishouCloudFulfillment(task, order) { 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 = source.binding && typeof source.binding === 'object' ? source.binding : {} const role = source.role && typeof source.role === 'object' ? source.role : {} const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {} const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} return { flowType: 'kuaishou_cloud', shopId: String(order?.shop_id || '').trim(), shopName: String(order?.shop_name || '').trim(), guideImages: [ `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/1.png`, `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/2.png`, `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/3.png`, ], 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', cloudSourceKey: String(binding.cloudSourceKey || '').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, vnPhone: String(binding.vnPhone || '').trim(), roleName: String(binding.roleName || '').trim(), roleId: String(binding.roleId || '').trim(), }, role: { status: String(role.status || 'pending').trim() || 'pending', name: String(role.name || binding.roleName || '').trim(), rid: String(role.rid || binding.roleId || '').trim(), refreshedAt: role.refreshedAt || null, errorMessage: String(role.errorMessage || '').trim(), }, 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, 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(), }, } } async function redeemClaimTaskWithInventoryFallback(context, initialInventoryItem) { return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem) } export async function redeemClaimTaskWithInventoryFallbackWithDeps( context, initialInventoryItem, { reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession, redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession, classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult, markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed, createTaskEvent: createRedeemTaskEvent = createTaskEvent, invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem, reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask, nowIso: getNowIso = nowIso, redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT, } = {}, ) { const attempts = [] let currentInventoryItem = initialInventoryItem let shouldReloadBeforeNextAttempt = false for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) { if (shouldReloadBeforeNextAttempt) { await reloadRedeemSession(context.task.browser_session_id) shouldReloadBeforeNextAttempt = false } const session = await redeemSession(context.task.browser_session_id, { code: currentInventoryItem.display_value, }) const finalRedeem = session.redeem?.final?.redeem || null const classification = classifyRedeemResult(finalRedeem) const attemptSummary = { attempt: attemptIndex, inventoryItemId: currentInventoryItem.id, codeMasked: maskCode(currentInventoryItem.display_value), credentialType: String(currentInventoryItem.credential_type || ''), outcome: classification.outcome, resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), resultMessage: classification.message, } attempts.push(attemptSummary) if (classification.success) { return { session, inventoryItem: currentInventoryItem, classification, attempts, } } if (classification.outcome === 'code_used') { const updatedAt = getNowIso() await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt) await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', { inventoryItemId: currentInventoryItem.id, codeMasked: attemptSummary.codeMasked, resultCode: attemptSummary.resultCode, resultMessage: classification.message, }, updatedAt) } else if (classification.outcome === 'code_invalid') { const updatedAt = getNowIso() await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt) await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', { inventoryItemId: currentInventoryItem.id, codeMasked: attemptSummary.codeMasked, resultCode: attemptSummary.resultCode, resultMessage: classification.message, }, updatedAt) } else { throw createRedeemTaskStateError(classification.message, { errorCode: 'claim_redeem_failed', taskStatus: 'retry_pending', inventoryStatus: 'reserved', deliveryStatus: 'pending', attempts, classification, }) } if (attemptIndex >= redeemReplacementLimit) { throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', { errorCode: 'claim_redeem_replacement_limit_reached', taskStatus: 'retry_pending', inventoryStatus: 'pending', deliveryStatus: 'pending', attempts, classification, }) } const replacement = await reserveReplacementInventory({ skuCode: context.orderItem.sku_code, taskId: context.task.id, credentialType: currentInventoryItem.credential_type || 'tencent_code', roleKey: 'primary_code', inventoryGroupCodes: currentInventoryItem.inventory_group_code ? [String(currentInventoryItem.inventory_group_code).trim()] : null, }) if (!replacement || !String(replacement.display_value || '').trim()) { const exhaustedMessage = classification.outcome === 'code_used' ? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试' : '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试' throw createRedeemTaskStateError(exhaustedMessage, { errorCode: 'claim_inventory_replacement_exhausted', taskStatus: 'waiting_inventory', inventoryStatus: 'pending', deliveryStatus: 'pending', attempts, classification, }) } const replacedAt = getNowIso() await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', { previousInventoryItemId: currentInventoryItem.id, previousCodeMasked: attemptSummary.codeMasked, previousOutcome: classification.outcome, nextInventoryItemId: replacement.id, nextCodeMasked: maskCode(replacement.display_value), credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(), }, replacedAt) currentInventoryItem = replacement shouldReloadBeforeNextAttempt = true } throw createRedeemTaskStateError('兑换失败,请稍后重试', { errorCode: 'claim_redeem_failed', taskStatus: 'retry_pending', inventoryStatus: 'pending', deliveryStatus: 'pending', attempts, classification: null, }) } function createRedeemTaskStateError(message, payload = {}) { const error = createHttpError(message, { statusCode: payload.statusCode || 409, errorCode: payload.errorCode || 'claim_redeem_failed', }) error.redeemTaskState = { taskStatus: payload.taskStatus || 'retry_pending', inventoryStatus: payload.inventoryStatus || 'pending', deliveryStatus: payload.deliveryStatus || 'pending', lastError: String(message || ''), attempts: payload.attempts || [], classification: payload.classification || null, } return error } function resolveTencentRedeemResultCode(finalRedeem, classification) { if (classification?.retCode != null) { return String(classification.retCode) } const value = finalRedeem?.iRet ?? finalRedeem?.ret return value == null ? '' : String(value) } function mergeTaskContext(task, patch = {}) { return { ...parseTaskContext(task), ...patch, } } function parseTaskState(task) { const value = task?.state_json if (!value) { return {} } if (typeof value === 'object') { return value } try { return JSON.parse(String(value || '{}')) } catch { return {} } } function isAssistedClaimTask(task) { return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' } function parseTaskContext(task) { const value = task?.context_json if (!value) { return {} } if (typeof value === 'object') { return value } try { return JSON.parse(String(value || '{}')) } catch { return {} } } async function clearTaskSession(task, { lastError = '' } = {}) { const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || '')) const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status const patch = { task_status: nextTaskStatus, user_action_status: nextUserActionStatus, browser_session_id: '', login_type: '', nickname: '', role_id: '', role_name: '', area: '', partition_name: '', artifacts_json: '{}', state_json: '{}', role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at, last_error: String(lastError || ''), updated_at: nowIso(), } return updateTask(task.id, patch) } function normalizeClaimLoginType(loginType) { return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq' } function isRecoverableSessionError(error) { const errorCode = String(error?.errorCode || error?.code || '').trim() return errorCode === 'session_not_found' || errorCode === 'session_closed' } function maskCode(value) { const text = String(value || '').trim() if (!text) { return '' } if (text.length <= 8) { return `${text.slice(0, 2)}****${text.slice(-2)}` } return `${text.slice(0, 4)}****${text.slice(-4)}` }