diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index 1db64294..aa0764e7 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -1,1255 +1,28 @@ -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' - -/** - * @typedef {{ - * taskStatus: string - * inventoryStatus: string - * deliveryStatus: string - * lastError: string - * attempts: unknown[] - * classification: { retCode?: unknown } | null - * }} RedeemTaskState - */ - -/** - * @typedef {{ - * statusCode?: number - * errorCode?: string - * taskStatus?: string - * inventoryStatus?: string - * deliveryStatus?: string - * attempts?: unknown[] - * classification?: { retCode?: unknown } | null - * }} RedeemTaskStatePayload - */ - -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 && typeof error === 'object' - ? /** @type {{ redeemTaskState?: RedeemTaskState }} */ (error).redeemTaskState - : null - ) || { - 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, - }) -} - -/** - * @param {string} message - * @param {RedeemTaskStatePayload} [payload] - */ -function createRedeemTaskStateError(message, payload = {}) { - const error = /** @type {Error & import('../../utils/http.js').HttpErrorLike & { redeemTaskState: RedeemTaskState }} */ (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)}` -} +export { + getClaimDetail, + getClaimDetailForAdminTask, + getClaimSessionSummary, + getClaimSessionSummaryForAdminTask, + confirmClaimRole, + confirmClaimRoleForAdminTask, + redeemClaimTask, + redeemClaimTaskForAdminTask, + getClaimScreenshotPath, +} from './session/actions.js' + +export { + createClaimSession, + createClaimSessionForAdminTask, + reloadClaimSession, + reloadClaimSessionForAdminTask, + closeClaimSession, + closeClaimSessionForAdminTask, +} from './session/lifecycle.js' + +export { + getClaimContext, +} from './session/context.js' + +export { + redeemClaimTaskWithInventoryFallbackWithDeps, +} from './session/redeem.js' diff --git a/apps/backend/src/services/claim/session/actions.js b/apps/backend/src/services/claim/session/actions.js new file mode 100644 index 00000000..86fc848b --- /dev/null +++ b/apps/backend/src/services/claim/session/actions.js @@ -0,0 +1,304 @@ +// @ts-check + +import { + getTencentBrowserSession, + getTencentBrowserSessionScreenshotPath, +} from '../../session/session.js' +import { createTaskEvent } from '../../../repositories/task-event-repo.js' +import { updateTask } from '../../../repositories/task-repo.js' +import { + getInventoryItemById, + markInventoryItemDelivered, +} from '../../../repositories/inventory-repo.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 { nowIso } from '../../../utils/time.js' +import { getClaimContext, getClaimContextByTaskId } from './context.js' +import { loadTaskSession, syncTaskWithSession } from './runtime.js' +import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js' +import { + assertPublicClaimActionAllowed, + assertTaskCanProceed, + buildClaimDetailPayload, + maskCode, + mergeTaskContext, +} from './shared.js' + +export async function getClaimDetail(token, { includeQrImage = true } = {}) { + const context = await getClaimContext(token) + let { task, session } = await loadTaskSession(context.task, { includeQrImage }) + + 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 getClaimSessionSummary(token) { + return getClaimSessionSummaryByContextLoader(() => getClaimContext(token)) +} + +export async function getClaimSessionSummaryForAdminTask(taskId) { + return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId)) +} + +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) +} + +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) +} + +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) +} + +async function getClaimSessionSummaryByContextLoader(loadContext) { + const context = await loadContext() + 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, + }) +} + +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, + }) +} + +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 && typeof error === 'object' + ? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState + : null + ) || { + 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, + }) + } +} diff --git a/apps/backend/src/services/claim/session/context.js b/apps/backend/src/services/claim/session/context.js new file mode 100644 index 00000000..61cd3ac8 --- /dev/null +++ b/apps/backend/src/services/claim/session/context.js @@ -0,0 +1,152 @@ +// @ts-check + +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 { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js' +import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js' +import { createHttpError } from '../../../utils/http.js' +import { nowIso } from '../../../utils/time.js' +import { CLAIM_TERMINAL_STATUSES } from './shared.js' + +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, + } +} + +export 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, + } +} + +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, + } +} diff --git a/apps/backend/src/services/claim/session/lifecycle.js b/apps/backend/src/services/claim/session/lifecycle.js new file mode 100644 index 00000000..43056663 --- /dev/null +++ b/apps/backend/src/services/claim/session/lifecycle.js @@ -0,0 +1,175 @@ +// @ts-check + +import { + closeTencentBrowserSession, + createTencentBrowserSession, + reloadTencentBrowserSession, +} from '../../session/session.js' +import { updateTask } from '../../../repositories/task-repo.js' +import { nowIso } from '../../../utils/time.js' +import { getClaimContext, getClaimContextByTaskId } from './context.js' +import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js' +import { + assertTaskCanProceed, + buildClaimDetailPayload, + isRecoverableSessionError, + normalizeClaimLoginType, +} from './shared.js' + +export async function createClaimSession(token, payload = {}) { + return createClaimSessionWithContextLoader( + () => getClaimContext(token), + payload, + ) +} + +export async function createClaimSessionForAdminTask(taskId, payload = {}) { + return createClaimSessionWithContextLoader( + () => getClaimContextByTaskId(taskId), + payload, + ) +} + +export async function reloadClaimSession(token) { + return reloadClaimSessionWithContextLoader(() => getClaimContext(token)) +} + +export async function reloadClaimSessionForAdminTask(taskId) { + return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) +} + +export async function closeClaimSession(token) { + return closeClaimSessionWithContextLoader(() => getClaimContext(token)) +} + +export async function closeClaimSessionForAdminTask(taskId) { + return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) +} + +async function createClaimSessionWithContextLoader(loadContext, payload = {}) { + const context = await loadContext() + 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, + }) +} + +async function reloadClaimSessionWithContextLoader(loadContext) { + const context = await loadContext() + 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, + }) +} + +async function closeClaimSessionWithContextLoader(loadContext) { + const context = await loadContext() + 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, + }) +} diff --git a/apps/backend/src/services/claim/session/redeem.js b/apps/backend/src/services/claim/session/redeem.js new file mode 100644 index 00000000..96130b72 --- /dev/null +++ b/apps/backend/src/services/claim/session/redeem.js @@ -0,0 +1,188 @@ +// @ts-check + +import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js' +import { classifyTencentRedeemResult } from '../../session/session-redeem.js' +import { createTaskEvent } from '../../../repositories/task-event-repo.js' +import { + invalidateReservedInventoryItem, + markInventoryItemConsumed, +} from '../../../repositories/inventory-repo.js' +import { reserveInventoryForTask } from '../../order/inventory-service.js' +import { createHttpError } from '../../../utils/http.js' +import { nowIso } from '../../../utils/time.js' +import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js' + +export 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, + }) +} + +/** + * @param {string} message + * @param {{ statusCode?: number, errorCode?: string, taskStatus?: string, inventoryStatus?: string, deliveryStatus?: string, attempts?: unknown[], classification?: { retCode?: unknown } | null }} [payload] + */ +function createRedeemTaskStateError(message, payload = {}) { + const error = /** @type {Error & import('../../../utils/http.js').HttpErrorLike & { redeemTaskState: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (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 +} + +export function resolveTencentRedeemResultCode(finalRedeem, classification) { + if (classification?.retCode != null) { + return String(classification.retCode) + } + + const value = finalRedeem?.iRet ?? finalRedeem?.ret + return value == null ? '' : String(value) +} diff --git a/apps/backend/src/services/claim/session/runtime.js b/apps/backend/src/services/claim/session/runtime.js new file mode 100644 index 00000000..0dd2dfbc --- /dev/null +++ b/apps/backend/src/services/claim/session/runtime.js @@ -0,0 +1,107 @@ +// @ts-check + +import { + getTencentBrowserSession, + getTencentBrowserSessionSummary, +} from '../../session/session.js' +import { updateTask } from '../../../repositories/task-repo.js' +import { nowIso } from '../../../utils/time.js' +import { isRecoverableSessionError, parseTaskState } from './shared.js' + +export 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, + } + } +} + +export 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) +} + +export 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) +} diff --git a/apps/backend/src/services/claim/session/shared.js b/apps/backend/src/services/claim/session/shared.js new file mode 100644 index 00000000..57c9591f --- /dev/null +++ b/apps/backend/src/services/claim/session/shared.js @@ -0,0 +1,239 @@ +// @ts-check + +import { buildClaimUrl } from '../claim-service.js' +import { createHttpError } from '../../../utils/http.js' +import { formatFenToAmount, normalizeFen } from '../../../utils/money.js' + +export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) +export const REDEEM_REPLACEMENT_LIMIT = 10 +export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' + +export 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, + } +} + +export 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(), + }, + } +} + +export function assertTaskCanProceed(task) { + if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { + throw createHttpError('当前任务已经结束,不能继续操作', { + statusCode: 410, + errorCode: 'claim_task_closed', + }) + } +} + +export function assertPublicClaimActionAllowed(task, action) { + if (!isAssistedClaimTask(task)) { + return + } + + const message = action === 'confirm' + ? '当前商品需要客服复核角色,请登录后联系人工继续' + : '当前商品需要客服确认后再执行兑换,请联系人工继续' + + throw createHttpError(message, { + statusCode: 409, + errorCode: 'claim_support_review_required', + }) +} + +export function mergeTaskContext(task, patch = {}) { + return { + ...parseTaskContext(task), + ...patch, + } +} + +export 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 {} + } +} + +export function isAssistedClaimTask(task) { + return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' +} + +export 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 {} + } +} + +export function normalizeClaimLoginType(loginType) { + return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq' +} + +export function isRecoverableSessionError(error) { + const errorCode = String(error?.errorCode || error?.code || '').trim() + return errorCode === 'session_not_found' || errorCode === 'session_closed' +} + +export 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)}` +}