302 lines
10 KiB
TypeScript
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)
|
|
}
|