fix(admin): 恢复任务重试/转人工/人工完结等后台写接口

删除咸鱼链路时误删了 retry、mark-manual-review、complete-manual-dispatch、regenerate-claim-link;现按 affiliate_dash + manual_dispatch 语义恢复,历史下线通道可人工完结但不可自动重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-14 17:32:26 +08:00
co-authored by Cursor
parent b3aad0044c
commit 745eeaee35
4 changed files with 414 additions and 6 deletions
+81 -2
View File
@@ -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']),
@@ -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,
@@ -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<AdminTaskActionResponse> {
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<AdminTaskActionResponse> {
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<AdminTaskActionResponse> {
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<AdminTaskManualDispatchResponse> {
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),
}
}
@@ -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 &&