import { closeClaimTokensByTaskId, updateClaimToken, } from '../../../repositories/claim-token-repo.js' import { updateTask } from '../../../repositories/task-repo.js' import { createTaskEvent } from '../../../repositories/task-event-repo.js' import { getOrderById } from '../../../repositories/order-repo.js' import { createTaskClaimToken, buildClaimUrl, buildConfiguredClaimUrl, } from '../../claim/claim-service.js' import { getDouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js' import { isDouyinMashidaProvider } from '../../platforms/douyin-mashida/order-mapper.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 { 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 { AdminTaskManualDispatchInput } from '../../../types/admin/write-inputs.js' import type { AdminTaskActionResponse, AdminTaskManualDispatchResponse, } from '../../../types/admin/write-models.js' import type { TaskRow } from '../../../types/repository/rows.js' export async function closeAdminTask( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, ): Promise { const task = await getRequiredTask(taskId) const viewerContext = createAdminViewerContext(session) if (!canViewerCloseTask(task, viewerContext)) { throw createHttpError('当前任务不可关闭', { statusCode: 409, errorCode: 'admin_task_close_not_allowed', }) } const now = nowIso() const claimTokenId = Number(task.primary_claim_token_id || 0) if (claimTokenId > 0 && String(task.primary_claim_token_status || '').trim() !== 'closed') { await updateClaimToken(claimTokenId, { status: 'closed', expired_at: now, updated_at: now, }) } await closeClaimTokensByTaskId(task.id, now) const updatedTask = await updateTask(task.id, { task_status: TASK_STATUS.CLOSED, delivery_status: 'closed', result_code: 'closed_by_admin', result_message: '任务已关闭,领取链接已失效', user_action_status: 'closed', last_error: '', claim_expires_at: now, updated_at: now, }) if (!updatedTask) { throw createHttpError('任务关闭失败', { statusCode: 500, errorCode: 'admin_task_close_failed', }) } await createTaskEvent( task.id, 'task_closed', { closedBy: session ? { userId: session.userId, username: session.username, displayName: session.displayName || session.username, role: session.role, } : null, claimTokenClosed: claimTokenId > 0, }, now, ) return { 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, displayName: session.displayName || 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, displayName: session.displayName || 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 = await resolveAdminTaskClaimUrl(effectiveTask, claimToken) response.token = claimToken } return response } /** * 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key): * - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程); * 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空; * - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。 */ async function resolveAdminTaskClaimUrl( task: Pick, token: string, ): Promise { const normalizedToken = String(token || '').trim() if (!normalizedToken) { return '' } const order = await getOrderById(task.order_id) if (isDouyinMashidaProvider(order?.provider)) { const claimUrl = buildConfiguredClaimUrl( getDouyinMashidaSourceConfig().claimLinkBase, normalizedToken, ) if (claimUrl) { return claimUrl } } return buildClaimUrl(normalizedToken) } 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, displayName: session.displayName || session.username, role: session.role, } : null, }, now, ) return { task: mapTaskActionPayload(updatedTask), claimUrl: await resolveAdminTaskClaimUrl(updatedTask, claimToken.token), 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 || ''), displayName: String(session.displayName || 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), } }