Files
order_site/apps/backend/src/services/admin/write/task-actions.ts
T
yml2213 7542d547bf feat: 新增客服备用金台账与后台用户中文显示名
- 新增备用金账户、流水、支出、对账与权限
- 后台用户支持中文显示名并同步备用金姓名
- 审计、登录记录、任务及工单操作人优先展示中文名
- 补充备用金与人工支出方案文档
2026-10-18 13:55:24 +08:00

448 lines
13 KiB
TypeScript

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<AdminTaskActionResponse> {
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<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,
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<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,
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<TaskRow, 'order_id'>,
token: string,
): Promise<string> {
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<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,
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<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 || ''),
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),
}
}