// @ts-check import { createTaskClaimToken } from '../claim/claim-service.js' import { closeClaimSessionForAdminTask, createClaimSessionForAdminTask, getClaimDetailForAdminTask, getClaimSessionSummaryForAdminTask, reloadClaimSessionForAdminTask, } from '../claim/claim-session-service.js' import { reserveInventoryForTask } from '../order/inventory-service.js' import { createHttpError } from '../../utils/http.js' import { replaceOrderItems } from '../../repositories/order-item-repo.js' import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js' import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js' import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js' import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js' import { createTaskEvent } from '../../repositories/task-event-repo.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js' import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js' const MANUAL_PROVIDER = 'manual' const MANUAL_PLATFORM = 'manual_redeem' const MANUAL_SOURCE_TYPE = 'admin_manual_redeem' const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted' /** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */ /** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateInput */ /** @param {AdminManualRedeemCreateInput} [payload] */ /** @param {AdminViewerSessionInput | null} [session] */ export async function createAdminManualRedeemTask( payload = /** @type {AdminManualRedeemCreateInput} */ ({}), session = null, ) { const viewerContext = createAdminViewerContext(session) const proofValue = normalizeManualProofValue(payload.proofValue) const skuCode = String(payload.skuCode || '').trim() const skuName = String(payload.skuName || '').trim() || skuCode const remark = String(payload.remark || '').trim() if (!proofValue) { throw createHttpError('请先输入唯一凭据', { statusCode: 400, errorCode: 'admin_manual_redeem_missing_proof', }) } if (!skuCode) { throw createHttpError('请先选择内部履约 SKU', { statusCode: 400, errorCode: 'admin_manual_redeem_missing_sku', }) } const existingOrder = await findOrderByPlatformOrderId({ provider: MANUAL_PROVIDER, platform: MANUAL_PLATFORM, shopId: '', platformOrderId: proofValue, }) if (existingOrder) { throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', { statusCode: 409, errorCode: 'admin_manual_redeem_duplicate_proof', }) } const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY) if (!profile) { throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', { statusCode: 500, errorCode: 'admin_manual_redeem_profile_missing', }) } const requirements = await listFulfillmentProfileRequirements(profile.id) const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code' const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code' const availableInventory = await findFirstAvailableInventoryItemBySkuCode( skuCode, credentialType, viewerContext.allowedInventoryGroupCodes, ) if (!availableInventory) { if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) { throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', { statusCode: 403, errorCode: 'admin_manual_redeem_inventory_group_unbound', }) } throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', { statusCode: 409, errorCode: 'admin_manual_redeem_inventory_unavailable', }) } const now = nowIso() const manualContext = { sourceType: MANUAL_SOURCE_TYPE, proofValue, remark, createdAt: now, createdBy: session ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), role: String(session.role || '').trim(), } : null, } let order try { order = await createOrder({ provider: MANUAL_PROVIDER, platform: MANUAL_PLATFORM, shopId: '', shopName: '人工兑换', platformOrderId: proofValue, orderStatus: 'manual_created', payStatus: 'paid', buyerId: '', buyerName: '', receiverContact: '', totalAmount: 0, currency: 'CNY', rawPayloadJson: JSON.stringify({ manualRedeem: manualContext, }), paidAt: now, createdAt: now, updatedAt: now, }) } catch (error) { if (String(error?.code || '') === '23505') { throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', { statusCode: 409, errorCode: 'admin_manual_redeem_duplicate_proof', }) } throw error } if (!order) { throw createHttpError('人工兑换订单创建失败', { statusCode: 500, errorCode: 'admin_manual_redeem_order_create_failed', }) } const orderItems = await replaceOrderItems(order.id, [ { skuCode, skuName, quantity: 1, specJson: JSON.stringify({ title: skuName, sourceType: MANUAL_SOURCE_TYPE, }), itemSnapshotJson: JSON.stringify({ manualRedeem: manualContext, skuCode, skuName, }), createdAt: now, updatedAt: now, }, ]) const orderItem = orderItems[0] if (!orderItem) { throw createHttpError('人工兑换订单商品创建失败', { statusCode: 500, errorCode: 'admin_manual_redeem_order_item_create_failed', }) } const createdTask = await createTask({ orderId: order.id, orderItemId: orderItem.id, unitIndex: 1, provider: MANUAL_PROVIDER, platform: MANUAL_PLATFORM, shopId: '', shopName: '人工兑换', platformOrderId: proofValue, taskNo: randomId('MR'), profileId: Number(profile.id), executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY), taskStatus: 'paid', inventoryStatus: 'pending', deliveryStatus: 'pending', resultCode: '', resultMessage: '', claimToken: '', claimExpiresAt: null, automationMode: 'manual', requiresClaim: true, userActionStatus: 'pending_claim', attemptCount: 0, lastError: '', contextJson: JSON.stringify({ profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY), profileName: String(profile.name || ''), skuCode, skuName, inventorySkuCode: skuCode, primaryRequirement: primaryRequirement ? { roleKey, credentialType, } : null, inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(), manualRedeem: manualContext, }), createdAt: now, updatedAt: now, }) if (!createdTask) { throw createHttpError('人工兑换任务创建失败', { statusCode: 500, errorCode: 'admin_manual_redeem_task_create_failed', }) } const reservedInventory = await reserveInventoryForTask({ skuCode, taskId: createdTask.id, credentialType, roleKey, inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, }) if (!reservedInventory) { await updateTask(createdTask.id, { task_status: 'waiting_inventory', inventory_status: 'pending', last_error: '库存不足,无法为人工兑换任务预占库存', updated_at: nowIso(), }) throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', { statusCode: 409, errorCode: 'admin_manual_redeem_inventory_race_lost', }) } const claimToken = await createTaskClaimToken(createdTask.id) await updateTask(createdTask.id, { task_status: 'link_generated', inventory_status: 'reserved', claim_token: claimToken.token, claim_expires_at: claimToken.expired_at, user_action_status: 'pending_claim', last_error: '', context_json: JSON.stringify({ ...parseTaskContext(createdTask), inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), }), updated_at: nowIso(), }) await createTaskEvent(createdTask.id, 'manual_redeem_created', { sourceType: MANUAL_SOURCE_TYPE, proofValue, skuCode, skuName, inventoryItemId: Number(reservedInventory.id || 0) || null, inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), createdBy: manualContext.createdBy, remark, }, nowIso()) return getAdminManualRedeemDetail(createdTask.id, session) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function getAdminManualRedeemDetail(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) const detail = await getClaimDetailForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ /** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */ /** @param {AdminViewerSessionInput | null} [session] */ export async function createAdminManualRedeemSession(taskId, payload = {}, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) const detail = await createClaimSessionForAdminTask(task.id, payload) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function getAdminManualRedeemSessionSummary(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) const detail = await getClaimSessionSummaryForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function reloadAdminManualRedeemSession(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) const detail = await reloadClaimSessionForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function closeAdminManualRedeemSession(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) const detail = await closeClaimSessionForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function closeAdminManualRedeemTask(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) await closeAdminTask(task.id) return getAdminManualRedeemDetail(task.id, session) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function confirmAdminManualRedeemRole(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) await confirmAdminTaskAssistedRole(task.id, session) return getAdminManualRedeemDetail(task.id, session) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function redeemAdminManualRedeemTask(taskId, session = null) { const task = await getRequiredManualRedeemTask(taskId, session) await redeemAdminTaskAssisted(task.id, session) return getAdminManualRedeemDetail(task.id, session) } async function getRequiredManualRedeemTask(taskId, session = null) { const task = await getTaskById(Number(taskId)) if (!task) { throw createHttpError('人工兑换任务不存在', { statusCode: 404, errorCode: 'admin_manual_redeem_task_not_found', }) } const taskContext = parseTaskContext(task) const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim() if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) { throw createHttpError('当前任务不是人工兑换任务', { statusCode: 409, errorCode: 'admin_manual_redeem_task_invalid', }) } const viewerContext = createAdminViewerContext(session) if (viewerContext.restrictInventoryGroups) { if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) { throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', { statusCode: 403, errorCode: 'admin_manual_redeem_inventory_group_unbound', }) } const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) { throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', { statusCode: 403, errorCode: 'admin_manual_redeem_inventory_group_denied', }) } } return task } function decorateManualRedeemDetail(task, detail) { const taskContext = parseTaskContext(task) const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object' ? taskContext.manualRedeem : {} return { ...detail, claimUrl: '', manualRequest: { sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE, proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(), remark: String(manualRedeem.remark || '').trim(), }, result: detail.result ? { ...detail.result, screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '', } : null, } } function normalizeManualProofValue(value) { return String(value || '').replace(/\s+/g, ' ').trim() }