增加兑换重试链路
This commit is contained in:
@@ -7,15 +7,20 @@ import {
|
||||
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 { createHttpError } from '../../utils/http.js'
|
||||
@@ -23,6 +28,7 @@ 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
|
||||
|
||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
@@ -280,7 +286,11 @@ async function finalizeClaimTaskRedeem(context) {
|
||||
? await getInventoryItemById(context.task.primary_inventory_item_id)
|
||||
: null
|
||||
|
||||
if (!inventoryItem || !String(inventoryItem.display_value || '').trim()) {
|
||||
if (
|
||||
!inventoryItem ||
|
||||
String(inventoryItem.status || '').trim() !== 'reserved' ||
|
||||
!String(inventoryItem.display_value || '').trim()
|
||||
) {
|
||||
throw createHttpError('当前任务没有可用的预占库存凭据', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_inventory_not_reserved',
|
||||
@@ -295,24 +305,39 @@ async function finalizeClaimTaskRedeem(context) {
|
||||
})
|
||||
|
||||
try {
|
||||
const session = await redeemTencentBrowserSession(context.task.browser_session_id, {
|
||||
code: inventoryItem.display_value,
|
||||
})
|
||||
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: String(finalRedeem?.iRet || finalRedeem?.ret || '0'),
|
||||
result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'),
|
||||
result_code: resolveTencentRedeemResultCode(finalRedeem, classification),
|
||||
result_message: classification.message,
|
||||
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
|
||||
artifacts_json: JSON.stringify(session.artifacts || {}),
|
||||
redeemed_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
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(inventoryItem.id, nowIso())
|
||||
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,
|
||||
@@ -327,13 +352,34 @@ async function finalizeClaimTaskRedeem(context) {
|
||||
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: 'retry_pending',
|
||||
delivery_status: 'pending',
|
||||
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: error instanceof Error ? error.message : String(error || ''),
|
||||
updated_at: nowIso(),
|
||||
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 || '兑换失败')), {
|
||||
@@ -649,6 +695,155 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemClaimTaskWithInventoryFallback(context, initialInventoryItem) {
|
||||
const attempts = []
|
||||
let currentInventoryItem = initialInventoryItem
|
||||
|
||||
for (let attemptIndex = 1; attemptIndex <= REDEEM_REPLACEMENT_LIMIT; attemptIndex += 1) {
|
||||
const session = await redeemTencentBrowserSession(context.task.browser_session_id, {
|
||||
code: currentInventoryItem.display_value,
|
||||
})
|
||||
const finalRedeem = session.redeem?.final?.redeem || null
|
||||
const classification = classifyTencentRedeemResult(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 = nowIso()
|
||||
await markInventoryItemConsumed(currentInventoryItem.id, classification.message, updatedAt)
|
||||
await createTaskEvent(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 = nowIso()
|
||||
await invalidateReservedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
|
||||
await createTaskEvent(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 >= REDEEM_REPLACEMENT_LIMIT) {
|
||||
throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', {
|
||||
errorCode: 'claim_redeem_replacement_limit_reached',
|
||||
taskStatus: 'retry_pending',
|
||||
inventoryStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
attempts,
|
||||
classification,
|
||||
})
|
||||
}
|
||||
|
||||
const replacement = await reserveInventoryForTask({
|
||||
skuCode: context.orderItem.sku_code,
|
||||
taskId: context.task.id,
|
||||
credentialType: currentInventoryItem.credential_type || 'tencent_code',
|
||||
roleKey: 'primary_code',
|
||||
})
|
||||
|
||||
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 = nowIso()
|
||||
await createTaskEvent(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 || ''),
|
||||
}, replacedAt)
|
||||
currentInventoryItem = replacement
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -671,6 +866,24 @@ 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
|
||||
@@ -703,3 +916,17 @@ 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)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user