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