import { createTencentBrowserSession, getTencentBrowserSession, getTencentBrowserSessionScreenshotPath, redeemTencentBrowserSession, } from './session.js' import { findClaimTokenByToken, updateClaimToken } from '../repositories/claim-token-repo.js' import { getOrderById } from '../repositories/order-repo.js' import { getOrderItemById } from '../repositories/order-item-repo.js' import { findTaskByClaimTokenId, updateTask } from '../repositories/task-repo.js' import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../repositories/cdk-repo.js' import { buildClaimUrl } from './claim-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']) export async function getClaimDetail(token, { includeQrImage = true } = {}) { const context = await getClaimContext(token) const session = await loadTaskSession(context.task, { includeQrImage }) const syncedTask = session ? syncTaskWithSession(context.task, session) : context.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) if (context.task.browser_session_id) { const existingSession = await getTencentBrowserSession(context.task.browser_session_id) const syncedTask = syncTaskWithSession(context.task, existingSession) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session: existingSession, }) } const session = await createTencentBrowserSession({ loginType: payload.loginType, }) const updatedTask = updateTask(context.task.id, { task_status: 'claimed', browser_session_id: session.sessionId, login_type: session.loginType, claimed_at: context.task.claimed_at || nowIso(), updated_at: nowIso(), }) return buildClaimDetailPayload({ claimToken: context.claimToken, task: updatedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function getClaimSessionSummary(token) { const context = await getClaimContext(token) 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 syncedTask = syncTaskWithSession(context.task, session) return buildClaimDetailPayload({ claimToken: context.claimToken, task: syncedTask, order: context.order, orderItem: context.orderItem, session, }) } export async function confirmClaimRole(token) { const context = await getClaimContext(token) 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 = 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 || ''), 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) 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 cdk = context.task.reserved_cdk_id ? getCdkById(context.task.reserved_cdk_id) : null if (!cdk || !String(cdk.cdk_code || '').trim()) { throw createHttpError('当前任务没有可用的预占 CDK', { statusCode: 409, errorCode: 'claim_cdk_not_reserved', }) } updateTask(context.task.id, { task_status: 'redeeming', updated_at: nowIso(), last_error: '', }) try { const session = await redeemTencentBrowserSession(context.task.browser_session_id, { code: cdk.cdk_code, }) const finalRedeem = session.redeem?.final?.redeem || null const updatedTask = updateTask(context.task.id, { task_status: 'redeemed', result_code: String(finalRedeem?.iRet || finalRedeem?.ret || '0'), result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'), screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', artifacts_json: JSON.stringify(session.artifacts || {}), redeemed_at: nowIso(), updated_at: nowIso(), last_error: '', }) markCdkDelivered(cdk.id, nowIso()) return buildClaimDetailPayload({ claimToken: context.claimToken, task: updatedTask, order: context.order, orderItem: context.orderItem, session, }) } catch (error) { const nextRetryCount = Number(context.task.retry_count || 0) + 1 const failedTask = updateTask(context.task.id, { task_status: 'retry_pending', retry_count: nextRetryCount, last_error: error instanceof Error ? error.message : String(error || ''), updated_at: nowIso(), }) 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) } async function getClaimContext(token) { const normalized = String(token || '').trim() if (!normalized) { throw createHttpError('缺少领取 token', { statusCode: 400, errorCode: 'missing_claim_token', }) } const claimToken = findClaimTokenByToken(normalized) if (!claimToken) { throw createHttpError('领取链接无效或不存在', { statusCode: 404, errorCode: 'claim_token_not_found', }) } const task = 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 = expireClaimContext(claimToken, task) throw createHttpError('领取链接已过期', { statusCode: 410, errorCode: 'claim_token_expired', context: expiredContext, }) } const order = getOrderById(task.order_id) const orderItem = 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 expireClaimContext(claimToken, task) { const now = nowIso() const nextClaimToken = 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.reserved_cdk_id) { releaseReservedCdk(task.reserved_cdk_id, now) } nextTask = updateTask(task.id, { task_status: 'expired', reserved_cdk_id: null, last_error: '领取链接已过期,预占 CDK 已释放', updated_at: now, }) } return { claimToken: nextClaimToken, task: nextTask, } } async function loadTaskSession(task, { includeQrImage = false } = {}) { if (!task.browser_session_id) { return null } return includeQrImage ? getTencentBrowserSession(task.browser_session_id) : getTencentBrowserSessionSummary(task.browser_session_id) } 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.claimed_at = task.claimed_at || nowIso() } 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 return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), task: { taskId: task.id, taskNo: task.task_no, status: task.task_status, expiresAt: task.expires_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, 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, } }