半自动+人工”链路已经落下第一版

This commit is contained in:
yml
2026-04-12 19:07:51 +08:00
parent 4c98b78267
commit 75647e9eed
24 changed files with 711 additions and 93 deletions
@@ -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) {
+208 -33
View File
@@ -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) {