410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
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,
|
|
}
|
|
}
|