- 新增备用金账户、流水、支出、对账与权限 - 后台用户支持中文显示名并同步备用金姓名 - 审计、登录记录、任务及工单操作人优先展示中文名 - 补充备用金与人工支出方案文档
448 lines
13 KiB
TypeScript
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),
|
|
}
|
|
}
|