From 0c2f2e0a125be0f933575c0f2edb395686dbd8f8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 13 Apr 2026 22:54:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=85=91=E6=8D=A2=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/order-fulfillment-bindings.json | 19 ++ .../src/repositories/inventory-repo.js | 52 ++++ apps/backend/src/repositories/task-repo.js | 8 +- .../src/services/admin/admin-service.js | 37 +++ .../services/claim/claim-session-service.js | 253 +++++++++++++++++- .../src/services/session/session-redeem.js | 79 +++++- .../services/session/session-redeem.test.js | 78 ++++++ .../src/components/admin/AdminStatusTag.vue | 6 +- apps/frontend/src/types/admin.ts | 15 ++ apps/frontend/src/utils/admin-display.ts | 4 + apps/frontend/src/utils/admin-options.ts | 1 + .../src/views/admin/AdminTaskDetailView.vue | 140 +++++++++- 12 files changed, 661 insertions(+), 31 deletions(-) create mode 100644 apps/backend/src/services/session/session-redeem.test.js diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json index a23fd7b8..1a7743e5 100644 --- a/apps/backend/data/order-fulfillment-bindings.json +++ b/apps/backend/data/order-fulfillment-bindings.json @@ -1,4 +1,23 @@ [ + { + "provider": "agiso", + "platform": "xianyu", + "shopId": "2209880145223", + "skuCode": "sjz_hdl_test01", + "skuName": "海底捞第五人格皮肤", + "profileKey": "tencent_claim_redeem", + "enabled": true, + "priority": 100, + "config": {}, + "match": { + "externalSkuCode": "6227949975509", + "externalItemId": "1038123159888", + "externalSkuName": "海底捞第五人格皮肤", + "config": { + "resolvedSkuName": "海底捞第五人格皮肤" + } + } + }, { "provider": "agiso", "platform": "xianyu", diff --git a/apps/backend/src/repositories/inventory-repo.js b/apps/backend/src/repositories/inventory-repo.js index 4d6cb676..3af47e3c 100644 --- a/apps/backend/src/repositories/inventory-repo.js +++ b/apps/backend/src/repositories/inventory-repo.js @@ -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) + }) +} diff --git a/apps/backend/src/repositories/task-repo.js b/apps/backend/src/repositories/task-repo.js index c1b65f88..73694763 100644 --- a/apps/backend/src/repositories/task-repo.js +++ b/apps/backend/src/repositories/task-repo.js @@ -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 ` diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index 4a254ede..dea76718 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -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() diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index 49e2ab10..887bdcac 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -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)}` +} diff --git a/apps/backend/src/services/session/session-redeem.js b/apps/backend/src/services/session/session-redeem.js index 2aa8e178..a911d6dd 100644 --- a/apps/backend/src/services/session/session-redeem.js +++ b/apps/backend/src/services/session/session-redeem.js @@ -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() +} diff --git a/apps/backend/src/services/session/session-redeem.test.js b/apps/backend/src/services/session/session-redeem.test.js new file mode 100644 index 00000000..26535ef3 --- /dev/null +++ b/apps/backend/src/services/session/session-redeem.test.js @@ -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, + ) +}) diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue index 96e8b7a2..976633d5 100644 --- a/apps/frontend/src/components/admin/AdminStatusTag.vue +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -8,7 +8,7 @@ const props = defineProps<{ function resolveTone(status: string) { const normalized = String(status || '').trim().toLowerCase() - if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'consumed', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) { + if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) { return 'success' } @@ -16,7 +16,7 @@ function resolveTone(status: string) { return 'primary' } - if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started'].includes(normalized)) { + if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed'].includes(normalized)) { return 'warning' } @@ -56,7 +56,7 @@ function resolveLabel(status: string) { available: '可用', reserved: '已预占', delivered: '已发放', - consumed: '已发放', + consumed: '已消耗', invalid: '已作废', active: '生效中', admin: '管理员', diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 1d0a720f..c3623baa 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -373,6 +373,21 @@ export interface AdminTaskDetail { roleId: string roleName: string } + redeemResolution: null | { + status: string + taskStatus: string + replacementCount: number + finishedAt: string | null + attempts: Array<{ + attempt: number + inventoryItemId: number | null + codeMasked: string + credentialType: string + outcome: string + resultCode: string + resultMessage: string + }> + } manualDispatch: null | { outcome: string deliveryReference: string diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 6ff15538..1b12f3ed 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -54,6 +54,10 @@ export function formatTaskEventType(eventType: string) { const labelMap: Record = { manual_dispatch_completed: '人工履约已回写', inventory_binding_released: '库存绑定已释放', + claim_redeem_code_used: '兑换码已使用', + claim_redeem_code_invalid: '兑换码错误', + claim_redeem_inventory_replaced: '已切换新兑换码', + claim_redeem_completed: '兑换链路完成', agiso_auto_delivery_success: '咸鱼自动发货成功', agiso_auto_delivery_failed: '咸鱼自动发货失败', agiso_auto_delivery_skipped: '咸鱼自动发货已跳过', diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index e61fd715..df96a829 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -28,6 +28,7 @@ export const adminInventoryStatusOptions = [ { label: '可用', value: 'available' }, { label: '已预占', value: 'reserved' }, { label: '已发放', value: 'delivered' }, + { label: '已消耗', value: 'consumed' }, { label: '已作废', value: 'invalid' }, ] diff --git a/apps/frontend/src/views/admin/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/AdminTaskDetailView.vue index de5c78bb..2b9ef07f 100644 --- a/apps/frontend/src/views/admin/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/AdminTaskDetailView.vue @@ -146,6 +146,13 @@ function formatTaskEventPayload(payload: Record) { payload.outcome ? `结果 ${payload.outcome}` : '', payload.resultCode ? `代码 ${payload.resultCode}` : '', payload.resultMessage ? `说明 ${payload.resultMessage}` : '', + payload.codeMasked ? `凭据 ${payload.codeMasked}` : '', + payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '', + payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '', + payload.inventoryItemId ? `库存项 #${payload.inventoryItemId}` : '', + payload.previousInventoryItemId ? `旧库存 #${payload.previousInventoryItemId}` : '', + payload.nextInventoryItemId ? `新库存 #${payload.nextInventoryItemId}` : '', + payload.previousOutcome ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` : '', payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '', payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '', payload.reason ? `原因 ${payload.reason}` : '', @@ -161,6 +168,29 @@ function formatTaskEventPayload(payload: Record) { return JSON.stringify(payload || {}) } +function formatRedeemOutcomeLabel(value: string) { + const normalized = String(value || '').trim() + + switch (normalized) { + case 'success': + return '兑换成功' + case 'code_used': + return '兑换码已使用' + case 'code_invalid': + return '兑换码错误' + case 'captcha_rejected': + return '验证码错误' + case 'failed': + return '兑换失败' + default: + return normalized || '-' + } +} + +function formatRedeemResolutionStatus(value: string) { + return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : (value || '-') +} + onMounted(loadDetail) onBeforeUnmount(clearScreenshotPreview) @@ -309,6 +339,62 @@ onBeforeUnmount(clearScreenshotPreview) +
+

兑换重试链路

+

这里记录自动兑换时每次使用过的 CDK、判定结果,以及是否切换到了新的同类型凭据。

+ +
+
+ 处理结果 + {{ formatRedeemResolutionStatus(detail.redeemResolution.status) }} +
+
+ 任务收口状态 + {{ detail.redeemResolution.taskStatus || detail.task.status || '-' }} +
+
+ 更换新码次数 + {{ detail.redeemResolution.replacementCount }} +
+
+ 完成时间 + {{ formatAdminDateTime(detail.redeemResolution.finishedAt) }} +
+
+ +
+
+
+ 第 {{ attempt.attempt }} 次尝试 + {{ formatRedeemOutcomeLabel(attempt.outcome) }} +
+
+
+ 库存项 + {{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }} +
+
+ 凭据 + {{ attempt.codeMasked || '-' }} +
+
+ 类型 + {{ attempt.credentialType || '-' }} +
+
+ 结果码 + {{ attempt.resultCode || '-' }} +
+
+

{{ attempt.resultMessage || '-' }}

+
+
+
+

库存绑定

这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。

@@ -347,7 +433,9 @@ onBeforeUnmount(clearScreenshotPreview) -
作废原因:{{ binding.invalidReason }}
+
+ {{ binding.inventoryStatus === 'invalid' ? '作废原因' : binding.inventoryStatus === 'consumed' ? '消耗原因' : '处理原因' }}:{{ binding.invalidReason }} +
{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }} @@ -461,6 +549,56 @@ onBeforeUnmount(clearScreenshotPreview) .data-table th,.data-table td { padding: 12px 10px; text-align: left; border-bottom: 1px solid rgba(86,108,138,.08); } .data-table th { width: 160px; color: #64748b; } .status-stack { display: flex; flex-wrap: wrap; gap: 8px; } +.redeem-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} +.redeem-summary-card, +.redeem-attempt-card { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(86,108,138,.1); + background: rgba(248,250,252,.92); +} +.redeem-summary-card span, +.redeem-attempt-grid span { + display: block; + color: #64748b; + font-size: 12px; + margin-bottom: 6px; +} +.redeem-summary-card strong, +.redeem-attempt-grid strong { + color: #1d3555; +} +.redeem-attempts { + display: grid; + gap: 12px; +} +.redeem-attempt-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.redeem-outcome { + color: #175cd3; + font-size: 13px; + font-weight: 600; +} +.redeem-attempt-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-top: 12px; +} +.redeem-attempt-copy { + margin: 12px 0 0; + color: #334155; + line-height: 1.6; +} .table-empty { text-align: center; color: #64748b; } .binding-primary { margin-top: 4px; color: #0f766e; font-size: 12px; font-weight: 600; } .binding-meta { margin-top: 4px; color: #64748b; font-size: 12px; }