再按凭据类型找库存项
-`tencent_claim_redeem` 当前会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码自动领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。
+`tencent_claim_redeem` 和 `tencent_claim_assisted` 当前都会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。
diff --git a/apps/backend/src/routes/admin/inventory.js b/apps/backend/src/routes/admin/inventory.js index d3237baa..03574513 100644 --- a/apps/backend/src/routes/admin/inventory.js +++ b/apps/backend/src/routes/admin/inventory.js @@ -11,6 +11,8 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' const router = Router() +router.use(requireAdminRoles(['admin', 'operator'])) + router.get('/inventory', createJsonHandler( (req) => getAdminInventoryItems(req.query), { diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js index e6dbe518..0520ecfa 100644 --- a/apps/backend/src/routes/admin/tasks.js +++ b/apps/backend/src/routes/admin/tasks.js @@ -2,12 +2,14 @@ import { Router } from 'express' import { closeAdminTask, + confirmAdminTaskAssistedRole, completeAdminTaskManualDispatch, getAdminTaskDetail, getAdminTaskScreenshotPath, getAdminTasks, markAdminTaskManualReview, regenerateAdminTaskClaimLink, + redeemAdminTaskAssisted, releaseAdminTaskInventoryBinding, releaseAdminTaskInventory, retryAdminTask, @@ -17,7 +19,7 @@ import { createFileHandler, createJsonHandler, requireAdminRoles } from './share const router = Router() router.get('/tasks', createJsonHandler( - (req) => getAdminTasks(req.query), + (req) => getAdminTasks(req.query, req.adminSession), { successMessage: 'ok', errorMessage: '读取任务列表失败', @@ -26,7 +28,7 @@ router.get('/tasks', createJsonHandler( )) router.get('/tasks/:taskId', createJsonHandler( - (req) => getAdminTaskDetail(req.params.taskId), + (req) => getAdminTaskDetail(req.params.taskId, req.adminSession), { successMessage: 'ok', errorMessage: '读取任务详情失败', @@ -35,14 +37,14 @@ router.get('/tasks/:taskId', createJsonHandler( )) router.get('/tasks/:taskId/screenshot', createFileHandler( - (req) => getAdminTaskScreenshotPath(req.params.taskId), + (req) => getAdminTaskScreenshotPath(req.params.taskId, req.adminSession), { errorMessage: '读取任务截图失败', scope: '[admin/tasks/:taskId/screenshot]', }, )) -router.post('/tasks/:taskId/retry', createJsonHandler( +router.post('/tasks/:taskId/retry', requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => retryAdminTask(req.params.taskId), { successMessage: '任务已重试', @@ -90,8 +92,8 @@ router.post('/tasks/:taskId/inventory-bindings/:bindingId/release', requireAdmin }, )) -router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin']), createJsonHandler( - (req) => regenerateAdminTaskClaimLink(req.params.taskId), +router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( + (req) => regenerateAdminTaskClaimLink(req.params.taskId, req.adminSession), { successMessage: '领取链接已重新生成', errorMessage: '重新生成领取链接失败', @@ -109,7 +111,46 @@ router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin']) }, )) -router.post('/tasks/:taskId/close', requireAdminRoles(['admin']), createJsonHandler( +router.post('/tasks/:taskId/support-confirm-role', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( + (req) => confirmAdminTaskAssistedRole(req.params.taskId, req.adminSession), + { + successMessage: '角色已确认', + errorMessage: '客服确认角色失败', + scope: '[admin/tasks/:taskId/support-confirm-role]', + audit: (req, data) => ({ + action: 'task_support_confirm_role', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + status: data.task.status, + }, + }), + }, +)) + +router.post('/tasks/:taskId/support-redeem', requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( + (req) => redeemAdminTaskAssisted(req.params.taskId, req.adminSession), + { + successMessage: '兑换任务已启动', + errorMessage: '客服发起兑换失败', + scope: '[admin/tasks/:taskId/support-redeem]', + audit: (req, data) => ({ + action: 'task_support_redeem', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + status: data.task.status, + deliveryStatus: data.task.deliveryStatus, + }, + }), + }, +)) + +router.post('/tasks/:taskId/close', requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => closeAdminTask(req.params.taskId), { successMessage: '任务已关闭', @@ -128,7 +169,7 @@ router.post('/tasks/:taskId/close', requireAdminRoles(['admin']), createJsonHand }, )) -router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin']), createJsonHandler( +router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => markAdminTaskManualReview(req.params.taskId), { successMessage: '任务已转人工处理', @@ -147,7 +188,7 @@ router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin']), c }, )) -router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin']), createJsonHandler( +router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => completeAdminTaskManualDispatch(req.params.taskId, req.body, req.adminSession), { successMessage: '人工履约结果已回写', diff --git a/apps/backend/src/routes/admin/webhook-events.js b/apps/backend/src/routes/admin/webhook-events.js index 6d757e42..831fc9cd 100644 --- a/apps/backend/src/routes/admin/webhook-events.js +++ b/apps/backend/src/routes/admin/webhook-events.js @@ -9,6 +9,8 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' const router = Router() +router.use(requireAdminRoles(['admin', 'operator'])) + router.get('/webhook-events', createJsonHandler( (req) => getAdminWebhookEvents(req.query), { diff --git a/apps/backend/src/services/admin/admin-auth-service.js b/apps/backend/src/services/admin/admin-auth-service.js index 3b2543f7..d3fe3754 100644 --- a/apps/backend/src/services/admin/admin-auth-service.js +++ b/apps/backend/src/services/admin/admin-auth-service.js @@ -343,7 +343,17 @@ function signPayload(encodedPayload) { } export function normalizeAdminRole(role) { - return String(role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'operator' + const normalized = String(role || '').trim().toLowerCase() + + if (normalized === 'admin') { + return 'admin' + } + + if (normalized === 'support') { + return 'support' + } + + return 'operator' } export function normalizeAdminUserStatus(status) { @@ -363,7 +373,7 @@ function safeCompare(input, expected) { function normalizeRoleQuery(role) { const normalized = String(role || '').trim().toLowerCase() - return ['admin', 'operator'].includes(normalized) ? normalized : '' + return ['admin', 'operator', 'support'].includes(normalized) ? normalized : '' } function normalizeStatusQuery(status) { diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index d0d2f093..54ffc5cd 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -1,6 +1,7 @@ import { runtimeConfig } from '../../config/runtime.js' import { query } from '../../db/client.js' -import { createTaskClaimToken } from '../claim/claim-service.js' +import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' +import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../claim/claim-session-service.js' import { getAgisoShopConfig, getAgisoShopConfigMap, @@ -41,6 +42,8 @@ import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js' import { normalizeProductName } from '../order/product-match-service.js' import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' +import { normalizeAdminRole } from './admin-auth-service.js' +import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' export async function getAdminDashboardSummary() { const todayPrefix = nowIso().slice(0, 10) @@ -176,7 +179,7 @@ export async function getAdminOrderDetail(orderId) { } } -export async function getAdminTasks(query = {}) { +export async function getAdminTasks(query = {}, session = null) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listTasks({ @@ -191,9 +194,14 @@ export async function getAdminTasks(query = {}) { dateTo: normalizeDateQuery(query.dateTo, true), }) const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id)) + const viewerContext = createAdminViewerContext(session) return { - items: items.map((item) => mapAdminTaskListItem(item, getTaskBindingSummary(taskBindingSummaryMap, item.id))), + items: items.map((item) => mapAdminTaskListItem( + item, + getTaskBindingSummary(taskBindingSummaryMap, item.id), + viewerContext, + )), pagination: { page, pageSize, total }, } } @@ -220,7 +228,7 @@ export async function getAdminMessageDeliveries(query = {}) { } } -export async function getAdminTaskDetail(taskId) { +export async function getAdminTaskDetail(taskId, session = null) { const task = await getTaskById(Number(taskId)) if (!task) { @@ -240,8 +248,12 @@ export async function getAdminTaskDetail(taskId) { const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null const taskContext = parseTaskContext(task) + const taskState = parseTaskState(task) const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings) - const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task)) + const viewerContext = createAdminViewerContext(session) + const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext)) + const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' + const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) return { task: mapAdminTaskListItem({ @@ -250,7 +262,7 @@ export async function getAdminTaskDetail(taskId) { sku_name: orderItem?.sku_name || '', inventory_display_value: inventoryItem?.display_value || '', claim_token: claimToken?.token || '', - }, taskBindingSummary), + }, taskBindingSummary, viewerContext), order: order ? { orderId: order.id, @@ -274,9 +286,10 @@ export async function getAdminTaskDetail(taskId) { claimToken: claimToken ? { primaryClaimTokenId: claimToken.id, - token: claimToken.token, + token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', status: claimToken.status, expiredAt: claimToken.expired_at, + claimUrl, } : null, inventory: inventoryItem @@ -285,37 +298,64 @@ export async function getAdminTaskDetail(taskId) { skuCode: inventoryItem.sku_code, batchNo: inventoryItem.batch_no, credentialType: inventoryItem.credential_type || 'tencent_code', - displayValue: inventoryItem.display_value, + displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '', status: inventoryItem.status, } : null, inventoryBindings: mappedInventoryBindings, - artifacts: safeParseJson(task.artifacts_json), - screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '', - manualDispatch: mapManualDispatchContext(taskContext.manualDispatch), + artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, + screenshotUrl, + review: { + required: isAssistedClaimTask(task), + screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null, + roleId: String(taskState.reviewRoleId || '').trim() || '', + roleName: String(taskState.reviewRoleName || '').trim() || '', + }, + manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), events: taskEvents.map(mapAdminTaskEvent), operations: { - canRetry: !isManualDispatchTask(task) && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), + canRetry: viewerContext.canManageTaskLifecycle + && !isManualDispatchTask(task) + && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease), - canRegenerateClaimLink: !isManualDispatchTask(task) && ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status), - canClose: !['redeemed', 'closed'].includes(task.task_status), - canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status), - canCompleteManualDispatch: isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), + canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), + canClose: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed'].includes(task.task_status), + canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status), + canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), + canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext), + canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext), + canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, }, } } -export async function getAdminTaskScreenshotPath(taskId) { +export async function getAdminTaskScreenshotPath(taskId, session = null) { const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) - if (!task.screenshot_path) { - throw createHttpError('当前任务还没有截图', { - statusCode: 404, - errorCode: 'admin_task_screenshot_not_found', - }) + if (viewerContext.role === 'support') { + if (!task.browser_session_id) { + throw createHttpError('当前任务还没有客服复核截图', { + statusCode: 404, + errorCode: 'admin_task_review_screenshot_not_found', + }) + } + + return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) } - return task.screenshot_path + if (task.screenshot_path) { + return task.screenshot_path + } + + if (task.browser_session_id) { + return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) + } + + throw createHttpError('当前任务还没有截图', { + statusCode: 404, + errorCode: 'admin_task_screenshot_not_found', + }) } export async function getAdminInventoryItems(query = {}) { @@ -925,9 +965,10 @@ export async function releaseAdminTaskInventoryBinding(taskId, bindingId) { } } -export async function regenerateAdminTaskClaimLink(taskId) { +export async function regenerateAdminTaskClaimLink(taskId, session = null) { const task = await getRequiredTask(taskId) const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + const viewerContext = createAdminViewerContext(session) if (isManualDispatchTask(task)) { throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { @@ -943,6 +984,13 @@ export async function regenerateAdminTaskClaimLink(taskId) { }) } + if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) { + throw createHttpError('当前账号只能重发半自动客服任务的领取链接', { + statusCode: 403, + errorCode: 'admin_task_regenerate_permission_denied', + }) + } + if (primaryClaimTokenId) { await updateClaimToken(primaryClaimTokenId, { status: 'revoked', @@ -966,6 +1014,30 @@ export async function regenerateAdminTaskClaimLink(taskId) { } } +export async function confirmAdminTaskAssistedRole(taskId, session = null) { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm') + await confirmClaimRoleForAdminTask(task.id) + const updatedTask = await getRequiredTask(task.id) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +export async function redeemAdminTaskAssisted(taskId, session = null) { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem') + await redeemClaimTaskForAdminTask(task.id) + const updatedTask = await getRequiredTask(task.id) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + export async function closeAdminTask(taskId) { const task = await getRequiredTask(taskId) @@ -1387,7 +1459,7 @@ async function getRequiredInventoryItem(inventoryItemId) { return inventoryItem } -function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary()) { +function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary(), viewerContext = createAdminViewerContext()) { const binding = buildTaskBindingState(task) return { @@ -1415,10 +1487,10 @@ function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSumma lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, - inventoryDisplayMasked: maskCode(task.primary_inventory_display_value), + inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '', inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(), - claimToken: task.primary_claim_token || task.claim_token || '', - screenshotPath: task.screenshot_path || '', + claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '', + screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''), } } @@ -1481,7 +1553,7 @@ function createTaskBindingSummaryFromBindings(bindings = []) { } } -function mapAdminTaskInventoryBinding(binding, task) { +function mapAdminTaskInventoryBinding(binding, task, viewerContext = createAdminViewerContext()) { const metadata = safeParseJson(binding.metadata_json) return { @@ -1494,7 +1566,7 @@ function mapAdminTaskInventoryBinding(binding, task) { skuCode: String(binding.sku_code || '').trim(), batchNo: String(binding.batch_no || '').trim(), credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code', - displayValue: String(binding.display_value || '').trim(), + displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '', invalidReason: String(binding.invalid_reason || '').trim(), consumedAt: binding.consumed_at || null, releasedAt: binding.released_at || null, @@ -1502,7 +1574,7 @@ function mapAdminTaskInventoryBinding(binding, task) { updatedAt: binding.updated_at, metadata: normalizeRecord(metadata), isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task), - canRelease: canReleaseTaskInventoryBinding(task, binding), + canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding), } } @@ -1762,7 +1834,25 @@ function parseTaskContext(task) { } } -function mapManualDispatchContext(value) { +function parseTaskState(task) { + const value = task?.state_json + + if (!value) { + return {} + } + + if (typeof value === 'object') { + return value + } + + try { + return JSON.parse(String(value || '{}')) + } catch { + return {} + } +} + +function mapManualDispatchContext(value, viewerContext = createAdminViewerContext()) { if (!value || typeof value !== 'object') { return null } @@ -1770,7 +1860,7 @@ function mapManualDispatchContext(value) { return { outcome: String(value.outcome || '').trim(), deliveryReference: String(value.deliveryReference || '').trim(), - deliveredCredential: String(value.deliveredCredential || '').trim(), + deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(value.deliveredCredential || '').trim() : '', resultMessage: String(value.resultMessage || '').trim(), completedAt: value.completedAt || null, completedBy: value.completedBy && typeof value.completedBy === 'object' @@ -1797,6 +1887,10 @@ function isManualDispatchTask(task) { return String(task?.executor_key || '').trim() === 'manual_dispatch' } +function isAssistedClaimTask(task) { + return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' +} + function getTaskPrimaryInventoryItemId(task) { const value = Number(task?.primary_inventory_item_id || 0) return Number.isFinite(value) && value > 0 ? value : null @@ -1815,6 +1909,87 @@ function getTaskRetryCount(task) { return Number(task?.attempt_count || 0) } +function createAdminViewerContext(session = null) { + const role = normalizeAdminRole(session?.role) + + return { + role, + canViewSensitiveTaskData: role === 'admin' || role === 'operator', + canManageSensitiveInventory: role === 'admin', + canManageTaskLifecycle: role === 'admin' || role === 'operator', + canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support', + } +} + +function canRegenerateClaimLinkForViewer(task, viewerContext) { + if (isManualDispatchTask(task)) { + return false + } + + const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status) + + if (!baseAllowed) { + return false + } + + if (viewerContext.canManageTaskLifecycle) { + return true + } + + return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task) +} + +function canViewerConfirmAssistedRole(task, viewerContext) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + return false + } + + return String(task?.task_status || '').trim() === 'claimed' +} + +function canViewerRedeemAssistedTask(task, viewerContext) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + return false + } + + return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) +} + +function ensureViewerCanOperateAssistedTask(task, viewerContext, action) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + throw createHttpError('当前账号没有此操作权限', { + statusCode: 403, + errorCode: 'admin_task_assisted_permission_denied', + }) + } + + if (action === 'confirm' && !canViewerConfirmAssistedRole(task, viewerContext)) { + throw createHttpError('当前任务状态还不能确认角色', { + statusCode: 409, + errorCode: 'admin_task_assisted_confirm_not_allowed', + }) + } + + if (action === 'redeem' && !canViewerRedeemAssistedTask(task, viewerContext)) { + throw createHttpError('当前任务状态还不能开始兑换', { + statusCode: 409, + errorCode: 'admin_task_assisted_redeem_not_allowed', + }) + } +} + +async function resolveAdminTaskScreenshotUrl(task, viewerContext) { + if (viewerContext.role === 'support') { + return task.browser_session_id ? `/api/v1/admin/tasks/${task.id}/screenshot` : '' + } + + if (task.screenshot_path || task.browser_session_id) { + return `/api/v1/admin/tasks/${task.id}/screenshot` + } + + return '' +} + function resolveDisplayShopName(provider, shopId, shopName) { const normalizedShopName = String(shopName || '').trim() if (normalizedShopName) { diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js index 6f84dde9..7babbce7 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js @@ -37,6 +37,22 @@ const CORE_PROFILES = [ }, ], }, + { + profileKey: 'tencent_claim_assisted', + name: '腾讯领取兑换(半自动+人工)', + executorKey: 'tencent_claim_assisted', + requiresClaim: true, + autoDispatch: false, + inventoryStrategy: 'static_pool', + requirements: [ + { + roleKey: 'primary_code', + credentialType: 'tencent_code', + quantityPerUnit: 1, + isRequired: true, + }, + ], + }, ] export async function ensureFulfillmentCatalogBootstrapped() { diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index b5718d8c..f9c2025a 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -5,10 +5,10 @@ import { getTencentBrowserSessionScreenshotPath, redeemTencentBrowserSession, } from '../session/session.js' -import { findClaimTokenByToken, 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 { getOrderItemById } from '../../repositories/order-item-repo.js' -import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js' +import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../repositories/task-repo.js' import { getInventoryItemById, markInventoryItemDelivered, @@ -24,7 +24,7 @@ const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) export async function getClaimDetail(token, { includeQrImage = true } = {}) { const context = await getClaimContext(token) const session = await loadTaskSession(context.task, { includeQrImage }) - const syncedTask = session ? syncTaskWithSession(context.task, session) : context.task + const syncedTask = session ? await syncTaskWithSession(context.task, session) : context.task return buildClaimDetailPayload({ claimToken: context.claimToken, @@ -97,7 +97,18 @@ export async function getClaimSessionSummary(token) { export async function confirmClaimRole(token) { const context = await getClaimContext(token) + assertPublicClaimActionAllowed(context.task, 'confirm') + return finalizeClaimRoleConfirmation(context) +} + +export async function confirmClaimRoleForAdminTask(taskId) { + const context = await getClaimContextByTaskId(taskId) + + return finalizeClaimRoleConfirmation(context) +} + +async function finalizeClaimRoleConfirmation(context) { if (!context.task.browser_session_id) { throw createHttpError('当前任务还没有创建浏览器会话', { statusCode: 409, @@ -140,7 +151,19 @@ export async function confirmClaimRole(token) { export async function redeemClaimTask(token) { const context = await getClaimContext(token) assertTaskCanProceed(context.task) + assertPublicClaimActionAllowed(context.task, 'redeem') + return finalizeClaimTaskRedeem(context) +} + +export async function redeemClaimTaskForAdminTask(taskId) { + const context = await getClaimContextByTaskId(taskId) + assertTaskCanProceed(context.task) + + return finalizeClaimTaskRedeem(context) +} + +async function finalizeClaimTaskRedeem(context) { if (!context.task.browser_session_id) { throw createHttpError('当前任务还没有创建浏览器会话', { statusCode: 409, @@ -298,6 +321,54 @@ async function getClaimContext(token) { } } +async function getClaimContextByTaskId(taskId) { + const task = await getTaskById(Number(taskId)) + + if (!task) { + throw createHttpError('领取任务不存在', { + statusCode: 404, + errorCode: 'claim_task_not_found', + }) + } + + const claimTokenId = Number(task.primary_claim_token_id || 0) + + if (!claimTokenId) { + throw createHttpError('当前任务还没有领取链接', { + statusCode: 409, + errorCode: 'claim_token_missing', + }) + } + + const claimToken = await getClaimTokenById(claimTokenId) + + if (!claimToken) { + throw createHttpError('领取链接不存在', { + statusCode: 404, + errorCode: 'claim_token_not_found', + }) + } + + const [order, orderItem] = await Promise.all([ + getOrderById(task.order_id), + getOrderItemById(task.order_item_id), + ]) + + if (!order || !orderItem) { + throw createHttpError('领取任务关联订单不完整', { + statusCode: 500, + errorCode: 'claim_order_incomplete', + }) + } + + return { + claimToken, + task, + order, + orderItem, + } +} + function assertTaskCanProceed(task) { if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { throw createHttpError('当前任务已经结束,不能继续操作', { @@ -307,6 +378,21 @@ function assertTaskCanProceed(task) { } } +function assertPublicClaimActionAllowed(task, action) { + if (!isAssistedClaimTask(task)) { + return + } + + const message = action === 'confirm' + ? '当前商品需要客服复核角色,请登录后联系人工继续' + : '当前商品需要客服确认后再执行兑换,请联系人工继续' + + throw createHttpError(message, { + statusCode: 409, + errorCode: 'claim_support_review_required', + }) +} + async function expireClaimContext(claimToken, task) { const now = nowIso() const nextClaimToken = await updateClaimToken(claimToken.id, { @@ -370,6 +456,16 @@ async function syncTaskWithSession(task, session) { patch.claimed_at = task.claimed_at || nowIso() } + if (session.review?.capturedAt) { + patch.state_json = JSON.stringify({ + ...parseTaskState(task), + reviewScreenshotReady: true, + reviewCapturedAt: String(session.review.capturedAt || ''), + reviewRoleId: String(session.review.roleId || ''), + reviewRoleName: String(session.review.roleName || ''), + }) + } + if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) { patch.screenshot_path = task.screenshot_path || '' } @@ -389,6 +485,8 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } taskId: task.id, taskNo: task.task_no, status: task.task_status, + executorKey: task.executor_key || '', + requiresSupportReview: isAssistedClaimTask(task), expiresAt: claimToken.expired_at, claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, @@ -424,3 +522,25 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } : null, } } + +function parseTaskState(task) { + const value = task?.state_json + + if (!value) { + return {} + } + + if (typeof value === 'object') { + return value + } + + try { + return JSON.parse(String(value || '{}')) + } catch { + return {} + } +} + +function isAssistedClaimTask(task) { + return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' +} diff --git a/apps/backend/src/services/session/session.js b/apps/backend/src/services/session/session.js index 7a41b28a..a02051a3 100644 --- a/apps/backend/src/services/session/session.js +++ b/apps/backend/src/services/session/session.js @@ -111,6 +111,7 @@ export async function createTencentBrowserSession(payload = {}) { qrUpdatedAt: '', lastState: null, lastRedeem: null, + lastReview: null, lastError: '', presentationSyncAttempts: 0, } @@ -237,6 +238,11 @@ export async function refreshTencentBrowserSession(sessionId, { includeQrImage = activityInfo, loginType: session.loginType, }) + + if (credentialReady && activityInfo?.role?.ready) { + await ensureReviewScreenshot(session, activityInfo) + } + session.updatedAt = new Date().toISOString() session.expiresAt = Date.now() + SESSION_TTL_MS @@ -335,6 +341,20 @@ export async function getTencentBrowserSessionScreenshotPath(sessionId) { return screenshotPath } +export async function getTencentBrowserSessionReviewScreenshotPath(sessionId) { + const session = await getRequiredSession(sessionId) + const screenshotPath = session.lastReview?.screenshotPath + + if (!screenshotPath) { + throw new SessionServiceError('当前会话还没有客服复核截图', { + statusCode: 404, + errorCode: 'review_screenshot_not_ready', + }) + } + + return screenshotPath +} + async function ensureBrowser() { if (!browserPromise) { const launchOptions = resolveBrowserLaunchOptions() @@ -910,6 +930,7 @@ function buildSessionPayload(session, { includeQrImage = true } = {}) { qrUpdatedAt: session.qrUpdatedAt, credentialReady: Boolean(session.lastState?.credentialReady), activityInfo, + review: buildReviewPayload(session.lastReview), redeem: buildRedeemPayload(session.lastRedeem), artifacts: buildArtifactsPayload(session), } @@ -928,11 +949,34 @@ function buildSessionPayload(session, { includeQrImage = true } = {}) { state: session.lastState, browserDebug: resolveBrowserLaunchOptions(), qrImagePath: session.qrImagePath, + review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }), redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }), artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }), } } +function buildReviewPayload(review, { includeInternalPaths = false } = {}) { + if (!review) { + return null + } + + const payload = { + capturedAt: review.capturedAt, + roleId: review.roleId || '', + roleName: review.roleName || '', + } + + if (!includeInternalPaths) { + return payload + } + + return { + ...payload, + screenshotPath: review.screenshotPath || '', + signature: review.signature || '', + } +} + function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) { if (!redeem) { return null @@ -972,6 +1016,7 @@ function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) { function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) { const payload = { hasQrImage: Boolean(session.qrImageBase64), + hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath), hasScreenshot: Boolean(session.lastRedeem?.screenshotPath), } @@ -986,6 +1031,38 @@ function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) { screenshotPath: session.lastRedeem?.screenshotPath || '', htmlPath: session.lastRedeem?.htmlPath || '', resultPath: session.lastRedeem?.resultPath || '', + reviewScreenshotPath: session.lastReview?.screenshotPath || '', + } +} + +async function ensureReviewScreenshot(session, activityInfo) { + const roleId = String(activityInfo?.role?.roleId || '').trim() + const roleName = String(activityInfo?.role?.roleName || '').trim() + const signature = `${session.loginType}|${roleId}|${roleName}` + + if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) { + return + } + + const screenshotPath = path.join(session.sessionDir, 'role-review.png') + + try { + await session.page.screenshot({ + path: screenshotPath, + fullPage: true, + }) + session.lastReview = { + screenshotPath, + capturedAt: new Date().toISOString(), + roleId, + roleName, + signature, + } + } catch (error) { + logBrowserSessionDebug('reviewScreenshot.capture.error', { + sessionId: session.sessionId, + message: error instanceof Error ? error.message : String(error), + }) } } diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue index 2389f4b6..96e8b7a2 100644 --- a/apps/frontend/src/components/admin/AdminStatusTag.vue +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -12,7 +12,7 @@ function resolveTone(status: string) { return 'success' } - if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) { + if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) { return 'primary' } @@ -61,6 +61,7 @@ function resolveLabel(status: string) { active: '生效中', admin: '管理员', operator: '普通运营', + support: '客服', revoked: '已撤销', disabled: '已停用', success: '成功', diff --git a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue b/apps/frontend/src/components/tencent/TencentRedeemPanel.vue index e8991006..0a2774d0 100644 --- a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue +++ b/apps/frontend/src/components/tencent/TencentRedeemPanel.vue @@ -80,6 +80,10 @@ const emit = defineEmits<{ +
`tencent_claim_redeem` 当前会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码自动领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。
+`tencent_claim_redeem` 和 `tencent_claim_assisted` 当前都会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。
命中库存后任务会进入预占 / 发放链路;没命中库存、或配置走 `manual_review` 时,会落到人工处理。
+命中库存后任务会进入预占 / 发放链路;配置为 `tencent_claim_assisted` 时会进入“用户登录 + 客服确认 + 后端自动兑换”;配置走 `manual_review` 时,会落到人工处理。