From 75647e9eed99f756bcbd5610986b703db8178a65 Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 12 Apr 2026 19:07:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=8A=E8=87=AA=E5=8A=A8+=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E2=80=9D=E9=93=BE=E8=B7=AF=E5=B7=B2=E7=BB=8F=E8=90=BD=E4=B8=8B?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/admin/inventory.js | 2 + apps/backend/src/routes/admin/tasks.js | 59 ++++- .../src/routes/admin/webhook-events.js | 2 + .../src/services/admin/admin-auth-service.js | 14 +- .../src/services/admin/admin-service.js | 241 +++++++++++++++--- .../fulfillment-bootstrap-service.js | 16 ++ .../services/claim/claim-session-service.js | 126 ++++++++- apps/backend/src/services/session/session.js | 77 ++++++ .../src/components/admin/AdminStatusTag.vue | 3 +- .../components/tencent/TencentRedeemPanel.vue | 4 + apps/frontend/src/composables/useClaimPage.ts | 29 ++- apps/frontend/src/router/index.ts | 17 +- apps/frontend/src/services/admin.ts | 8 + apps/frontend/src/types/admin.ts | 12 +- apps/frontend/src/types/claim.ts | 2 + apps/frontend/src/utils/admin-auth.ts | 26 +- apps/frontend/src/utils/admin-options.ts | 1 + .../admin/AdminFulfillmentBindingsView.vue | 5 +- .../src/views/admin/AdminInventoryView.vue | 4 +- apps/frontend/src/views/admin/AdminLayout.vue | 24 +- .../src/views/admin/AdminTaskDetailView.vue | 81 +++++- .../src/views/admin/AdminTasksView.vue | 2 +- .../src/views/admin/AdminUsersView.vue | 43 +++- apps/frontend/src/views/claim/ClaimView.vue | 6 +- 24 files changed, 711 insertions(+), 93 deletions(-) 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<{ +
+ 当前商品需要客服复核,完成扫码登录后请联系人工继续。 +
+
- Boolean(task.value && hasSession.value && roleReady.value && task.value.status === 'claimed'), + Boolean( + task.value + && !task.value.requiresSupportReview + && hasSession.value + && roleReady.value + && task.value.status === 'claimed', + ), ) const canRedeem = computed(() => - Boolean(task.value && hasSession.value && roleConfirmed.value && !redeemLoading.value && ( - task.value.status === 'role_confirmed' || task.value.status === 'redeeming' - )), + Boolean( + task.value + && !task.value.requiresSupportReview + && hasSession.value + && roleConfirmed.value + && !redeemLoading.value + && (task.value.status === 'role_confirmed' || task.value.status === 'redeeming'), + ), ) const redeemBlockedReason = computed(() => { if (!detail.value) { @@ -119,6 +130,14 @@ export function useClaimPage(token: string) { return `请先选择${loginTypeLabel.value}并初始化登录会话` } + if (task.value?.requiresSupportReview) { + if (!roleReady.value) { + return '扫码成功后,系统会自动同步登录信息,准备发给客服复核' + } + + return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续' + } + if (redeemLoading.value) { return '兑换任务正在执行中' } @@ -133,7 +152,7 @@ export function useClaimPage(token: string) { return '' }) - const redeemButtonLabel = computed(() => '开始兑换') + const redeemButtonLabel = computed(() => (task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换')) const screenshotEmptyTitle = computed(() => task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', ) diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index a83f64bc..4cddfa1a 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -1,5 +1,5 @@ import { createRouter, createWebHashHistory } from 'vue-router' -import { hasAdminSession } from '@/utils/admin-auth' +import { getAdminRole, hasAdminSession } from '@/utils/admin-auth' const router = createRouter({ history: createWebHashHistory(), @@ -42,6 +42,7 @@ const router = createRouter({ }, { path: 'users', + meta: { allowedRoles: ['admin'] }, component: () => import('@/views/admin/AdminUsersView.vue'), }, { @@ -66,6 +67,7 @@ const router = createRouter({ }, { path: 'inventory', + meta: { allowedRoles: ['admin', 'operator'] }, component: () => import('@/views/admin/AdminInventoryView.vue'), }, { @@ -74,22 +76,27 @@ const router = createRouter({ }, { path: 'webhook-events', + meta: { allowedRoles: ['admin', 'operator'] }, component: () => import('@/views/admin/AdminWebhookEventsView.vue'), }, { path: 'platform-shops', + meta: { allowedRoles: ['admin'] }, component: () => import('@/views/admin/AdminPlatformShopsView.vue'), }, { path: 'platform-fulfillment', + meta: { allowedRoles: ['admin'] }, component: () => import('@/views/admin/AdminFulfillmentBindingsView.vue'), }, { path: 'webhook-events/:eventId', + meta: { allowedRoles: ['admin', 'operator'] }, component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'), }, { path: 'audit-logs', + meta: { allowedRoles: ['admin'] }, component: () => import('@/views/admin/AdminAuditLogsView.vue'), }, ], @@ -118,6 +125,14 @@ router.beforeEach((to) => { return '/admin/login' } + const allowedRoles = to.matched + .map((record) => (record.meta?.allowedRoles as string[] | undefined) || []) + .find((roles) => Array.isArray(roles) && roles.length > 0) + + if (allowedRoles && !allowedRoles.includes(getAdminRole())) { + return '/admin/dashboard' + } + return true }) diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index 4c5c83c6..713d2251 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -131,6 +131,14 @@ export function regenerateAdminTaskClaimLink(taskId: number | string) { ) } +export function confirmAdminTaskAssistedRole(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/support-confirm-role`, {}) +} + +export function redeemAdminTaskAssisted(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/support-redeem`, {}) +} + export function closeAdminTask(taskId: number | string) { return apiPost(`/api/v1/admin/tasks/${taskId}/close`, {}) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index cfa68f80..8ae01624 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -4,7 +4,7 @@ export interface AdminPagination { total: number } -export type AdminRole = 'admin' | 'operator' +export type AdminRole = 'admin' | 'operator' | 'support' export type AdminUserStatus = 'active' | 'disabled' export interface AdminLoginResponse { @@ -199,6 +199,9 @@ export interface AdminTaskOperations { canClose: boolean canMarkManualReview: boolean canCompleteManualDispatch: boolean + canSupportConfirmRole: boolean + canSupportRedeem: boolean + canViewSensitiveTaskData: boolean } export interface AdminTaskActionResponse { @@ -301,6 +304,7 @@ export interface AdminTaskDetail { token: string status: string expiredAt: string + claimUrl: string } inventory: null | { inventoryItemId: number @@ -332,6 +336,12 @@ export interface AdminTaskDetail { }> artifacts: Record screenshotUrl: string + review: { + required: boolean + screenshotCapturedAt: string | null + roleId: string + roleName: string + } manualDispatch: null | { outcome: string deliveryReference: string diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index b8fb1394..47224bd5 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -22,6 +22,8 @@ export interface ClaimTaskInfo { taskId: number taskNo: string status: ClaimTaskStatus + executorKey: string + requiresSupportReview: boolean expiresAt: string | null claimedAt: string | null roleConfirmedAt: string | null diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts index de204aaa..78098e74 100644 --- a/apps/frontend/src/utils/admin-auth.ts +++ b/apps/frontend/src/utils/admin-auth.ts @@ -4,6 +4,12 @@ const ADMIN_USER_ID_KEY = 'order-site-admin-user-id' const ADMIN_USERNAME_KEY = 'order-site-admin-username' const ADMIN_ROLE_KEY = 'order-site-admin-role' +const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin', number> = { + support: 1, + operator: 2, + admin: 3, +} + export function getAdminToken() { return localStorage.getItem(ADMIN_TOKEN_KEY) || '' } @@ -23,21 +29,25 @@ export function getAdminUsername() { export function getAdminRole() { const role = localStorage.getItem(ADMIN_ROLE_KEY) - return role === 'admin' ? 'admin' : 'operator' -} - -export function hasAdminRole(role: 'admin' | 'operator') { - if (role === 'operator') { - return Boolean(getAdminToken()) + if (role === 'admin' || role === 'operator' || role === 'support') { + return role } - return getAdminRole() === 'admin' + return 'operator' +} + +export function hasAdminRole(role: 'admin' | 'operator' | 'support') { + if (!getAdminToken()) { + return false + } + + return ADMIN_ROLE_LEVEL[getAdminRole()] >= ADMIN_ROLE_LEVEL[role] } export function setAdminSession( token: string, expiresAt: string, - user?: { userId: number; username: string; role: 'admin' | 'operator' }, + user?: { userId: number; username: string; role: 'admin' | 'operator' | 'support' }, ) { localStorage.setItem(ADMIN_TOKEN_KEY, token) localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt) diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 026fcbdf..e61fd715 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -57,6 +57,7 @@ export const adminUserRoleOptions = [ { label: '全部', value: '' }, { label: '管理员', value: 'admin' }, { label: '普通运营', value: 'operator' }, + { label: '客服', value: 'support' }, ] export const adminUserStatusOptions = [ diff --git a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue index 2b8d6e4b..4dbf3121 100644 --- a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue +++ b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue @@ -55,6 +55,7 @@ type ValidationState = { const PROFILE_OPTIONS = [ { label: '腾讯领取兑换', value: 'tencent_claim_redeem' }, + { label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' }, { label: '人工发货', value: 'manual_review' }, ] @@ -405,7 +406,7 @@ onMounted(loadConfigs)
说明 - 每条规则同时定义“外部商品匹配条件”和“内部履约 SKU / 履约方式”。未命中的商品会进入人工处理。 + 每条规则同时定义“外部商品匹配条件”和“内部履约 SKU / 履约方式”。未命中的商品会直接忽略,不再继续走后续履约动作。
字段怎么理解 @@ -424,7 +425,7 @@ onMounted(loadConfigs)
履约方式 - `腾讯领取兑换` 表示走自动库存链路,`人工发货` 表示落到人工处理。 + `腾讯领取兑换` 表示用户确认后自动兑换,`腾讯领取兑换(半自动+人工)` 表示用户先登录、客服确认角色后再自动兑换,`人工发货` 表示直接落到人工处理。
diff --git a/apps/frontend/src/views/admin/AdminInventoryView.vue b/apps/frontend/src/views/admin/AdminInventoryView.vue index 9795b3d0..2197cbf6 100644 --- a/apps/frontend/src/views/admin/AdminInventoryView.vue +++ b/apps/frontend/src/views/admin/AdminInventoryView.vue @@ -423,12 +423,12 @@ onMounted(loadInventoryItems)
2. 库存预占

再按凭据类型找库存项

-

`tencent_claim_redeem` 当前会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码自动领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。

+

`tencent_claim_redeem` 和 `tencent_claim_assisted` 当前都会优先查找 `tencent_code`,适合三角洲行动这类腾讯兑换码领取场景。以后扩展别的履约档案时,也会按各自 requirement 匹配。

3. 履约结果

自动绑定或转人工

-

命中库存后任务会进入预占 / 发放链路;没命中库存、或配置走 `manual_review` 时,会落到人工处理。

+

命中库存后任务会进入预占 / 发放链路;配置为 `tencent_claim_assisted` 时会进入“用户登录 + 客服确认 + 后端自动兑换”;配置走 `manual_review` 时,会落到人工处理。

diff --git a/apps/frontend/src/views/admin/AdminLayout.vue b/apps/frontend/src/views/admin/AdminLayout.vue index 15a41c3b..a416cbc2 100644 --- a/apps/frontend/src/views/admin/AdminLayout.vue +++ b/apps/frontend/src/views/admin/AdminLayout.vue @@ -8,17 +8,33 @@ import { clearAdminSession, getAdminRole, getAdminTokenExpiresAt, getAdminUserna import { formatAdminDateTime } from '@/utils/admin-time' const router = useRouter() -const isAdmin = computed(() => getAdminRole() === 'admin') +const currentRole = computed(() => getAdminRole()) +const isAdmin = computed(() => currentRole.value === 'admin') +const isOperator = computed(() => currentRole.value === 'operator') +const roleLabel = computed(() => { + if (currentRole.value === 'admin') { + return '管理员' + } + + if (currentRole.value === 'support') { + return '客服' + } + + return '普通运营' +}) const navItems = computed(() => { const baseItems = [ { to: '/admin/dashboard', label: '概览' }, { to: '/admin/orders', label: '订单' }, { to: '/admin/tasks', label: '任务' }, - { to: '/admin/inventory', label: '库存' }, { to: '/admin/message-deliveries', label: '消息发送' }, - { to: '/admin/webhook-events', label: 'Webhook' }, ] + if (isAdmin.value || isOperator.value) { + baseItems.splice(3, 0, { to: '/admin/inventory', label: '库存' }) + baseItems.push({ to: '/admin/webhook-events', label: 'Webhook' }) + } + if (isAdmin.value) { baseItems.splice(1, 0, { to: '/admin/users', label: '用户' }) baseItems.push({ to: '/admin/platform-shops', label: 'Agiso店铺' }) @@ -54,7 +70,7 @@ async function submitLogout() { 当前账号 {{ getAdminUsername() || '未读取' }} 当前角色 - {{ getAdminRole() === 'admin' ? '管理员' : '普通运营' }} + {{ roleLabel }} 登录有效期 {{ formatAdminDateTime(getAdminTokenExpiresAt()) }} diff --git a/apps/frontend/src/views/admin/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/AdminTaskDetailView.vue index 5b4c2fe7..4bed3a4f 100644 --- a/apps/frontend/src/views/admin/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/AdminTaskDetailView.vue @@ -1,12 +1,14 @@ @@ -135,7 +139,7 @@ const helperText = computed(() => { :can-confirm-role="canConfirmRole" confirm-action-label="确认当前角色并继续" :confirm-action-loading="roleConfirmLoading" - :confirm-action-visible="!roleConfirmed" + :confirm-action-visible="!roleConfirmed && !task?.requiresSupportReview" :confirm-readonly="true" :hide-redeem-form="true" :login-type-label="loginTypeLabel"