Files
order_site/apps/backend/src/services/admin/admin-task-read-helpers.ts
T
yml2213 b82f9c4063 fix(worker-platform): 补齐拼单份额超时自动处置机制与退回大厅流程
- 根因排查:此前超时扫描仅针对整单 in_progress,而拼单母单为 open 且 deadline_at 为空,导致打手超时的拼单份额无法被定时扫描命中与释放;
- 定时扫描:在 settleOverdueWorkOrders 中引入 listOverdueWorkOrderShares,自动扫描超时未提交资料的拼单份额(joined);
- 超时处置:新增 settleOverdueWorkOrderShare 事务处理,按母单 timeout_policy 释放或扣除押金,份额标记 cancelled,母单保持或恢复 open 状态重回抢单大厅,记录 timeout 事件并推送实时变更;
- 提交防呆:在打手提交拼单验收时检测个人份额是否已超时,超时则立即执行处置并拦截提交;
- 倒计时展示:在 mapWorkOrderForWorker 中根据 myShare.created_at + timeout_minutes 为拼单打手计算并展示个人履约截止时间;
- 质量验证:补齐拼单截止时间计算与处置链路测试,全量 676 个测试均通过,通过 npm run check 校验。
2026-10-11 14:53:56 +08:00

340 lines
11 KiB
TypeScript

import { getTaskById } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { safeParseJson } from './admin-query-utils.js'
import { asJsonObject, type JsonRecord } from '../../types/json.js'
import {
TASK_STATUS,
isTaskFulfillmentCompletedStatus,
normalizeTaskStatus,
} from '../../domain/task-status.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import {
mergeAffiliateDashSkuMapping,
resolveAffiliateDashDisplayProductName,
} from '../platforms/affiliate-dash/sku-mapping.js'
import {
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
} from './admin-read-shared-helpers.js'
import type {
AdminOrderFulfillmentProgress,
AdminTaskListItem,
} from '../../types/admin/read-models.js'
import type { AdminTaskActionPayload } from '../../types/admin/write-models.js'
import type { TaskEventRow, TaskRow } from '../../types/repository/rows.js'
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
type TaskFulfillmentState = {
resourceStatus: string
customerStatus: string
}
export function mapAdminTaskSummary(task: TaskRow): JsonRecord {
const fulfillment = buildTaskFulfillmentState(task)
return {
taskId: task.id,
taskNo: task.task_no,
deliveryStatus: task.delivery_status || '',
status: task.task_status,
resourceStatus: fulfillment.resourceStatus,
customerStatus: fulfillment.customerStatus,
loginType: task.login_type,
runtimeSessionId: task.runtime_session_id,
claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at,
redeemedAt: task.redeemed_at,
lastError: task.last_error,
retryCount: getTaskRetryCount(task),
createdAt: task.created_at,
updatedAt: task.updated_at,
}
}
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
return {
taskId: task.id,
taskNo: task.task_no,
status: task.task_status,
deliveryStatus: task.delivery_status,
resultCode: task.result_code,
resultMessage: task.result_message,
primaryClaimTokenId,
lastError: task.last_error,
updatedAt: task.updated_at,
}
}
export async function getRequiredTask(taskId: number | string): Promise<TaskRow> {
const task = await getTaskById(Number(taskId))
if (!task) {
throw createHttpError('任务不存在', {
statusCode: 404,
errorCode: 'admin_task_not_found',
})
}
return task
}
export function mapAdminTaskListItem(
task: TaskRow,
viewerContext: AdminViewerContext = createAdminViewerContext(),
): AdminTaskListItem {
const fulfillment = buildTaskFulfillmentState(task)
const skuCode = String(task.sku_code || '').trim()
const rawSkuName = String(task.sku_name || '').trim()
const skuMapping = mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping)
return {
taskId: task.id,
taskNo: task.task_no,
platformOrderId: task.platform_order_id,
skuCode,
skuName: resolveAffiliateDashDisplayProductName(skuCode, rawSkuName, skuMapping),
status: task.task_status,
executorKey: task.executor_key || '',
deliveryStatus: task.delivery_status || '',
resultCode: task.result_code || '',
resultMessage: task.result_message || '',
resourceStatus: fulfillment.resourceStatus,
customerStatus: fulfillment.customerStatus,
loginType: task.login_type,
roleName: resolveTaskDisplayRoleName(task),
roleId: resolveTaskDisplayRoleId(task),
runtimeSessionId: task.runtime_session_id,
claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at,
redeemedAt: task.redeemed_at,
retryCount: getTaskRetryCount(task),
lastError: task.last_error,
createdAt: task.created_at,
updatedAt: task.updated_at,
claimToken: viewerContext.canViewSensitiveTaskData
? task.primary_claim_token || task.claim_token || ''
: '',
screenshotPath: viewerContext.role === 'support' ? '' : task.screenshot_path || '',
}
}
export function mapAdminTaskEvent(event: TaskEventRow): JsonRecord {
const payload = safeParseJson(event.payload_json)
return {
eventId: event.id,
eventType: String(event.event_type || '').trim(),
payload: normalizeRecord(payload),
createdAt: event.created_at,
}
}
export function buildOrderFulfillmentProgress(
tasks: TaskRow[] | null | undefined,
): AdminOrderFulfillmentProgress {
const normalizedTasks = Array.isArray(tasks) ? tasks : []
const totalTaskCount = normalizedTasks.length
const taskFulfillments = normalizedTasks.map((task) => buildTaskFulfillmentState(task))
const preparedTaskCount = normalizedTasks.filter((task) => isTaskResourcePrepared(task)).length
const completedTaskCount = normalizedTasks.filter((task) =>
isTaskFulfillmentCompleted(task),
).length
let resourceStatus = 'pending_prepare'
let customerStatus = 'not_started'
if (totalTaskCount === 0) {
return {
totalTaskCount,
preparedTaskCount,
completedTaskCount,
resourceStatus,
customerStatus,
}
}
if (normalizedTasks.every((task) => isTaskFulfillmentCompleted(task))) {
resourceStatus = 'resource_ready'
customerStatus = 'customer_completed'
} else if (
taskFulfillments.some((item) =>
['customer_processing', 'customer_confirmed', 'link_opened'].includes(item.customerStatus),
)
) {
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
customerStatus = 'customer_processing'
} else if (taskFulfillments.some((item) => item.customerStatus === 'waiting_customer')) {
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
customerStatus = 'waiting_customer'
} else if (taskFulfillments.some((item) => item.customerStatus === 'customer_exception')) {
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
customerStatus = 'customer_exception'
} else if (preparedTaskCount > 0) {
resourceStatus = 'resource_ready'
} else if (
taskFulfillments.some((item) =>
['manual_review', 'retry_pending'].includes(item.resourceStatus),
)
) {
resourceStatus = 'resource_exception'
}
return {
totalTaskCount,
preparedTaskCount,
completedTaskCount,
resourceStatus,
customerStatus,
}
}
function buildTaskFulfillmentState(task: TaskRow | null | undefined): TaskFulfillmentState {
const normalizedStatus = normalizeTaskStatus(task?.task_status)
if (!normalizedStatus) {
return {
resourceStatus: 'pending_prepare',
customerStatus: 'not_started',
}
}
if (normalizedStatus === TASK_STATUS.PENDING_PAYMENT) {
return { resourceStatus: 'pending_payment', customerStatus: 'not_started' }
}
if (normalizedStatus === TASK_STATUS.PAID) {
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
}
if (normalizedStatus === TASK_STATUS.PENDING_BINDING_PREPARE) {
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
}
if (normalizedStatus === TASK_STATUS.WAITING_BINDING) {
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
}
if (normalizedStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN) {
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
}
if (normalizedStatus === TASK_STATUS.COMPLETED) {
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
}
if (normalizedStatus === TASK_STATUS.MANUAL_REVIEW) {
return { resourceStatus: 'manual_review', customerStatus: 'customer_exception' }
}
if (normalizedStatus === TASK_STATUS.RETRY_PENDING) {
return { resourceStatus: 'retry_pending', customerStatus: 'customer_exception' }
}
if (normalizedStatus === TASK_STATUS.CLOSED) {
return { resourceStatus: 'closed', customerStatus: 'closed' }
}
if (normalizedStatus === TASK_STATUS.EXPIRED) {
return { resourceStatus: 'expired', customerStatus: 'expired' }
}
if (normalizedStatus === TASK_STATUS.LINK_GENERATED) {
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
}
if (normalizedStatus === TASK_STATUS.CLAIMED) {
return { resourceStatus: 'resource_ready', customerStatus: 'link_opened' }
}
if (normalizedStatus === TASK_STATUS.ROLE_CONFIRMED) {
return { resourceStatus: 'resource_ready', customerStatus: 'customer_confirmed' }
}
if (normalizedStatus === TASK_STATUS.REDEEMING) {
return { resourceStatus: 'resource_ready', customerStatus: 'customer_processing' }
}
if (normalizedStatus === TASK_STATUS.REDEEMED) {
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
}
if (isTaskResourcePrepared(task)) {
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
}
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
}
function isTaskResourcePrepared(task: TaskRow | null | undefined): boolean {
return Boolean(task && getTaskPrimaryClaimTokenId(task))
}
function isTaskFulfillmentCompleted(task: TaskRow | null | undefined): boolean {
return isTaskFulfillmentCompletedStatus(task?.task_status)
}
function normalizeRecord(value: unknown): JsonRecord {
return asJsonObject(value)
}
function getTaskRetryCount(task: TaskRow | null | undefined): number {
return Number(task?.attempt_count || 0)
}
function resolveTaskDisplayRoleName(task: TaskRow): string {
if (task.kuaishou_role_name || task.role_name) {
return String(task.kuaishou_role_name || task.role_name).trim()
}
const context = safeParseJson(task.context_json)
const affiliateDash = context.affiliateDash
if (affiliateDash && typeof affiliateDash === 'object') {
const dash = affiliateDash as Record<string, unknown>
const name = String(
dash.roleName || dash.boundRoleName || dash.game_account_role_name || '',
).trim()
if (name) return name
}
const kuaishouCloud = context.kuaishouCloudFulfillment
if (kuaishouCloud && typeof kuaishouCloud === 'object') {
const cloud = kuaishouCloud as Record<string, unknown>
const roleObj = cloud.role as Record<string, unknown> | undefined
if (roleObj?.name) return String(roleObj.name).trim()
}
return ''
}
function resolveTaskDisplayRoleId(task: TaskRow): string {
if (task.kuaishou_role_id || task.role_id) {
return String(task.kuaishou_role_id || task.role_id).trim()
}
const context = safeParseJson(task.context_json)
const affiliateDash = context.affiliateDash
if (affiliateDash && typeof affiliateDash === 'object') {
const dash = affiliateDash as Record<string, unknown>
const id = String(
dash.boundAccount ||
dash.gameAccount ||
dash.expectedGameAccount ||
dash.game_uid ||
dash.game_account ||
'',
).trim()
if (id) return id
}
const claimIdentity = context.claimIdentity
if (claimIdentity && typeof claimIdentity === 'object') {
const id = String((claimIdentity as Record<string, unknown>).expectedUid || '').trim()
if (id) return id
}
const kuaishouCloud = context.kuaishouCloudFulfillment
if (kuaishouCloud && typeof kuaishouCloud === 'object') {
const cloud = kuaishouCloud as Record<string, unknown>
const roleObj = cloud.role as Record<string, unknown> | undefined
if (roleObj?.rid) return String(roleObj.rid).trim()
}
return ''
}