diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index d4ada657..33f4a859 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -2,7 +2,13 @@ import type { Request } from 'express' import { Router } from 'express' import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js' -import { closeAdminTask } from '../../services/admin/write/task-actions.js' +import { + closeAdminTask, + completeAdminTaskManualDispatch, + markAdminTaskManualReview, + regenerateAdminTaskClaimLink, + retryAdminTask, +} from '../../services/admin/write/task-actions.js' import { consumeAdminTaskKuaishouIndustryVoucher, resendAdminTaskKuaishouIndustryVoucherCode, @@ -10,10 +16,14 @@ import { import { createJsonHandler, requireAdminRoles } from './session.js' import type { AdminTaskKuaishouIndustryConsumeRouteBody, + AdminTaskManualDispatchRouteBody, AdminTaskRouteParams, AdminTaskRouteQuery, } from '../../types/admin/route-inputs.js' -import type { AdminTaskActionResponse } from '../../types/admin/write-models.js' +import type { + AdminTaskActionResponse, + AdminTaskManualDispatchResponse, +} from '../../types/admin/write-models.js' const router = Router() @@ -44,6 +54,31 @@ router.get( }), ) +router.post( + '/tasks/:taskId/retry', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => retryAdminTask(getTaskId(req), req.adminSession || null), { + successMessage: '任务已重试', + errorMessage: '重试任务失败', + scope: '[admin/tasks/:taskId/retry]', + audit: (req, data) => buildTaskAudit('task_retry', req, data), + }), +) + +router.post( + '/tasks/:taskId/regenerate-claim-link', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null), + { + successMessage: '领取链接已重新生成', + errorMessage: '重新生成领取链接失败', + scope: '[admin/tasks/:taskId/regenerate-claim-link]', + audit: (req, data) => buildTaskAudit('task_regenerate_claim_link', req, data), + }, + ), +) + router.post( '/tasks/:taskId/close', requireAdminRoles(['admin', 'operator', 'support']), @@ -55,6 +90,50 @@ router.post( }), ) +router.post( + '/tasks/:taskId/mark-manual-review', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req), req.adminSession || null), { + successMessage: '任务已转人工处理', + errorMessage: '标记人工处理失败', + scope: '[admin/tasks/:taskId/mark-manual-review]', + audit: (req, data) => buildTaskAudit('task_mark_manual_review', req, data), + }), +) + +router.post( + '/tasks/:taskId/complete-manual-dispatch', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + completeAdminTaskManualDispatch( + getTaskId(req), + req.body as AdminTaskManualDispatchRouteBody, + req.adminSession || null, + ), + { + successMessage: '人工履约结果已回写', + errorMessage: '回写人工履约结果失败', + scope: '[admin/tasks/:taskId/complete-manual-dispatch]', + audit: (req, data) => { + const result = data as AdminTaskManualDispatchResponse + return { + action: 'task_complete_manual_dispatch', + targetType: 'task', + targetId: String(getTaskId(req)), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + outcome: result.outcome, + deliveryStatus: result.task.deliveryStatus, + resultCode: result.task.resultCode, + }, + } + }, + }, + ), +) + router.post( '/tasks/:taskId/kuaishou-industry/resend-code', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 4bf22b38..55e4fd59 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -51,6 +51,7 @@ import { resolveOrderItemSkuNick, resolveOrderItemTitle, } from './admin-read-shared-helpers.js' +import { isRetiredFulfillmentExecutor } from '../fulfillment/executors/types.js' import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' import { buildOrderFulfillmentProgress, @@ -385,6 +386,7 @@ export async function getAdminTaskDetail( canRetry: viewerContext.canManageTaskLifecycle && !isManualDispatchTask(task) && + !isRetiredFulfillmentExecutor(task.executor_key) && canRetryTaskStatus(task.task_status), canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), canClose: canViewerCloseTask(task, viewerContext), @@ -392,7 +394,7 @@ export async function getAdminTaskDetail( viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status), canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && - isManualDispatchTask(task) && + (isManualDispatchTask(task) || isRetiredFulfillmentExecutor(task.executor_key)) && canCompleteManualDispatchTaskStatus(task.task_status), canPrepareKuaishouCloudFulfillment: false, canRefreshKuaishouCloudRoleInfo: false, diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts index 4e4d6087..d199bb4d 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -1,17 +1,36 @@ import { updateClaimToken } from '../../../repositories/claim-token-repo.js' import { updateTask } from '../../../repositories/task-repo.js' import { createTaskEvent } from '../../../repositories/task-event-repo.js' +import { createTaskClaimToken, buildClaimUrl } from '../../claim/claim-service.js' +import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js' +import { preparePaidFulfillmentTask } from '../../fulfillment/executors/registry.js' +import { + canRetryTaskStatus, + canRegenerateClaimLinkStatus, + TASK_STATUS, +} from '../../../domain/task-status.js' +import { isRetiredFulfillmentExecutor } from '../../fulfillment/executors/types.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS } from '../../../domain/task-status.js' -import { canViewerCloseTask, createAdminViewerContext } from '../admin-read-shared-helpers.js' +import { + canViewerCloseTask, + createAdminViewerContext, + getTaskPrimaryClaimTokenId, + isManualDispatchTask, + parseTaskContext, +} from '../admin-read-shared-helpers.js' import { getRequiredTask, mapTaskActionPayload } from '../admin-task-read-helpers.js' +import { maskCode, normalizeManualDispatchOutcome } from '../write-helpers.js' import type { AdminEntityIdInput, AdminViewerSessionInput, } from '../../../types/admin/read-inputs.js' -import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js' +import type { AdminTaskManualDispatchInput } from '../../../types/admin/write-inputs.js' +import type { + AdminTaskActionResponse, + AdminTaskManualDispatchResponse, +} from '../../../types/admin/write-models.js' export async function closeAdminTask( taskId: AdminEntityIdInput, @@ -76,3 +95,309 @@ export async function closeAdminTask( task: mapTaskActionPayload(updatedTask), } } + +export async function markAdminTaskManualReview( + taskId: AdminEntityIdInput, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + const now = nowIso() + const reason = task.last_error || '已转人工处理' + + const updatedTask = await updateTask(task.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + delivery_status: task.delivery_status || 'pending', + last_error: reason, + updated_at: now, + }) + + if (!updatedTask) { + throw createHttpError('标记人工处理失败', { + statusCode: 500, + errorCode: 'admin_task_mark_manual_review_failed', + }) + } + + await createTaskEvent( + task.id, + 'task_mark_manual_review', + { + reason, + markedBy: session + ? { + userId: session.userId, + username: session.username, + role: session.role, + } + : null, + }, + now, + ) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +export async function retryAdminTask( + taskId: AdminEntityIdInput, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + + if (isManualDispatchTask(task)) { + throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_retry_not_allowed', + }) + } + + if (isRetiredFulfillmentExecutor(task.executor_key)) { + throw createHttpError('该履约通道已下线,请转人工处理', { + statusCode: 409, + errorCode: 'admin_task_executor_retired', + }) + } + + if (!canRetryTaskStatus(task.task_status)) { + throw createHttpError('当前任务状态不允许重试', { + statusCode: 409, + errorCode: 'admin_task_retry_not_allowed', + }) + } + + const now = nowIso() + const resetTask = await updateTask(task.id, { + task_status: TASK_STATUS.PAID, + last_error: '', + result_code: '', + result_message: '', + attempt_count: Number(task.attempt_count || 0) + 1, + updated_at: now, + }) + + if (!resetTask) { + throw createHttpError('任务重试失败', { + statusCode: 500, + errorCode: 'admin_task_retry_failed', + }) + } + + const prepared = await preparePaidFulfillmentTask(resetTask, { + updateTask, + createTaskClaimToken, + notifyTaskAutoManualReview, + nowIso, + }) + + const effectiveTask = prepared || resetTask + await createTaskEvent( + task.id, + 'task_retry', + { + previousStatus: task.task_status, + nextStatus: effectiveTask.task_status, + executorKey: task.executor_key, + retriedBy: session + ? { + userId: session.userId, + username: session.username, + role: session.role, + } + : null, + }, + now, + ) + + const claimToken = String( + effectiveTask.primary_claim_token || effectiveTask.claim_token || '', + ).trim() + const response: AdminTaskActionResponse = { + task: mapTaskActionPayload(effectiveTask), + } + + if (claimToken) { + response.claimUrl = buildClaimUrl(claimToken) + response.token = claimToken + } + + return response +} + +export async function regenerateAdminTaskClaimLink( + taskId: AdminEntityIdInput, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + + if (isManualDispatchTask(task)) { + throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_claim_not_allowed', + }) + } + + if (!canRegenerateClaimLinkStatus(task.task_status)) { + throw createHttpError('当前任务状态不允许重新生成领取链接', { + statusCode: 409, + errorCode: 'admin_task_regenerate_not_allowed', + }) + } + + if (!viewerContext.canManageTaskLifecycle) { + throw createHttpError('当前账号无权重发领取链接', { + statusCode: 403, + errorCode: 'admin_task_regenerate_permission_denied', + }) + } + + const now = nowIso() + if (primaryClaimTokenId) { + await updateClaimToken(primaryClaimTokenId, { + status: 'revoked', + updated_at: now, + }) + } + + const claimToken = await createTaskClaimToken(task.id) + const nextStatus = + task.task_status === TASK_STATUS.MANUAL_REVIEW || task.task_status === TASK_STATUS.RETRY_PENDING + ? TASK_STATUS.LINK_GENERATED + : task.task_status === TASK_STATUS.PAID + ? TASK_STATUS.LINK_GENERATED + : task.task_status + + const updatedTask = await updateTask(task.id, { + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + task_status: nextStatus, + user_action_status: 'pending_claim', + last_error: '', + updated_at: now, + }) + + if (!updatedTask) { + throw createHttpError('重新生成领取链接失败', { + statusCode: 500, + errorCode: 'admin_task_regenerate_failed', + }) + } + + await createTaskEvent( + task.id, + 'task_regenerate_claim_link', + { + regeneratedBy: session + ? { + userId: session.userId, + username: session.username, + role: session.role, + } + : null, + }, + now, + ) + + return { + task: mapTaskActionPayload(updatedTask), + claimUrl: claimToken.claimUrl, + token: claimToken.token, + } +} + +export async function completeAdminTaskManualDispatch( + taskId: AdminEntityIdInput, + payload: AdminTaskManualDispatchInput = {}, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + + if ( + !isManualDispatchTask(task) && + !isRetiredFulfillmentExecutor(task.executor_key) + ) { + throw createHttpError('当前任务不是人工履约任务', { + statusCode: 409, + errorCode: 'admin_task_not_manual_dispatch', + }) + } + + if ( + task.task_status === TASK_STATUS.REDEEMED || + task.task_status === TASK_STATUS.CLOSED || + task.task_status === TASK_STATUS.COMPLETED + ) { + throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_already_completed', + }) + } + + const now = nowIso() + const outcome = normalizeManualDispatchOutcome(payload.outcome) + const resultMessage = String(payload.resultMessage || '').trim() + const deliveryReference = String(payload.deliveryReference || '').trim() + const deliveredCredential = String(payload.deliveredCredential || '').trim() + const context = parseTaskContext(task) + const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' + const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' + const nextTaskStatus = outcome === 'failed' ? TASK_STATUS.CLOSED : TASK_STATUS.REDEEMED + const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' + const manualDispatch = { + outcome, + deliveryReference, + deliveredCredential, + resultMessage: resultMessage || fallbackMessage, + completedAt: now, + completedBy: session + ? { + userId: Number(session.userId || 0), + username: String(session.username || ''), + role: String(session.role || ''), + } + : null, + } + + const updatedTask = await updateTask(task.id, { + task_status: nextTaskStatus, + delivery_status: nextDeliveryStatus, + result_code: resultCode, + result_message: resultMessage || fallbackMessage, + user_action_status: 'not_required', + last_error: outcome === 'failed' ? resultMessage || fallbackMessage : '', + redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, + context_json: JSON.stringify({ + ...context, + manualDispatch, + }), + updated_at: now, + }) + + if (!updatedTask) { + throw createHttpError('回写人工履约结果失败', { + statusCode: 500, + errorCode: 'admin_task_manual_dispatch_failed', + }) + } + + await createTaskEvent( + task.id, + 'manual_dispatch_completed', + { + outcome, + resultCode, + resultMessage: resultMessage || fallbackMessage, + deliveryReference, + deliveredCredentialMasked: maskCode(deliveredCredential), + completedBy: manualDispatch.completedBy, + }, + now, + ) + + return { + outcome, + task: mapTaskActionPayload(updatedTask), + } +} diff --git a/apps/frontend/src/pages/admin/AdminTasksPage.tsx b/apps/frontend/src/pages/admin/AdminTasksPage.tsx index 5b419eb8..8b7608a0 100644 --- a/apps/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTasksPage.tsx @@ -230,6 +230,8 @@ export default function AdminTasksPage() { const canRetry = canManageTaskLifecycle && row.executorKey !== 'manual_dispatch' && + row.executorKey !== 'kuaishou_ct_assisted' && + row.executorKey !== 'kuaishou_feifei' && ['retry_pending', 'manual_review'].includes(row.status) const canManualReview = canManageTaskLifecycle &&