Files
order_site/apps/backend/src/services/claim/session/redeem.ts
T
2026-05-21 14:22:20 +08:00

302 lines
10 KiB
TypeScript

import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js'
import { classifyTencentRedeemResult } from '../../session/session-redeem.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import {
invalidateReservedInventoryItem,
markInventoryItemConsumed,
} from '../../../repositories/inventory-repo.js'
import { reserveInventoryForTask } from '../../order/inventory-service.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js'
import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js'
type ClaimRedeemContext = {
task: Pick<TaskRow, 'id' | 'browser_session_id'>
orderItem: Pick<OrderItemRow, 'sku_code'>
}
type TencentRedeemPayload = {
[key: string]: unknown
iRet?: unknown
ret?: unknown
}
type TencentRedeemSession = {
[key: string]: unknown
sessionId?: string
artifacts?: {
[key: string]: unknown
hasScreenshot?: boolean
} | null
redeem?: {
final?: {
redeem?: TencentRedeemPayload | null
} | null
} | null
}
type TencentRedeemClassification = {
success: boolean
outcome: string
message: string
retCode?: unknown
[key: string]: unknown
}
type RedeemAttemptSummary = {
attempt: number
inventoryItemId: number
codeMasked: string
credentialType: string
outcome: string
resultCode: string
resultMessage: string
}
type RedeemTaskState = {
taskStatus: string
inventoryStatus: string
deliveryStatus: string
lastError: string
attempts: RedeemAttemptSummary[]
classification: TencentRedeemClassification | null
}
type RedeemTaskStateError = Error & {
statusCode?: number
errorCode?: string
redeemTaskState: RedeemTaskState
}
type RedeemTaskStateErrorPayload = {
statusCode?: number
errorCode?: string
taskStatus?: string
inventoryStatus?: string
deliveryStatus?: string
attempts?: RedeemAttemptSummary[]
classification?: TencentRedeemClassification | null
}
type RedeemClaimTaskDeps = {
reloadTencentBrowserSession?: (sessionId: string) => Promise<unknown>
redeemTencentBrowserSession?: (
sessionId: string,
payload: { code: string },
) => Promise<TencentRedeemSession>
classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification
markInventoryItemConsumed?: (
inventoryItemId: number | string,
reason: string,
consumedAt: string,
) => Promise<InventoryItemRow | null>
createTaskEvent?: (
taskId: number | string,
eventType: string,
payload?: unknown,
createdAt?: string,
) => Promise<unknown>
invalidateReservedInventoryItem?: (
inventoryItemId: number | string,
reason: string,
updatedAt: string,
) => Promise<InventoryItemRow | null>
reserveInventoryForTask?: (payload: {
skuCode: string
taskId: number | string
credentialType?: string
roleKey?: string
inventoryGroupCodes?: string[] | null
}) => Promise<InventoryItemRow | null>
nowIso?: () => string
redeemReplacementLimit?: number
}
type RedeemClaimTaskResult = {
session: TencentRedeemSession
inventoryItem: InventoryItemRow
classification: TencentRedeemClassification
attempts: RedeemAttemptSummary[]
}
export async function redeemClaimTaskWithInventoryFallback(
context: ClaimRedeemContext,
initialInventoryItem: InventoryItemRow,
): Promise<RedeemClaimTaskResult> {
return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem)
}
export async function redeemClaimTaskWithInventoryFallbackWithDeps(
context: ClaimRedeemContext,
initialInventoryItem: InventoryItemRow,
{
reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession,
redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession,
classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult,
markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed,
createTaskEvent: createRedeemTaskEvent = createTaskEvent,
invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem,
reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask,
nowIso: getNowIso = nowIso,
redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT,
}: RedeemClaimTaskDeps = {},
): Promise<RedeemClaimTaskResult> {
const attempts: RedeemAttemptSummary[] = []
let currentInventoryItem = initialInventoryItem
let shouldReloadBeforeNextAttempt = false
for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) {
if (shouldReloadBeforeNextAttempt) {
await reloadRedeemSession(context.task.browser_session_id)
shouldReloadBeforeNextAttempt = false
}
const session = await redeemSession(context.task.browser_session_id, {
code: currentInventoryItem.display_value,
})
const finalRedeem = session.redeem?.final?.redeem || null
const classification = classifyRedeemResult(finalRedeem)
const attemptSummary = {
attempt: attemptIndex,
inventoryItemId: currentInventoryItem.id,
codeMasked: maskCode(currentInventoryItem.display_value),
credentialType: String(currentInventoryItem.credential_type || ''),
outcome: classification.outcome,
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
resultMessage: classification.message,
}
attempts.push(attemptSummary)
if (classification.success) {
return {
session,
inventoryItem: currentInventoryItem,
classification,
attempts,
}
}
if (classification.outcome === 'code_used') {
const updatedAt = getNowIso()
await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else if (classification.outcome === 'code_invalid') {
const updatedAt = getNowIso()
await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else {
throw createRedeemTaskStateError(classification.message, {
errorCode: 'claim_redeem_failed',
taskStatus: 'retry_pending',
inventoryStatus: 'reserved',
deliveryStatus: 'pending',
attempts,
classification,
})
}
if (attemptIndex >= redeemReplacementLimit) {
throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', {
errorCode: 'claim_redeem_replacement_limit_reached',
taskStatus: 'retry_pending',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification,
})
}
const replacement = await reserveReplacementInventory({
skuCode: context.orderItem.sku_code,
taskId: context.task.id,
credentialType: currentInventoryItem.credential_type || 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: currentInventoryItem.inventory_group_code
? [String(currentInventoryItem.inventory_group_code).trim()]
: null,
})
if (!replacement || !String(replacement.display_value || '').trim()) {
const exhaustedMessage = classification.outcome === 'code_used'
? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试'
: '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试'
throw createRedeemTaskStateError(exhaustedMessage, {
errorCode: 'claim_inventory_replacement_exhausted',
taskStatus: 'waiting_inventory',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification,
})
}
const replacedAt = getNowIso()
await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', {
previousInventoryItemId: currentInventoryItem.id,
previousCodeMasked: attemptSummary.codeMasked,
previousOutcome: classification.outcome,
nextInventoryItemId: replacement.id,
nextCodeMasked: maskCode(replacement.display_value),
credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''),
inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(),
}, replacedAt)
currentInventoryItem = replacement
shouldReloadBeforeNextAttempt = true
}
throw createRedeemTaskStateError('兑换失败,请稍后重试', {
errorCode: 'claim_redeem_failed',
taskStatus: 'retry_pending',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification: null,
})
}
function createRedeemTaskStateError(
message: string,
payload: RedeemTaskStateErrorPayload = {},
): RedeemTaskStateError {
const error = createHttpError(message, {
statusCode: payload.statusCode || 409,
errorCode: payload.errorCode || 'claim_redeem_failed',
}) as unknown as RedeemTaskStateError
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
}
export function resolveTencentRedeemResultCode(
finalRedeem: TencentRedeemPayload | null,
classification: Pick<TencentRedeemClassification, 'retCode'> | null,
): string {
if (classification?.retCode != null) {
return String(classification.retCode)
}
const value = finalRedeem?.iRet ?? finalRedeem?.ret
return value == null ? '' : String(value)
}