diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js index 8440306d..a0e80c04 100644 --- a/apps/backend/src/routes/admin.js +++ b/apps/backend/src/routes/admin.js @@ -4,6 +4,7 @@ import authRouter from './admin/auth.js' import auditLogsRouter from './admin/audit-logs.js' import dashboardRouter from './admin/dashboard.js' import inventoryRouter from './admin/inventory.js' +import manualRedeemRouter from './admin/manual-redeem.js' import messageDeliveriesRouter from './admin/message-deliveries.js' import ordersRouter from './admin/orders.js' import platformConfigRouter from './admin/platform-config.js' @@ -23,6 +24,7 @@ router.use(auditLogsRouter) router.use(platformConfigRouter) router.use(ordersRouter) router.use(tasksRouter) +router.use(manualRedeemRouter) router.use(inventoryRouter) router.use(messageDeliveriesRouter) router.use(webhookEventsRouter) diff --git a/apps/backend/src/routes/admin/manual-redeem.js b/apps/backend/src/routes/admin/manual-redeem.js new file mode 100644 index 00000000..659ac736 --- /dev/null +++ b/apps/backend/src/routes/admin/manual-redeem.js @@ -0,0 +1,183 @@ +// @ts-check + +import { Router } from 'express' + +import { + closeAdminManualRedeemSession, + closeAdminManualRedeemTask, + confirmAdminManualRedeemRole, + createAdminManualRedeemSession, + createAdminManualRedeemTask, + getAdminManualRedeemDetail, + getAdminManualRedeemSessionSummary, + reloadAdminManualRedeemSession, + redeemAdminManualRedeemTask, +} from '../../services/admin/admin-manual-redeem-service.js' +import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +/** @typedef {import('../../types/admin-route-inputs.js').AdminInventorySkuSuggestionRouteQuery} AdminInventorySkuSuggestionRouteQuery */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminManualRedeemCreateRouteBody} AdminManualRedeemCreateRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminManualRedeemRouteParams} AdminManualRedeemRouteParams */ + +const router = Router() + +router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support'])) + +router.get('/manual-redeem/sku-suggestions', createJsonHandler( + (req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)), + { + successMessage: 'ok', + errorMessage: '读取人工兑换 SKU 建议失败', + scope: '[admin/manual-redeem/sku-suggestions]', + }, +)) + +router.post('/manual-redeem', createJsonHandler( + (req) => createAdminManualRedeemTask( + /** @type {AdminManualRedeemCreateRouteBody} */ (req.body), + req.adminSession || null, + ), + { + successMessage: '人工兑换任务已创建', + errorMessage: '创建人工兑换任务失败', + scope: '[admin/manual-redeem]', + audit: (req, data) => { + const result = /** @type {{ task?: { taskId?: number, taskNo?: string }, manualRequest?: { proofValue?: string }, order?: { platformOrderId?: string }, orderItem?: { skuCode?: string } }} */ (data) + return { + action: 'manual_redeem_created', + targetType: 'task', + targetId: String(result.task?.taskId || ''), + data: { + taskId: result.task?.taskId, + taskNo: result.task?.taskNo, + proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '', + skuCode: result.orderItem?.skuCode || '', + }, + } + }, + }, +)) + +router.get('/manual-redeem/:taskId', createJsonHandler( + (req) => getAdminManualRedeemDetail((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + { + successMessage: 'ok', + errorMessage: '读取人工兑换详情失败', + scope: '[admin/manual-redeem/:taskId]', + }, +)) + +router.post('/manual-redeem/:taskId/session', createJsonHandler( + (req) => createAdminManualRedeemSession( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + /** @type {{ loginType?: string, forceRecreate?: boolean }} */ (req.body), + ), + { + successMessage: '人工兑换登录会话已创建', + errorMessage: '创建人工兑换登录会话失败', + scope: '[admin/manual-redeem/:taskId/session]', + }, +)) + +router.get('/manual-redeem/:taskId/session/summary', createJsonHandler( + (req) => getAdminManualRedeemSessionSummary((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + { + successMessage: 'ok', + errorMessage: '读取人工兑换会话摘要失败', + scope: '[admin/manual-redeem/:taskId/session/summary]', + }, +)) + +router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler( + (req) => reloadAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + { + successMessage: '人工兑换会话已刷新', + errorMessage: '刷新人工兑换会话失败', + scope: '[admin/manual-redeem/:taskId/session/refresh]', + }, +)) + +router.delete('/manual-redeem/:taskId/session', createJsonHandler( + (req) => closeAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + { + successMessage: '人工兑换会话已关闭', + errorMessage: '关闭人工兑换会话失败', + scope: '[admin/manual-redeem/:taskId/session]', + }, +)) + +router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler( + (req) => confirmAdminManualRedeemRole( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), + { + successMessage: '角色已确认', + errorMessage: '确认人工兑换角色失败', + scope: '[admin/manual-redeem/:taskId/confirm-role]', + audit: (req, data) => { + const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string } }} */ (data) + return { + action: 'manual_redeem_role_confirmed', + targetType: 'task', + targetId: String(result.task?.taskId || ''), + data: { + taskId: result.task?.taskId, + taskNo: result.task?.taskNo, + status: result.task?.status, + }, + } + }, + }, +)) + +router.post('/manual-redeem/:taskId/redeem', createJsonHandler( + (req) => redeemAdminManualRedeemTask( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), + { + successMessage: '人工兑换任务已启动', + errorMessage: '执行人工兑换失败', + scope: '[admin/manual-redeem/:taskId/redeem]', + audit: (req, data) => { + const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string, deliveryStatus?: string } }} */ (data) + return { + action: 'manual_redeem_started', + targetType: 'task', + targetId: String(result.task?.taskId || ''), + data: { + taskId: result.task?.taskId, + taskNo: result.task?.taskNo, + status: result.task?.status, + deliveryStatus: result.task?.deliveryStatus, + }, + } + }, + }, +)) + +router.post('/manual-redeem/:taskId/close', createJsonHandler( + (req) => closeAdminManualRedeemTask((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + { + successMessage: '人工兑换任务已关闭', + errorMessage: '关闭人工兑换任务失败', + scope: '[admin/manual-redeem/:taskId/close]', + audit: (req, data) => { + const result = /** @type {{ task?: { taskId?: number, taskNo?: string, status?: string } }} */ (data) + return { + action: 'manual_redeem_closed', + targetType: 'task', + targetId: String(result.task?.taskId || ''), + data: { + taskId: result.task?.taskId, + taskNo: result.task?.taskNo, + status: result.task?.status, + }, + } + }, + }, +)) + +export default router diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.js b/apps/backend/src/services/admin/admin-manual-redeem-service.js new file mode 100644 index 00000000..87b428ce --- /dev/null +++ b/apps/backend/src/services/admin/admin-manual-redeem-service.js @@ -0,0 +1,376 @@ +// @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 { 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 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) + + if (!availableInventory) { + 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, + 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, + }) + + 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: '', + updated_at: nowIso(), + }) + + await createTaskEvent(createdTask.id, 'manual_redeem_created', { + sourceType: MANUAL_SOURCE_TYPE, + proofValue, + skuCode, + skuName, + inventoryItemId: Number(reservedInventory.id || 0) || null, + createdBy: manualContext.createdBy, + remark, + }, nowIso()) + + return getAdminManualRedeemDetail(createdTask.id) +} + +/** @param {AdminEntityIdInput} taskId */ +export async function getAdminManualRedeemDetail(taskId) { + const task = await getRequiredManualRedeemTask(taskId) + const detail = await getClaimDetailForAdminTask(task.id) + return decorateManualRedeemDetail(task, detail) +} + +/** @param {AdminEntityIdInput} taskId */ +/** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */ +export async function createAdminManualRedeemSession(taskId, payload = {}) { + const task = await getRequiredManualRedeemTask(taskId) + const detail = await createClaimSessionForAdminTask(task.id, payload) + return decorateManualRedeemDetail(task, detail) +} + +/** @param {AdminEntityIdInput} taskId */ +export async function getAdminManualRedeemSessionSummary(taskId) { + const task = await getRequiredManualRedeemTask(taskId) + const detail = await getClaimSessionSummaryForAdminTask(task.id) + return decorateManualRedeemDetail(task, detail) +} + +/** @param {AdminEntityIdInput} taskId */ +export async function reloadAdminManualRedeemSession(taskId) { + const task = await getRequiredManualRedeemTask(taskId) + const detail = await reloadClaimSessionForAdminTask(task.id) + return decorateManualRedeemDetail(task, detail) +} + +/** @param {AdminEntityIdInput} taskId */ +export async function closeAdminManualRedeemSession(taskId) { + const task = await getRequiredManualRedeemTask(taskId) + const detail = await closeClaimSessionForAdminTask(task.id) + return decorateManualRedeemDetail(task, detail) +} + +/** @param {AdminEntityIdInput} taskId */ +export async function closeAdminManualRedeemTask(taskId) { + const task = await getRequiredManualRedeemTask(taskId) + await closeAdminTask(task.id) + return getAdminManualRedeemDetail(task.id) +} + +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function confirmAdminManualRedeemRole(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId) + await confirmAdminTaskAssistedRole(task.id, session) + return getAdminManualRedeemDetail(task.id) +} + +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function redeemAdminManualRedeemTask(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId) + await redeemAdminTaskAssisted(task.id, session) + return getAdminManualRedeemDetail(task.id) +} + +async function getRequiredManualRedeemTask(taskId) { + 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', + }) + } + + 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() +} diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index d6b8d1cd..77a1e1a5 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -44,6 +44,20 @@ export async function getClaimDetail(token, { includeQrImage = true } = {}) { }) } +export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) { + const context = await getClaimContextByTaskId(taskId) + const { task, session } = await loadTaskSession(context.task, { includeQrImage }) + const syncedTask = session ? await syncTaskWithSession(task, session) : task + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + export async function createClaimSession(token, payload = {}) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) @@ -106,6 +120,68 @@ export async function createClaimSession(token, payload = {}) { }) } +export async function createClaimSessionForAdminTask(taskId, payload = {}) { + const context = await getClaimContextByTaskId(taskId) + assertTaskCanProceed(context.task) + const requestedLoginType = normalizeClaimLoginType(payload.loginType) + const forceRecreate = Boolean(payload.forceRecreate) + let task = context.task + + if (task.browser_session_id) { + const existing = await loadTaskSession(task, { includeQrImage: true }) + task = existing.task + + if (existing.session) { + const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type) + + if (!forceRecreate && existingLoginType === requestedLoginType) { + const syncedTask = await syncTaskWithSession(task, existing.session) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session: existing.session, + }) + } + + try { + await closeTencentBrowserSession(existing.session.sessionId) + } catch (error) { + if (!isRecoverableSessionError(error)) { + throw error + } + } + + task = await clearTaskSession(task, { + lastError: '', + }) + } + } + + const session = await createTencentBrowserSession({ + loginType: requestedLoginType, + }) + const updatedTask = await updateTask(task.id, { + task_status: 'claimed', + browser_session_id: session.sessionId, + login_type: session.loginType, + user_action_status: 'claimed', + claimed_at: task.claimed_at || nowIso(), + updated_at: nowIso(), + last_error: '', + }) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: updatedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + export async function getClaimSessionSummary(token) { const context = await getClaimContext(token) const { task, session } = await loadTaskSession(context.task, { @@ -133,6 +209,33 @@ export async function getClaimSessionSummary(token) { }) } +export async function getClaimSessionSummaryForAdminTask(taskId) { + const context = await getClaimContextByTaskId(taskId) + const { task, session } = await loadTaskSession(context.task, { + includeQrImage: false, + }) + + if (!session) { + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task, + order: context.order, + orderItem: context.orderItem, + session: null, + }) + } + + const syncedTask = await syncTaskWithSession(task, session) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + export async function reloadClaimSession(token) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) @@ -163,6 +266,36 @@ export async function reloadClaimSession(token) { }) } +export async function reloadClaimSessionForAdminTask(taskId) { + const context = await getClaimContextByTaskId(taskId) + assertTaskCanProceed(context.task) + + const active = await loadTaskSession(context.task, { + includeQrImage: true, + }) + + if (!active.session) { + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: active.task, + order: context.order, + orderItem: context.orderItem, + session: null, + }) + } + + const session = await reloadTencentBrowserSession(active.session.sessionId) + const syncedTask = await syncTaskWithSession(active.task, session) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + export async function closeClaimSession(token) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) @@ -199,6 +332,42 @@ export async function closeClaimSession(token) { }) } +export async function closeClaimSessionForAdminTask(taskId) { + const context = await getClaimContextByTaskId(taskId) + assertTaskCanProceed(context.task) + let task = context.task + + if (!task.browser_session_id) { + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task, + order: context.order, + orderItem: context.orderItem, + session: null, + }) + } + + try { + await closeTencentBrowserSession(task.browser_session_id) + } catch (error) { + if (!isRecoverableSessionError(error)) { + throw error + } + } + + task = await clearTaskSession(task, { + lastError: '', + }) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task, + order: context.order, + orderItem: context.orderItem, + session: null, + }) +} + export async function confirmClaimRole(token) { const context = await getClaimContext(token) assertPublicClaimActionAllowed(context.task, 'confirm') diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index 77320725..e7494e47 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -58,6 +58,10 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateRouteBody + */ + /** * @typedef {{ inventoryItemId?: string }} AdminInventoryRouteParams */ @@ -70,6 +74,10 @@ export {} * @typedef {{ taskId?: string, bindingId?: string }} AdminTaskRouteParams */ +/** + * @typedef {{ taskId?: string }} AdminManualRedeemRouteParams + */ + /** * @typedef {{ eventId?: string }} AdminWebhookEventRouteParams */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 1ef4622e..9e8a7f7c 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -107,3 +107,12 @@ export {} * deliveredCredential?: string * }} AdminTaskManualDispatchInput */ + +/** + * @typedef {{ + * proofValue?: string + * skuCode?: string + * skuName?: string + * remark?: string + * }} AdminManualRedeemCreateInput + */ diff --git a/apps/backend/src/types/repository-rows.js b/apps/backend/src/types/repository-rows.js index dbedf629..3176906e 100644 --- a/apps/backend/src/types/repository-rows.js +++ b/apps/backend/src/types/repository-rows.js @@ -63,8 +63,10 @@ export {} * role_name: string * role_id: string * claim_token: string + * claim_expires_at?: string | null * primary_claim_token: string * primary_claim_token_id: number | null + * primary_claim_expires_at?: string | null * primary_inventory_item_id: number | null * inventory_display_value: string * primary_inventory_display_value: string diff --git a/apps/frontend/src/components/tencent/TencentAuthCard.vue b/apps/frontend/src/components/tencent/TencentAuthCard.vue index bb6677b7..ee707871 100644 --- a/apps/frontend/src/components/tencent/TencentAuthCard.vue +++ b/apps/frontend/src/components/tencent/TencentAuthCard.vue @@ -13,6 +13,8 @@ const props = defineProps<{ loginTabs: LoginTab[] loginType: TencentLoginType loginTypeLabel: string + qrCopyButtonLabel?: string + qrCopyEnabled?: boolean qrFigureStyle: { width: string; maxWidth: string } qrImage: string scanInstruction: string @@ -24,6 +26,7 @@ const props = defineProps<{ const emit = defineEmits<{ closeSession: [] + copyQr: [] createSession: [loginType: TencentLoginType] openQrPreview: [] qrImageLoad: [event: Event] @@ -53,6 +56,7 @@ const emit = defineEmits<{ :style="props.qrFigureStyle" type="button" @click="emit('openQrPreview')" + @dblclick.prevent.stop="emit('copyQr')" > {{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}

+

+ 双击二维码可直接复制到剪贴板,也可以使用下方按钮复制。 +

{{ props.sessionNotice || @@ -118,6 +128,17 @@ const emit = defineEmits<{ {{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }} + + {{ props.qrCopyButtonLabel || '复制二维码' }} + +

([]) + const detail = ref(null) + const detailLoading = ref(false) + const createLoading = ref(false) + const sessionLoading = ref(false) + const roleConfirmLoading = ref(false) + const redeemLoading = ref(false) + const closeLoading = ref(false) + const pollWarningMessage = ref('') + const qrImageNaturalWidth = ref(0) + const screenshotBlob = ref(null) + const screenshotUrl = ref('') + const screenshotLoading = ref(false) + const loginType = ref(DEFAULT_LOGIN_TYPE) + + let pollTimer: ReturnType | null = null + let pollToken = 0 + let pollFailureCount = 0 + + const session = computed(() => detail.value?.session || null) + const task = computed(() => detail.value?.task || null) + const order = computed(() => detail.value?.order || null) + const orderItem = computed(() => detail.value?.orderItem || null) + const result = computed(() => detail.value?.result || null) + const manualRequest = computed(() => detail.value?.manualRequest || null) + const activityInfo = computed(() => session.value?.activityInfo || null) + const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) + const roleConfirmed = computed(() => + Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)), + ) + const hasSession = computed(() => Boolean(session.value?.sessionId)) + const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) + const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '') + const qrImage = computed(() => + session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', + ) + const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0) + const reviewReady = computed(() => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed') + const resultReady = computed(() => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed') + const loginTabs = [ + { value: 'qq' as const, label: 'QQ账号登录' }, + { value: 'wx' as const, label: '微信账号登录' }, + ] + + const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status)) + const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`) + const scanInstruction = computed(() => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`) + const roleFacts = computed(() => [ + { + label: '登录昵称', + value: activityInfo.value?.nickname || '等待客户登录', + }, + { + label: '当前角色', + value: activityInfo.value?.role?.roleName || '后端浏览器同步中', + accent: true, + }, + { + label: '角色 ID', + value: activityInfo.value?.role?.roleId || '未识别', + }, + { + label: '所在大区', + value: activityInfo.value?.role?.area || '未识别', + }, + ]) + const resultFacts = computed(() => [ + { + label: '唯一凭据', + value: manualRequest.value?.proofValue || order.value?.platformOrderId || '-', + }, + { + label: '商品', + value: orderItem.value?.skuName || '-', + }, + { + label: '业务返回码', + value: result.value?.resultCode || '-', + }, + { + label: '业务消息', + value: result.value?.resultMessage || '尚未兑换', + }, + ]) + const canConfirmRole = computed(() => + Boolean( + task.value + && task.value.status === 'claimed' + && hasSession.value + && roleReady.value + && !roleConfirmLoading.value, + ), + ) + const canRedeem = computed(() => + Boolean( + task.value + && ['role_confirmed', 'redeeming'].includes(task.value.status) + && !redeemLoading.value, + ), + ) + const redeemBlockedReason = computed(() => { + if (!task.value) { + return '请先创建人工兑换任务并预占库存' + } + + if (!hasSession.value) { + return `请先生成${loginTypeLabel.value}二维码并等待客户登录` + } + + if (redeemLoading.value) { + return '兑换任务正在执行中' + } + + if (!roleReady.value) { + return sessionNotice.value || '客户登录后,系统会自动同步角色和大区信息' + } + + if (!roleConfirmed.value) { + return '请先让客户确认角色截图,再点击确认当前角色' + } + + return '' + }) + const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换')) + const screenshotEmptyTitle = computed(() => (task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图')) + const screenshotEmptyMessage = computed(() => + task.value?.status === 'redeemed' + ? '本次兑换可能未生成最终截图,或截图仍在同步中。' + : '客户登录并识别到角色后,这里会展示给客服复核的角色截图。' + ) + const canCreateTask = computed(() => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value) + const hasActiveTask = computed(() => Boolean(task.value?.taskId)) + + watch(qrImage, () => { + qrImageNaturalWidth.value = 0 + }) + + watch( + () => currentTaskId.value, + (taskId, previousTaskId) => { + if (!taskId || taskId === previousTaskId) { + return + } + + void loadDetail(taskId) + }, + { immediate: true }, + ) + + onMounted(() => { + void loadSkuSuggestions() + }) + + onBeforeUnmount(() => { + resetPolling() + clearScreenshotPreview() + }) + + async function loadSkuSuggestions(keyword = '') { + try { + const response = await fetchAdminManualRedeemSkuSuggestions({ + credentialType: 'tencent_code', + keyword: keyword.trim(), + }) + suggestions.value = response.data.items + } catch (error) { + notifyTencentActionError(error, '读取人工兑换 SKU 建议失败') + } + } + + async function loadDetail(taskId = currentTaskId.value) { + if (!taskId) { + detail.value = null + clearScreenshotPreview() + return false + } + + detailLoading.value = true + + try { + const response = await fetchAdminManualRedeemDetail(taskId) + detail.value = response.data + loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) + await loadScreenshotPreview(response.data) + restartPollingIfNeeded() + return true + } catch (error) { + notifyTencentActionError(error, '读取人工兑换详情失败') + return false + } finally { + detailLoading.value = false + } + } + + async function createTaskNow() { + if (!canCreateTask.value) { + return + } + + createLoading.value = true + + try { + const response = await createAdminManualRedeemTask({ + proofValue: form.proofValue.trim(), + skuCode: form.skuCode.trim(), + skuName: resolveSkuName(form.skuCode), + remark: form.remark.trim(), + }) + + detail.value = response.data + loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) + await loadScreenshotPreview(response.data) + await router.replace({ + path: route.path, + query: { + taskId: String(response.data.task.taskId), + }, + }) + showSuccess(response.msg || '人工兑换任务已创建') + } catch (error) { + notifyTencentActionError(error, '创建人工兑换任务失败') + } finally { + createLoading.value = false + } + } + + async function createSessionFlow(nextLoginType = loginType.value) { + if (!task.value?.taskId) { + return + } + + sessionLoading.value = true + resetPolling() + resetPollingWarning() + + try { + loginType.value = nextLoginType + const response = await createAdminManualRedeemSession(task.value.taskId, { + loginType: nextLoginType, + forceRecreate: hasSession.value, + }) + + detail.value = response.data + loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType) + await loadScreenshotPreview(response.data) + startPolling() + } catch (error) { + notifyTencentActionError(error, '创建人工兑换登录会话失败') + } finally { + sessionLoading.value = false + } + } + + async function refreshSessionFlow() { + if (!task.value?.taskId) { + return false + } + + sessionLoading.value = true + resetPolling() + resetPollingWarning() + + try { + const response = await refreshAdminManualRedeemSession(task.value.taskId) + detail.value = response.data + loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) + await loadScreenshotPreview(response.data) + restartPollingIfNeeded(response.data) + return true + } catch (error) { + notifyTencentActionError(error, '刷新人工兑换会话失败') + await refreshSessionSummary({ silent: true }) + restartPollingIfNeeded() + return false + } finally { + sessionLoading.value = false + } + } + + async function closeSessionFlow({ silent = false } = {}) { + if (!task.value?.taskId) { + return false + } + + if (!silent) { + sessionLoading.value = true + } + + resetPolling() + resetPollingWarning() + + try { + const response = await removeAdminManualRedeemSession(task.value.taskId) + detail.value = response.data + loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) + await loadScreenshotPreview(response.data) + + if (!silent) { + showSuccess(response.msg || '人工兑换会话已关闭') + } + + return true + } catch (error) { + if (!silent) { + notifyTencentActionError(error, '关闭人工兑换会话失败') + } + return false + } finally { + if (!silent) { + sessionLoading.value = false + } + } + } + + async function refreshSessionSummary({ silent = false } = {}) { + if (!task.value?.taskId) { + return false + } + + if (!silent) { + sessionLoading.value = true + resetPollingWarning() + } + + try { + const currentSession = detail.value?.session || null + const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId) + detail.value = mergeDetail(detail.value, response.data) + loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) + + if (shouldRefreshQrImage(currentSession, response.data.session)) { + const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId) + detail.value = mergeDetail(detail.value, fullResponse.data) + loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value) + } + + await loadScreenshotPreview(detail.value) + + if (silent) { + resetPollingWarning() + } + + return true + } catch (error) { + if (silent) { + handleSilentPollingError(error) + } else { + notifyTencentActionError(error, '刷新人工兑换会话状态失败') + } + + return false + } finally { + if (!silent) { + sessionLoading.value = false + } + } + } + + async function confirmRoleNow() { + if (!task.value?.taskId || !canConfirmRole.value) { + return + } + + roleConfirmLoading.value = true + + try { + const response = await confirmAdminManualRedeemRole(task.value.taskId) + detail.value = response.data + await loadScreenshotPreview(response.data) + showSuccess(response.msg || '角色已确认') + restartPollingIfNeeded(response.data) + } catch (error) { + notifyTencentActionError(error, '确认角色失败') + await refreshSessionSummary({ silent: true }) + } finally { + roleConfirmLoading.value = false + } + } + + async function redeemNow() { + if (!task.value?.taskId || !canRedeem.value) { + return + } + + redeemLoading.value = true + resetPolling() + resetPollingWarning() + + try { + const response = await redeemAdminManualRedeem(task.value.taskId) + detail.value = response.data + await loadScreenshotPreview(response.data) + showSuccess(response.msg || '兑换完成') + restartPollingIfNeeded(response.data) + } catch (error) { + notifyTencentActionError(error, '人工兑换失败') + await refreshSessionSummary({ silent: true }) + restartPollingIfNeeded() + } finally { + redeemLoading.value = false + } + } + + async function closeTaskNow() { + if (!task.value?.taskId) { + return + } + + try { + await showConfirm( + `确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`, + '关闭人工兑换', + ) + } catch { + return + } + + closeLoading.value = true + + try { + const response = await closeAdminManualRedeemTask(task.value.taskId) + detail.value = response.data + await loadScreenshotPreview(response.data) + resetPolling() + showSuccess(response.msg || '人工兑换任务已关闭') + } catch (error) { + notifyTencentActionError(error, '关闭人工兑换任务失败') + } finally { + closeLoading.value = false + } + } + + async function switchLoginType(nextLoginType: TencentLoginType) { + if (nextLoginType === loginType.value) { + return + } + + if (hasSession.value) { + await createSessionFlow(nextLoginType) + return + } + + loginType.value = nextLoginType + } + + function resetCurrentTask() { + resetPolling() + detail.value = null + clearScreenshotPreview() + void router.replace({ + path: route.path, + query: {}, + }) + } + + function handleQrImageLoad(event: Event) { + const target = event.target + + if (!(target instanceof HTMLImageElement)) { + return + } + + qrImageNaturalWidth.value = target.naturalWidth || 0 + } + + async function copyQrImage() { + if (!qrImage.value) { + return false + } + + try { + const response = await fetch(qrImage.value) + const blob = await response.blob() + await writeImageBlobToClipboard(blob, '复制二维码失败') + showSuccess('二维码已复制到剪贴板') + return true + } catch (error) { + notifyTencentActionError(error, '复制二维码失败') + return false + } + } + + async function copyScreenshot() { + if (!screenshotBlob.value) { + return false + } + + try { + await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败') + showSuccess('截图已复制到剪贴板') + return true + } catch (error) { + notifyTencentActionError(error, '复制截图失败') + return false + } + } + + function downloadScreenshot() { + if (!screenshotBlob.value || !screenshotUrl.value) { + return false + } + + const anchor = document.createElement('a') + anchor.href = screenshotUrl.value + anchor.download = task.value?.status === 'redeemed' + ? `manual-redeem-result-${task.value?.taskNo || 'task'}.png` + : `manual-redeem-review-${task.value?.taskNo || 'task'}.png` + anchor.click() + return true + } + + async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) { + if ( + typeof navigator === 'undefined' + || !navigator.clipboard + || typeof window === 'undefined' + || !('ClipboardItem' in window) + ) { + throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载') + } + + try { + const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem + await navigator.clipboard.write([ + new ClipboardItemCtor({ + [blob.type || 'image/png']: blob, + }), + ]) + } catch (error) { + throw error instanceof Error ? error : new Error(fallbackMessage) + } + } + + async function loadScreenshotPreview(nextDetail = detail.value) { + const taskId = Number(nextDetail?.task?.taskId || 0) + const shouldFetch = Boolean(taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady)) + + if (!shouldFetch) { + clearScreenshotPreview() + return + } + + screenshotLoading.value = true + + try { + const blob = await fetchAdminTaskScreenshot(taskId) + clearScreenshotPreview() + screenshotBlob.value = blob + screenshotUrl.value = URL.createObjectURL(blob) + } catch { + clearScreenshotPreview() + } finally { + screenshotLoading.value = false + } + } + + function clearScreenshotPreview() { + screenshotBlob.value = null + + if (screenshotUrl.value) { + URL.revokeObjectURL(screenshotUrl.value) + screenshotUrl.value = '' + } + } + + function resolveSkuName(skuCode: string) { + const normalized = String(skuCode || '').trim() + const matched = suggestions.value.find((item) => String(item.skuCode || '').trim() === normalized) + return matched?.skuCode || normalized + } + + function startPolling() { + const tokenId = ++pollToken + + const loop = async () => { + if (tokenId !== pollToken || !task.value?.taskId) { + return + } + + await refreshSessionSummary({ silent: true }) + + if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) { + return + } + + pollTimer = setTimeout(() => { + void loop() + }, POLL_INTERVAL_MS) + } + + pollTimer = setTimeout(() => { + void loop() + }, POLL_INTERVAL_MS) + } + + function restartPollingIfNeeded(nextDetail = detail.value) { + resetPolling() + + if (shouldKeepPolling(nextDetail)) { + startPolling() + } + } + + function resetPolling() { + pollToken += 1 + + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + } + + function resetPollingWarning() { + pollFailureCount = 0 + pollWarningMessage.value = '' + } + + function handleSilentPollingError(error: unknown) { + console.error(error) + pollFailureCount += 1 + + if (pollFailureCount < POLL_FAILURE_LIMIT) { + return + } + + pollWarningMessage.value = '人工兑换状态刷新失败,已暂停自动轮询,请手动刷新。' + resetPolling() + } + + const qrDisplayWidth = computed(() => { + const naturalWidth = qrImageNaturalWidth.value + + if (!naturalWidth) { + return 220 + } + + if (naturalWidth < 160) { + return Math.min(naturalWidth * 2, 220) + } + + return Math.min(naturalWidth, 240) + }) + + const qrFigureStyle = computed(() => ({ + width: `${qrDisplayWidth.value}px`, + maxWidth: '100%', + })) + + const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`) + + return { + form, + suggestions, + detail, + detailLoading, + createLoading, + sessionLoading, + roleConfirmLoading, + redeemLoading, + closeLoading, + loginType, + loginTypeLabel, + loginTabs, + session, + task, + order, + orderItem, + manualRequest, + activityInfo, + roleReady, + roleConfirmed, + hasSession, + qrImage, + qrFigureStyle, + qrPreviewWidth, + statusLabel, + sessionNotice, + roleFacts, + resultFacts, + canConfirmRole, + canRedeem, + redeemBlockedReason, + redeemButtonLabel, + initButtonLabel, + scanInstruction, + screenshotEmptyTitle, + screenshotEmptyMessage, + screenshotUrl, + screenshotLoading, + reviewReady, + resultReady, + canCreateTask, + hasActiveTask, + createTaskNow, + loadSkuSuggestions, + createSessionFlow, + refreshSessionFlow, + closeSessionFlow, + switchLoginType, + confirmRoleNow, + redeemNow, + closeTaskNow, + resetCurrentTask, + handleQrImageLoad, + copyQrImage, + copyScreenshot, + downloadScreenshot, + } +} + +function syncLoginTypeFromDetail(detail: AdminManualRedeemDetail, fallback: TencentLoginType = DEFAULT_LOGIN_TYPE) { + const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim() + if (nextLoginType === 'wx') { + return 'wx' + } + + if (nextLoginType === 'qq') { + return 'qq' + } + + return fallback +} + +function shouldKeepPolling(detail: AdminManualRedeemDetail | null) { + if (!detail?.task?.taskId || !detail.session?.sessionId) { + return false + } + + return ACTIVE_TASK_STATUSES.has(String(detail.task.status || '').trim()) +} + +function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) { + switch (taskStatus) { + case 'link_generated': + return '待生成二维码' + case 'claimed': + if (sessionStatus === 'scanned') { + return '客户待确认' + } + if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') { + return '已登录待复核' + } + return '等待客户登录' + case 'role_confirmed': + return '角色已确认' + case 'redeeming': + return '正在兑换' + case 'redeemed': + return '兑换完成' + case 'waiting_inventory': + return '等待库存' + case 'retry_pending': + return '等待重试' + case 'closed': + return '任务已关闭' + default: + return sessionStatus || taskStatus || '等待中' + } +} + +function mergeDetail(current: AdminManualRedeemDetail | null, next: AdminManualRedeemDetail) { + if (next.session === null) { + return { + ...next, + session: null, + } + } + + if (!current?.session) { + return next + } + + return { + ...next, + session: { + ...current.session, + ...next.session, + qrImageBase64: + typeof next.session.qrImageBase64 === 'string' + ? next.session.qrImageBase64 + : current.session.qrImageBase64, + activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null, + review: next.session.review ?? current.session.review ?? null, + redeem: next.session.redeem ?? current.session.redeem ?? null, + artifacts: next.session.artifacts || current.session.artifacts, + }, + } +} + +function shouldRefreshQrImage(currentSession: AdminManualRedeemDetail['session'], nextSession: AdminManualRedeemDetail['session']) { + if (!currentSession || !nextSession || !nextSession.artifacts?.hasQrImage) { + return false + } + + if (!currentSession.qrImageBase64) { + return true + } + + return Boolean(nextSession.qrUpdatedAt && nextSession.qrUpdatedAt !== currentSession.qrUpdatedAt) +} diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 4cddfa1a..9851df80 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -57,6 +57,10 @@ const router = createRouter({ path: 'tasks', component: () => import('@/views/admin/AdminTasksView.vue'), }, + { + path: 'manual-redeem', + component: () => import('@/views/admin/AdminManualRedeemView.vue'), + }, { path: 'tasks/:taskId', component: () => import('@/views/admin/AdminTaskDetailView.vue'), diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index 53ca5949..e5e5bf55 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -1,4 +1,4 @@ -import { apiGet, apiGetBlob, apiPost } from '@/lib/http' +import { apiDelete, apiGet, apiGetBlob, apiPost } from '@/lib/http' import type { AdminAgisoMessagingDefaults, AdminAgisoObservedShopItem, @@ -13,6 +13,7 @@ import type { AdminTaskActionResponse, AdminDashboardSummary, AdminLoginResponse, + AdminManualRedeemDetail, AdminOrderDetail, AdminOrderListItem, AdminPagination, @@ -184,6 +185,54 @@ export function fetchAdminInventorySkuSuggestions(params?: Record('/api/v1/admin/inventory/sku-suggestions', params) } +export function fetchAdminManualRedeemSkuSuggestions(params?: Record) { + return apiGet<{ items: AdminInventorySkuSuggestion[] }>('/api/v1/admin/manual-redeem/sku-suggestions', params) +} + +export function createAdminManualRedeemTask(payload: { + proofValue: string + skuCode: string + skuName?: string + remark?: string +}) { + return apiPost('/api/v1/admin/manual-redeem', payload) +} + +export function fetchAdminManualRedeemDetail(taskId: number | string) { + return apiGet(`/api/v1/admin/manual-redeem/${taskId}`) +} + +export function createAdminManualRedeemSession( + taskId: number | string, + payload: { loginType: string; forceRecreate?: boolean }, +) { + return apiPost(`/api/v1/admin/manual-redeem/${taskId}/session`, payload) +} + +export function fetchAdminManualRedeemSessionSummary(taskId: number | string) { + return apiGet(`/api/v1/admin/manual-redeem/${taskId}/session/summary`) +} + +export function refreshAdminManualRedeemSession(taskId: number | string) { + return apiPost(`/api/v1/admin/manual-redeem/${taskId}/session/refresh`, {}) +} + +export function removeAdminManualRedeemSession(taskId: number | string) { + return apiDelete(`/api/v1/admin/manual-redeem/${taskId}/session`) +} + +export function confirmAdminManualRedeemRole(taskId: number | string) { + return apiPost(`/api/v1/admin/manual-redeem/${taskId}/confirm-role`, {}) +} + +export function redeemAdminManualRedeem(taskId: number | string) { + return apiPost(`/api/v1/admin/manual-redeem/${taskId}/redeem`, {}) +} + +export function closeAdminManualRedeemTask(taskId: number | string) { + return apiPost(`/api/v1/admin/manual-redeem/${taskId}/close`, {}) +} + export function fetchAdminMessageDeliveries(params?: Record) { return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>( '/api/v1/admin/message-deliveries', diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 5dc85368..95721605 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -1,3 +1,5 @@ +import type { ClaimDetailData } from './claim' + export interface AdminPagination { page: number pageSize: number @@ -277,6 +279,14 @@ export interface AdminTaskActionResponse { token?: string } +export interface AdminManualRedeemDetail extends ClaimDetailData { + manualRequest: { + sourceType: string + proofValue: string + remark: string + } +} + export interface AdminOrderDetail { order: { orderId: number diff --git a/apps/frontend/src/types/tencent/activity.ts b/apps/frontend/src/types/tencent/activity.ts index 3de13bf3..dd66aac3 100644 --- a/apps/frontend/src/types/tencent/activity.ts +++ b/apps/frontend/src/types/tencent/activity.ts @@ -3,6 +3,12 @@ export interface TencentBrowserSessionArtifacts { hasScreenshot: boolean } +export interface TencentBrowserReviewInfo { + capturedAt: string + roleId: string + roleName: string +} + export interface TencentBrowserRoleInfo { ready: boolean roleName: string diff --git a/apps/frontend/src/types/tencent/session.ts b/apps/frontend/src/types/tencent/session.ts index f4a3b6ee..c88cc83c 100644 --- a/apps/frontend/src/types/tencent/session.ts +++ b/apps/frontend/src/types/tencent/session.ts @@ -1,4 +1,8 @@ -import type { TencentBrowserActivityInfo, TencentBrowserSessionArtifacts } from './activity' +import type { + TencentBrowserActivityInfo, + TencentBrowserReviewInfo, + TencentBrowserSessionArtifacts, +} from './activity' import type { TencentBrowserRedeemResult } from './redeem' export type TencentLoginType = 'qq' | 'wx' @@ -28,6 +32,7 @@ export interface TencentBrowserSessionData { qrUpdatedAt: string credentialReady: boolean activityInfo?: TencentBrowserActivityInfo | null + review?: TencentBrowserReviewInfo | null redeem?: TencentBrowserRedeemResult | null artifacts: TencentBrowserSessionArtifacts } diff --git a/apps/frontend/src/views/admin/AdminLayout.vue b/apps/frontend/src/views/admin/AdminLayout.vue index e56ccc3f..048bc246 100644 --- a/apps/frontend/src/views/admin/AdminLayout.vue +++ b/apps/frontend/src/views/admin/AdminLayout.vue @@ -27,6 +27,7 @@ const navItems = computed(() => { { to: '/admin/dashboard', label: '概览' }, { to: '/admin/orders', label: '订单' }, { to: '/admin/tasks', label: '任务' }, + { to: '/admin/manual-redeem', label: '人工兑换' }, { to: '/admin/message-deliveries', label: '消息发送' }, ] diff --git a/apps/frontend/src/views/admin/AdminManualRedeemView.vue b/apps/frontend/src/views/admin/AdminManualRedeemView.vue new file mode 100644 index 00000000..6b9b0855 --- /dev/null +++ b/apps/frontend/src/views/admin/AdminManualRedeemView.vue @@ -0,0 +1,511 @@ + + +