增加兑换重试链路

This commit is contained in:
yml2213
2026-04-13 22:54:14 +08:00
parent bc0d6fc8aa
commit 0c2f2e0a12
12 changed files with 661 additions and 31 deletions
@@ -111,6 +111,34 @@ export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) {
})
}
export async function markInventoryItemConsumed(inventoryItemId, reason, consumedAt) {
return withTransaction(async (client) => {
await client.query(
`
UPDATE inventory_items
SET
status = 'consumed',
invalid_reason = $1,
consumed_at = $2,
updated_at = $2
WHERE id = $3
`,
[String(reason || '').trim(), consumedAt, Number(inventoryItemId)],
)
await client.query(
`
UPDATE task_inventory_bindings
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
`,
[consumedAt, Number(inventoryItemId)],
)
return getInventoryItemById(inventoryItemId)
})
}
export async function listInventoryItems({
page = 1,
pageSize = 20,
@@ -249,3 +277,27 @@ export async function invalidateInventoryItem(inventoryItemId, invalidReason, up
return getInventoryItemById(inventoryItemId)
}
export async function invalidateReservedInventoryItem(inventoryItemId, invalidReason, updatedAt) {
return withTransaction(async (client) => {
await client.query(
`
UPDATE task_inventory_bindings
SET binding_status = 'released', released_at = $1, updated_at = $1
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
`,
[updatedAt, Number(inventoryItemId)],
)
await client.query(
`
UPDATE inventory_items
SET status = 'invalid', invalid_reason = $1, updated_at = $2
WHERE id = $3
`,
[String(invalidReason || '').trim(), updatedAt, Number(inventoryItemId)],
)
return getInventoryItemById(inventoryItemId)
})
}
+7 -1
View File
@@ -35,7 +35,13 @@ const TASK_JOINS = `
FROM task_inventory_bindings tib
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed')
ORDER BY tib.id ASC
ORDER BY
CASE tib.binding_status
WHEN 'reserved' THEN 0
WHEN 'consumed' THEN 1
ELSE 2
END ASC,
tib.id DESC
LIMIT 1
) inv ON TRUE
`
@@ -314,6 +314,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
roleId: String(taskState.reviewRoleId || '').trim() || '',
roleName: String(taskState.reviewRoleName || '').trim() || '',
},
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
events: taskEvents.map(mapAdminTaskEvent),
operations: {
@@ -2017,6 +2018,42 @@ function mapManualDispatchContext(value, viewerContext = createAdminViewerContex
}
}
function mapRedeemResolutionContext(value) {
if (!value || typeof value !== 'object') {
return null
}
const attempts = Array.isArray(value.attempts)
? value.attempts
.map((attempt) => mapRedeemResolutionAttempt(attempt))
.filter(Boolean)
: []
return {
status: String(value.status || '').trim(),
taskStatus: String(value.taskStatus || '').trim(),
replacementCount: Math.max(0, Number(value.replacementCount || 0)),
finishedAt: value.finishedAt || null,
attempts,
}
}
function mapRedeemResolutionAttempt(value) {
if (!value || typeof value !== 'object') {
return null
}
return {
attempt: Math.max(1, Number(value.attempt || 1)),
inventoryItemId: Number(value.inventoryItemId || 0) || null,
codeMasked: String(value.codeMasked || '').trim(),
credentialType: String(value.credentialType || '').trim(),
outcome: String(value.outcome || '').trim(),
resultCode: String(value.resultCode || '').trim(),
resultMessage: String(value.resultMessage || '').trim(),
}
}
function normalizeManualDispatchOutcome(value) {
const normalized = String(value || '').trim().toLowerCase()
@@ -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)}`
}
@@ -63,6 +63,7 @@ export async function runTencentBrowserRedeem({
code,
verifyCode,
})
const classification = classifyTencentRedeemResult(redeem)
const attemptRecord = {
attempt,
@@ -70,6 +71,7 @@ export async function runTencentBrowserRedeem({
verifysession: captcha.verifysession,
ocrSample: ocr?.data?.saved || null,
redeem,
classification,
role: activityInfo.role,
}
@@ -111,7 +113,7 @@ export async function runTencentBrowserRedeem({
finishedAt: new Date().toISOString(),
}
session.status = 'redeemed'
session.notice = String(finalResult.redeem?.sMsg || '兑换完成')
session.notice = resolveTencentRedeemMessage(finalResult?.redeem)
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
@@ -277,22 +279,42 @@ export async function refreshPageCaptcha(page, verifyImgId) {
}
export function isCaptchaRejectedResult(result) {
const retCode = Number(result?.iRet)
if (Number.isFinite(retCode) && retCode === -100) {
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
return true
}
const text = [
String(result?.sMsg || ''),
String(result?.msg || ''),
String(result?.popup?.text || ''),
String(result?.popup?.detail || ''),
]
.join(' ')
.trim()
return false
}
return /验证码|校验码/.test(text)
export function classifyTencentRedeemResult(result) {
const retCode = Number(result?.iRet)
const message = resolveTencentRedeemMessage(result)
const normalized = normalizeTencentRedeemText(message)
if (Number.isFinite(retCode) && retCode === -100) {
return buildTencentRedeemClassification('captcha_rejected', retCode, message)
}
if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) {
return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用')
}
if (/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确/.test(normalized)) {
return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确')
}
if (/验证码|校验码/.test(normalized)) {
return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试')
}
if (
retCode === 0 ||
/成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized)
) {
return buildTencentRedeemClassification('success', retCode, message || '兑换成功')
}
return buildTencentRedeemClassification('failed', retCode, message || '兑换失败')
}
function resetRedeemPopup(page) {
@@ -508,3 +530,34 @@ function inferRedeemCodeFromPopup(popup) {
return 1
}
export function resolveTencentRedeemMessage(result) {
const candidates = [
result?.popup?.detail,
result?.popup?.text,
result?.sMsg,
result?.msg,
]
.map((item) => String(item || '').trim())
.filter(Boolean)
return candidates[0] || '兑换完成'
}
function buildTencentRedeemClassification(outcome, retCode, message) {
return {
outcome,
success: outcome === 'success',
retryWithSameCode: outcome === 'captcha_rejected',
retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid',
retCode: Number.isFinite(retCode) ? retCode : null,
message: String(message || '').trim(),
}
}
function normalizeTencentRedeemText(value) {
return String(value || '')
.replace(/\s+/g, '')
.trim()
.toLowerCase()
}
@@ -0,0 +1,78 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
classifyTencentRedeemResult,
isCaptchaRejectedResult,
resolveTencentRedeemMessage,
} from './session-redeem.js'
test('resolveTencentRedeemMessage prefers popup detail over generic title', () => {
assert.equal(
resolveTencentRedeemMessage({
popup: {
text: '兑换结果',
detail: '兑换码错误,请确认兑换码信息是否准确。',
},
}),
'兑换码错误,请确认兑换码信息是否准确。',
)
})
test('classifyTencentRedeemResult marks used codes as replacement retries', () => {
const result = classifyTencentRedeemResult({
popup: {
text: '兑换结果',
detail: '兑换码已使用。',
},
})
assert.equal(result.outcome, 'code_used')
assert.equal(result.success, false)
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult marks invalid codes as replacement retries', () => {
const result = classifyTencentRedeemResult({
popup: {
text: '兑换结果',
detail: '兑换码错误,请确认兑换码信息是否准确。',
},
})
assert.equal(result.outcome, 'code_invalid')
assert.equal(result.success, false)
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult recognizes successful gift popup', () => {
const result = classifyTencentRedeemResult({
popup: {
text: '恭喜您获得了礼包:动作-干员庆生,请注意:游戏虚拟道具奖品将会在24小时内到账,请登录游戏查看邮件。',
detail: '',
},
})
assert.equal(result.outcome, 'success')
assert.equal(result.success, true)
})
test('isCaptchaRejectedResult only treats captcha problems as same-code retries', () => {
assert.equal(
isCaptchaRejectedResult({
iRet: -100,
sMsg: '验证码错误',
}),
true,
)
assert.equal(
isCaptchaRejectedResult({
popup: {
text: '兑换结果',
detail: '兑换码已使用。',
},
}),
false,
)
})