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 校验。
This commit is contained in:
yml2213
2026-10-11 14:53:56 +08:00
parent 06d888fb6f
commit b82f9c4063
20 changed files with 418 additions and 44 deletions
@@ -128,7 +128,9 @@ export function resolveOrderFulfillmentType({
fulfillmentType: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual'
canClaimH5: boolean
} {
const normProvider = String(provider || '').trim().toLowerCase()
const normProvider = String(provider || '')
.trim()
.toLowerCase()
const hasAutoClaimTask =
tasks.some(
(t) =>
@@ -139,7 +139,9 @@ export async function getAdminOrders(
}
}
function groupRowsByOrderId<T extends { order_id?: number | string | null }>(rows: T[]): Map<number, T[]> {
function groupRowsByOrderId<T extends { order_id?: number | string | null }>(
rows: T[],
): Map<number, T[]> {
const grouped = new Map<number, T[]>()
for (const row of rows) {
// pg BIGINT 常以 string 返回;统一 Number 键,避免列表挂不上 order_items →「商品 0」。
@@ -292,7 +292,9 @@ function resolveTaskDisplayRoleName(task: TaskRow): string {
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()
const name = String(
dash.roleName || dash.boundRoleName || dash.game_account_role_name || '',
).trim()
if (name) return name
}
const kuaishouCloud = context.kuaishouCloudFulfillment
@@ -314,11 +316,11 @@ function resolveTaskDisplayRoleId(task: TaskRow): string {
const dash = affiliateDash as Record<string, unknown>
const id = String(
dash.boundAccount ||
dash.gameAccount ||
dash.expectedGameAccount ||
dash.game_uid ||
dash.game_account ||
'',
dash.gameAccount ||
dash.expectedGameAccount ||
dash.game_uid ||
dash.game_account ||
'',
).trim()
if (id) return id
}
@@ -184,4 +184,3 @@ export function isMaskedSecretValue(value: unknown, currentSecret = ''): boolean
}
return false
}
@@ -1,12 +1,8 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getOrderClaimContext,
} from './kuaishou-cloud-claim-context.js'
import {
updateAdminOrderClaimStatus,
} from '../admin/write/order-actions.js'
import { getOrderClaimContext } from './kuaishou-cloud-claim-context.js'
import { updateAdminOrderClaimStatus } from '../admin/write/order-actions.js'
test('getOrderClaimContext: 缺少订单号时抛出 400 错误', async () => {
await assert.rejects(
@@ -1,7 +1,11 @@
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { getOrderItemById, listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
import { findTaskByClaimTokenId, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js'
import {
findTaskByClaimTokenId,
listTasksByOrderId,
updateTask,
} from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
@@ -28,7 +32,6 @@ export type ClaimContext = {
orderItem: OrderItemRow
}
export async function getClaimContext(token: unknown) {
const normalized = String(token || '').trim()
@@ -194,9 +197,7 @@ export async function getOrderClaimContext(orderNo: unknown): Promise<ClaimConte
})
}
const activeTask =
tasks.find((t) => !isTaskFinalStatus(t.task_status)) ||
tasks[tasks.length - 1]
const activeTask = tasks.find((t) => !isTaskFinalStatus(t.task_status)) || tasks[tasks.length - 1]
if (!activeTask) {
throw createHttpError('该订单暂无可用履约任务', {
@@ -205,9 +206,7 @@ export async function getOrderClaimContext(orderNo: unknown): Promise<ClaimConte
})
}
const orderItem =
items.find((it) => it.id === activeTask.order_item_id) ||
items[0]
const orderItem = items.find((it) => it.id === activeTask.order_item_id) || items[0]
if (!orderItem) {
throw createHttpError('订单商品明细缺失', {
@@ -227,7 +226,9 @@ export async function getOrderClaimContext(orderNo: unknown): Promise<ClaimConte
const claimToken: ClaimTokenRow = {
id: Number(activeTask.primary_claim_token_id || 0),
token: String(activeTask.primary_claim_token || activeTask.claim_token || `h5_${order.platform_order_id}`).trim(),
token: String(
activeTask.primary_claim_token || activeTask.claim_token || `h5_${order.platform_order_id}`,
).trim(),
status: 'active',
expired_at: activeTask.claim_expires_at || null,
created_at: activeTask.created_at,
@@ -177,4 +177,3 @@ export async function configureDouyinMashidaMessage(
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -246,7 +246,9 @@ async function notifyBuyerClaimLink(input: {
if (input.config.claimMessageEnabled === false) {
return
}
const claimLinkBase = String(input.config.claimLinkBase || '').trim().replace(/\/+$/, '')
const claimLinkBase = String(input.config.claimLinkBase || '')
.trim()
.replace(/\/+$/, '')
if (!claimLinkBase) {
return
}
@@ -140,7 +140,9 @@ export async function resolveDouyinMashidaCertLink(input: {
config: DouyinMashidaSourceConfig
}): Promise<string> {
const fallback = String(input.config.certLink || '').trim()
const claimLinkBase = String(input.config.claimLinkBase || '').trim().replace(/\/+$/, '')
const claimLinkBase = String(input.config.claimLinkBase || '')
.trim()
.replace(/\/+$/, '')
if (!claimLinkBase) {
return fallback
}
@@ -457,6 +457,12 @@ export function mapWorkOrderForWorker(
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
deadlineAt:
myShare && Number(workOrder.timeout_minutes || 0) > 0
? new Date(
new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000,
).toISOString()
: visibleOrder.deadlineAt,
workerNote,
myShare: myShare ? mapWorkOrderShare(myShare) : null,
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
@@ -11,6 +11,7 @@ import {
createWorkOrderEvent,
getWorkOrderShare,
settleOverdueWorkOrder,
settleOverdueWorkOrderShare,
submitWorkOrderShareAcceptance,
updateWorkOrder,
updateWorkOrderAcceptanceRecord,
@@ -410,6 +411,29 @@ async function submitWorkerSharingAcceptance(
})
}
const now = nowIso()
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
if (
sharingShare.status === 'joined' &&
timeoutMinutes > 0 &&
new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now()
) {
await settleOverdueWorkOrderShare({
workOrderId: workOrder.id,
shareId: sharingShare.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
now,
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(session.workerId)],
hallChanged: true,
})
publishWorkerWalletRealtimeChange(Number(session.workerId))
throw createHttpError('拼单任务已超时,系统已按超时策略处置', {
statusCode: 409,
errorCode: 'work_order_timeout_expired',
})
}
const worker = await getRequiredWorker(session.workerId)
const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
const { files, imageUrls, note } = resolveAcceptanceImages(
@@ -1,7 +1,9 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
listOverdueWorkOrders,
listOverdueWorkOrderShares,
settleOverdueWorkOrder,
settleOverdueWorkOrderShare,
} from '../../repositories/worker-platform/index.js'
import { nowIso } from '../../utils/time.js'
import {
@@ -11,10 +13,10 @@ import {
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
const overdue = await listOverdueWorkOrders({
limit: Math.max(1, Number(options.limit || 50)),
workerId: Number(options.workerId || 0),
})
const limit = Math.max(1, Number(options.limit || 50))
const workerId = Number(options.workerId || 0)
const overdue = await listOverdueWorkOrders({ limit, workerId })
let processedCount = 0
let skippedCount = 0
for (const workOrder of overdue) {
@@ -39,8 +41,33 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
skippedCount += 1
}
}
// 处置超时未提交材料的拼单份额(status=joined)
const overdueShares = await listOverdueWorkOrderShares({ limit, workerId })
for (const shareItem of overdueShares) {
const result = await settleOverdueWorkOrderShare({
workOrderId: shareItem.workOrderId,
shareId: shareItem.shareId,
policy: normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy),
now: nowIso(),
})
if (result.share) {
processedCount += 1
publishWorkOrderRealtimeChange({
workOrderId: Number(shareItem.workOrderId),
workerIds: Number(shareItem.workerId) ? [Number(shareItem.workerId)] : [],
hallChanged: true,
})
if (Number(shareItem.workerId)) {
publishWorkerWalletRealtimeChange(Number(shareItem.workerId))
}
} else {
skippedCount += 1
}
}
return {
checkedCount: overdue.length,
checkedCount: overdue.length + overdueShares.length,
processedCount,
skippedCount,
}
@@ -3059,3 +3059,68 @@ test('已冻结打手调级或变更资料不应因未传 freezeReason 报错',
'必须区分是否为新冻结,防止已冻结账号调级时抛出 freeze_reason 必填错误',
)
})
test('mapWorkOrderForWorker 拼单工单正确计算个人份额截止时间', () => {
const workOrder = buildWorkOrderRow({
product_name: '指挥官秘钥15个',
status: 'open',
sharing_enabled: true,
sharing_total_quantity: 15,
sharing_unit_reward: 700,
timeout_minutes: 120,
deadline_at: null,
})
const share = buildWorkOrderShareRow({
created_at: '2026-08-18T09:09:08.000Z',
})
const mapped = mapWorkOrderForWorker(
workOrder,
resolveWorkerPermissions(buildWorkerUserRow()),
share,
)
assert.equal(mapped.deadlineAt, '2026-08-18T11:09:08.000Z')
})
test('拼单超时处置链路完整性验证', async () => {
const { readFile } = await import('node:fs/promises')
const timeoutSrc = await readFile(
new URL('./worker-order-timeout-service.ts', import.meta.url),
'utf8',
)
assert.ok(
timeoutSrc.includes('listOverdueWorkOrderShares') &&
timeoutSrc.includes('settleOverdueWorkOrderShare'),
'settleOverdueWorkOrders 必须同时扫描并处置超时拼单份额',
)
const acceptanceSrc = await readFile(
new URL('./worker-acceptance-service.ts', import.meta.url),
'utf8',
)
assert.ok(
acceptanceSrc.includes('settleOverdueWorkOrderShare') &&
acceptanceSrc.includes('work_order_timeout_expired'),
'submitWorkerSharingAcceptance 必须在提交验收时拦截超时份额并处置',
)
const queryRepoSrc = await readFile(
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
queryRepoSrc.includes('listOverdueWorkOrderShares') &&
queryRepoSrc.includes("wos.status = 'joined'"),
'listOverdueWorkOrderShares 必须查询超时且未提交材料的 joined 份额',
)
const settlementRepoSrc = await readFile(
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
settlementRepoSrc.includes('settleOverdueWorkOrderShare') &&
settlementRepoSrc.includes("SET status = 'cancelled'"),
'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金',
)
})