增加兑换重试链路
This commit is contained in:
@@ -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",
|
"provider": "agiso",
|
||||||
"platform": "xianyu",
|
"platform": "xianyu",
|
||||||
|
|||||||
@@ -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({
|
export async function listInventoryItems({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -249,3 +277,27 @@ export async function invalidateInventoryItem(inventoryItemId, invalidReason, up
|
|||||||
|
|
||||||
return getInventoryItemById(inventoryItemId)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ const TASK_JOINS = `
|
|||||||
FROM task_inventory_bindings tib
|
FROM task_inventory_bindings tib
|
||||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
||||||
WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed')
|
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
|
LIMIT 1
|
||||||
) inv ON TRUE
|
) inv ON TRUE
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export async function getAdminTaskDetail(taskId, session = null) {
|
|||||||
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
||||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||||
},
|
},
|
||||||
|
redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution),
|
||||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||||
events: taskEvents.map(mapAdminTaskEvent),
|
events: taskEvents.map(mapAdminTaskEvent),
|
||||||
operations: {
|
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) {
|
function normalizeManualDispatchOutcome(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase()
|
const normalized = String(value || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ import {
|
|||||||
reloadTencentBrowserSession,
|
reloadTencentBrowserSession,
|
||||||
redeemTencentBrowserSession,
|
redeemTencentBrowserSession,
|
||||||
} from '../session/session.js'
|
} from '../session/session.js'
|
||||||
|
import { classifyTencentRedeemResult, resolveTencentRedeemMessage } from '../session/session-redeem.js'
|
||||||
import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||||
import { getOrderById } from '../../repositories/order-repo.js'
|
import { getOrderById } from '../../repositories/order-repo.js'
|
||||||
import { getOrderItemById } from '../../repositories/order-item-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 { findTaskByClaimTokenId, getTaskById, updateTask } from '../../repositories/task-repo.js'
|
||||||
import {
|
import {
|
||||||
getInventoryItemById,
|
getInventoryItemById,
|
||||||
|
invalidateReservedInventoryItem,
|
||||||
|
markInventoryItemConsumed,
|
||||||
markInventoryItemDelivered,
|
markInventoryItemDelivered,
|
||||||
releaseReservedInventoryItem,
|
releaseReservedInventoryItem,
|
||||||
} from '../../repositories/inventory-repo.js'
|
} from '../../repositories/inventory-repo.js'
|
||||||
|
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||||
import { buildClaimUrl } from './claim-service.js'
|
import { buildClaimUrl } from './claim-service.js'
|
||||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js'
|
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
@@ -23,6 +28,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
|||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
|
||||||
const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||||
|
const REDEEM_REPLACEMENT_LIMIT = 10
|
||||||
|
|
||||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
@@ -280,7 +286,11 @@ async function finalizeClaimTaskRedeem(context) {
|
|||||||
? await getInventoryItemById(context.task.primary_inventory_item_id)
|
? await getInventoryItemById(context.task.primary_inventory_item_id)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (!inventoryItem || !String(inventoryItem.display_value || '').trim()) {
|
if (
|
||||||
|
!inventoryItem ||
|
||||||
|
String(inventoryItem.status || '').trim() !== 'reserved' ||
|
||||||
|
!String(inventoryItem.display_value || '').trim()
|
||||||
|
) {
|
||||||
throw createHttpError('当前任务没有可用的预占库存凭据', {
|
throw createHttpError('当前任务没有可用的预占库存凭据', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
errorCode: 'claim_inventory_not_reserved',
|
errorCode: 'claim_inventory_not_reserved',
|
||||||
@@ -295,24 +305,39 @@ async function finalizeClaimTaskRedeem(context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await redeemTencentBrowserSession(context.task.browser_session_id, {
|
const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem)
|
||||||
code: inventoryItem.display_value,
|
const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult
|
||||||
})
|
|
||||||
const finalRedeem = session.redeem?.final?.redeem || null
|
const finalRedeem = session.redeem?.final?.redeem || null
|
||||||
|
const finishedAt = nowIso()
|
||||||
const updatedTask = await updateTask(context.task.id, {
|
const updatedTask = await updateTask(context.task.id, {
|
||||||
task_status: 'redeemed',
|
task_status: 'redeemed',
|
||||||
inventory_status: 'consumed',
|
inventory_status: 'consumed',
|
||||||
delivery_status: 'delivered',
|
delivery_status: 'delivered',
|
||||||
result_code: String(finalRedeem?.iRet || finalRedeem?.ret || '0'),
|
result_code: resolveTencentRedeemResultCode(finalRedeem, classification),
|
||||||
result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'),
|
result_message: classification.message,
|
||||||
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
|
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
|
||||||
artifacts_json: JSON.stringify(session.artifacts || {}),
|
artifacts_json: JSON.stringify(session.artifacts || {}),
|
||||||
redeemed_at: nowIso(),
|
context_json: JSON.stringify(mergeTaskContext(context.task, {
|
||||||
updated_at: nowIso(),
|
redeemResolution: {
|
||||||
|
status: 'success',
|
||||||
|
attempts,
|
||||||
|
replacementCount: Math.max(0, attempts.length - 1),
|
||||||
|
finishedAt,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
redeemed_at: finishedAt,
|
||||||
|
updated_at: finishedAt,
|
||||||
last_error: '',
|
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({
|
const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||||
order: context.order,
|
order: context.order,
|
||||||
task: updatedTask,
|
task: updatedTask,
|
||||||
@@ -327,13 +352,34 @@ async function finalizeClaimTaskRedeem(context) {
|
|||||||
session,
|
session,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const now = nowIso()
|
||||||
const nextRetryCount = Number(context.task.attempt_count || 0) + 1
|
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, {
|
const failedTask = await updateTask(context.task.id, {
|
||||||
task_status: 'retry_pending',
|
task_status: failureState.taskStatus,
|
||||||
delivery_status: 'pending',
|
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,
|
attempt_count: nextRetryCount,
|
||||||
last_error: error instanceof Error ? error.message : String(error || ''),
|
last_error: String(failureState.lastError || ''),
|
||||||
updated_at: nowIso(),
|
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 || '兑换失败')), {
|
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) {
|
function parseTaskState(task) {
|
||||||
const value = task?.state_json
|
const value = task?.state_json
|
||||||
|
|
||||||
@@ -671,6 +866,24 @@ function isAssistedClaimTask(task) {
|
|||||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
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 = '' } = {}) {
|
async function clearTaskSession(task, { lastError = '' } = {}) {
|
||||||
const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || ''))
|
const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || ''))
|
||||||
const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : 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()
|
const errorCode = String(error?.errorCode || error?.code || '').trim()
|
||||||
return errorCode === 'session_not_found' || errorCode === 'session_closed'
|
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,
|
code,
|
||||||
verifyCode,
|
verifyCode,
|
||||||
})
|
})
|
||||||
|
const classification = classifyTencentRedeemResult(redeem)
|
||||||
|
|
||||||
const attemptRecord = {
|
const attemptRecord = {
|
||||||
attempt,
|
attempt,
|
||||||
@@ -70,6 +71,7 @@ export async function runTencentBrowserRedeem({
|
|||||||
verifysession: captcha.verifysession,
|
verifysession: captcha.verifysession,
|
||||||
ocrSample: ocr?.data?.saved || null,
|
ocrSample: ocr?.data?.saved || null,
|
||||||
redeem,
|
redeem,
|
||||||
|
classification,
|
||||||
role: activityInfo.role,
|
role: activityInfo.role,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export async function runTencentBrowserRedeem({
|
|||||||
finishedAt: new Date().toISOString(),
|
finishedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
session.status = 'redeemed'
|
session.status = 'redeemed'
|
||||||
session.notice = String(finalResult.redeem?.sMsg || '兑换完成')
|
session.notice = resolveTencentRedeemMessage(finalResult?.redeem)
|
||||||
session.updatedAt = new Date().toISOString()
|
session.updatedAt = new Date().toISOString()
|
||||||
await persistSessionState(session)
|
await persistSessionState(session)
|
||||||
|
|
||||||
@@ -277,22 +279,42 @@ export async function refreshPageCaptcha(page, verifyImgId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCaptchaRejectedResult(result) {
|
export function isCaptchaRejectedResult(result) {
|
||||||
const retCode = Number(result?.iRet)
|
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
|
||||||
|
|
||||||
if (Number.isFinite(retCode) && retCode === -100) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = [
|
return false
|
||||||
String(result?.sMsg || ''),
|
}
|
||||||
String(result?.msg || ''),
|
|
||||||
String(result?.popup?.text || ''),
|
|
||||||
String(result?.popup?.detail || ''),
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
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) {
|
function resetRedeemPopup(page) {
|
||||||
@@ -508,3 +530,34 @@ function inferRedeemCodeFromPopup(popup) {
|
|||||||
|
|
||||||
return 1
|
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,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
|||||||
function resolveTone(status: string) {
|
function resolveTone(status: string) {
|
||||||
const normalized = String(status || '').trim().toLowerCase()
|
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'
|
return 'success'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ function resolveTone(status: string) {
|
|||||||
return 'primary'
|
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'
|
return 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ function resolveLabel(status: string) {
|
|||||||
available: '可用',
|
available: '可用',
|
||||||
reserved: '已预占',
|
reserved: '已预占',
|
||||||
delivered: '已发放',
|
delivered: '已发放',
|
||||||
consumed: '已发放',
|
consumed: '已消耗',
|
||||||
invalid: '已作废',
|
invalid: '已作废',
|
||||||
active: '生效中',
|
active: '生效中',
|
||||||
admin: '管理员',
|
admin: '管理员',
|
||||||
|
|||||||
@@ -373,6 +373,21 @@ export interface AdminTaskDetail {
|
|||||||
roleId: string
|
roleId: string
|
||||||
roleName: 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 | {
|
manualDispatch: null | {
|
||||||
outcome: string
|
outcome: string
|
||||||
deliveryReference: string
|
deliveryReference: string
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export function formatTaskEventType(eventType: string) {
|
|||||||
const labelMap: Record<string, string> = {
|
const labelMap: Record<string, string> = {
|
||||||
manual_dispatch_completed: '人工履约已回写',
|
manual_dispatch_completed: '人工履约已回写',
|
||||||
inventory_binding_released: '库存绑定已释放',
|
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_success: '咸鱼自动发货成功',
|
||||||
agiso_auto_delivery_failed: '咸鱼自动发货失败',
|
agiso_auto_delivery_failed: '咸鱼自动发货失败',
|
||||||
agiso_auto_delivery_skipped: '咸鱼自动发货已跳过',
|
agiso_auto_delivery_skipped: '咸鱼自动发货已跳过',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const adminInventoryStatusOptions = [
|
|||||||
{ label: '可用', value: 'available' },
|
{ label: '可用', value: 'available' },
|
||||||
{ label: '已预占', value: 'reserved' },
|
{ label: '已预占', value: 'reserved' },
|
||||||
{ label: '已发放', value: 'delivered' },
|
{ label: '已发放', value: 'delivered' },
|
||||||
|
{ label: '已消耗', value: 'consumed' },
|
||||||
{ label: '已作废', value: 'invalid' },
|
{ label: '已作废', value: 'invalid' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ function formatTaskEventPayload(payload: Record<string, unknown>) {
|
|||||||
payload.outcome ? `结果 ${payload.outcome}` : '',
|
payload.outcome ? `结果 ${payload.outcome}` : '',
|
||||||
payload.resultCode ? `代码 ${payload.resultCode}` : '',
|
payload.resultCode ? `代码 ${payload.resultCode}` : '',
|
||||||
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
|
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.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
|
||||||
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
|
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
|
||||||
payload.reason ? `原因 ${payload.reason}` : '',
|
payload.reason ? `原因 ${payload.reason}` : '',
|
||||||
@@ -161,6 +168,29 @@ function formatTaskEventPayload(payload: Record<string, unknown>) {
|
|||||||
return JSON.stringify(payload || {})
|
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)
|
onMounted(loadDetail)
|
||||||
onBeforeUnmount(clearScreenshotPreview)
|
onBeforeUnmount(clearScreenshotPreview)
|
||||||
</script>
|
</script>
|
||||||
@@ -309,6 +339,62 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="detail.redeemResolution" class="table-card">
|
||||||
|
<h3>兑换重试链路</h3>
|
||||||
|
<p class="section-copy">这里记录自动兑换时每次使用过的 CDK、判定结果,以及是否切换到了新的同类型凭据。</p>
|
||||||
|
|
||||||
|
<div class="redeem-summary-grid">
|
||||||
|
<article class="redeem-summary-card">
|
||||||
|
<span>处理结果</span>
|
||||||
|
<strong>{{ formatRedeemResolutionStatus(detail.redeemResolution.status) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="redeem-summary-card">
|
||||||
|
<span>任务收口状态</span>
|
||||||
|
<strong>{{ detail.redeemResolution.taskStatus || detail.task.status || '-' }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="redeem-summary-card">
|
||||||
|
<span>更换新码次数</span>
|
||||||
|
<strong>{{ detail.redeemResolution.replacementCount }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="redeem-summary-card">
|
||||||
|
<span>完成时间</span>
|
||||||
|
<strong>{{ formatAdminDateTime(detail.redeemResolution.finishedAt) }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="detail.redeemResolution.attempts.length > 0" class="redeem-attempts">
|
||||||
|
<article
|
||||||
|
v-for="attempt in detail.redeemResolution.attempts"
|
||||||
|
:key="`${attempt.attempt}-${attempt.inventoryItemId || 'na'}`"
|
||||||
|
class="redeem-attempt-card"
|
||||||
|
>
|
||||||
|
<div class="redeem-attempt-head">
|
||||||
|
<strong>第 {{ attempt.attempt }} 次尝试</strong>
|
||||||
|
<span class="redeem-outcome">{{ formatRedeemOutcomeLabel(attempt.outcome) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="redeem-attempt-grid">
|
||||||
|
<div>
|
||||||
|
<span>库存项</span>
|
||||||
|
<strong>{{ attempt.inventoryItemId ? `#${attempt.inventoryItemId}` : '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>凭据</span>
|
||||||
|
<strong>{{ attempt.codeMasked || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>类型</span>
|
||||||
|
<strong>{{ attempt.credentialType || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>结果码</span>
|
||||||
|
<strong>{{ attempt.resultCode || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="redeem-attempt-copy">{{ attempt.resultMessage || '-' }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="table-card">
|
<section class="table-card">
|
||||||
<h3>库存绑定</h3>
|
<h3>库存绑定</h3>
|
||||||
<p class="section-copy">这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。</p>
|
<p class="section-copy">这里展示任务当前和历史绑定过的库存项,便于核对多库存任务的真实履约轨迹。</p>
|
||||||
@@ -347,7 +433,9 @@ onBeforeUnmount(clearScreenshotPreview)
|
|||||||
<AdminStatusTag :status="binding.bindingStatus" />
|
<AdminStatusTag :status="binding.bindingStatus" />
|
||||||
<AdminStatusTag :status="binding.inventoryStatus" />
|
<AdminStatusTag :status="binding.inventoryStatus" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="binding.invalidReason" class="binding-meta">作废原因:{{ binding.invalidReason }}</div>
|
<div v-if="binding.invalidReason" class="binding-meta">
|
||||||
|
{{ binding.inventoryStatus === 'invalid' ? '作废原因' : binding.inventoryStatus === 'consumed' ? '消耗原因' : '处理原因' }}:{{ binding.invalidReason }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }}</td>
|
<td>{{ canViewSensitiveTaskData ? (binding.displayValue || '-') : '客服不可见' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -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,.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; }
|
.data-table th { width: 160px; color: #64748b; }
|
||||||
.status-stack { display: flex; flex-wrap: wrap; gap: 8px; }
|
.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; }
|
.table-empty { text-align: center; color: #64748b; }
|
||||||
.binding-primary { margin-top: 4px; color: #0f766e; font-size: 12px; font-weight: 600; }
|
.binding-primary { margin-top: 4px; color: #0f766e; font-size: 12px; font-weight: 600; }
|
||||||
.binding-meta { margin-top: 4px; color: #64748b; font-size: 12px; }
|
.binding-meta { margin-top: 4px; color: #64748b; font-size: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user