fix: 免审验收容错与成本中心归一回填超时修复

- worker:等级升级计数移出验收事务,瞬时错误重试一次,最终失败转人工待办且不再返回 500
- worker:待验收阶段修改验收资料时保留 reviewMode/evidenceStatus/evidenceDueAt
- cost-center:迁移 107 新增人工待归一覆盖部分索引(生产实测候选聚合 19.6s → 391ms)
- cost-center:归一人工商品/按工单回填商品/模板预览前端超时放宽到 300s
- cost-center:批处理接口语句超时放宽到 60s(SET LOCAL,在线接口仍为 15s)
This commit is contained in:
yml2213
2026-10-29 14:33:35 +08:00
parent 814b357d46
commit 979a1bfa41
13 changed files with 617 additions and 68 deletions
@@ -106,6 +106,36 @@ export async function createWorkerAcceptanceAdminNotification(input: {
})
}
/**
* 免审核自动结算没跑完(多为库被回填/批量同步压到 statement_timeout):
* 验收资料已落库、订单留在待验收,给后台落一条高优先级待办,督促人工验收。
*/
export async function createWorkOrderAutoAcceptFailureAdminNotification(input: {
workOrderId: number
workOrderNo: string
productName: string
workerName: string
reason?: string
}) {
return createConfiguredWorkerPlatformNotification('acceptance_submitted', {
notificationType: 'worker_auto_accept_failed',
priority: 'high',
entityType: 'work_order',
entityId: input.workOrderId,
dedupeKey: `work_order_auto_accept_failed:${input.workOrderId}`,
title: 'VIP 免审自动验收失败,已转人工',
body: [
`订单:${input.workOrderNo || input.workOrderId}`,
`商品:${input.productName || '-'}`,
`打手:${input.workerName || '-'}`,
`原因:${input.reason || '结算超时'}`,
'处理:请在待验收列表人工验收',
].join(';'),
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
now: new Date().toISOString(),
})
}
/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */
export async function createWorkOrderFullyAcceptedAdminNotification(input: {
workOrderId: number
@@ -1,4 +1,7 @@
import { hasAcceptanceEvidence } from '../../domain/work-order-acceptance-evidence.js'
import {
hasAcceptanceEvidence,
resolveVipAutoAcceptEvidencePatch,
} from '../../domain/work-order-acceptance-evidence.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
isFriendGiftAcceptanceMode,
@@ -24,6 +27,8 @@ import {
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { retryOnTransientDbError } from '../../utils/db-retry.js'
import { logWarn } from '../../utils/logger.js'
import { addHours, nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
@@ -32,6 +37,7 @@ import {
} from '../file-storage/file-storage-service.js'
import {
createWorkerAcceptanceAdminNotification,
createWorkOrderAutoAcceptFailureAdminNotification,
getWorkerAcceptanceReminder,
remindWorkerAcceptanceAdminNotification,
} from '../admin/admin-notification-service.js'
@@ -205,7 +211,7 @@ export async function submitWorkerOrderAcceptance(
!isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
(worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence)
) {
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
const settlement = await settleAcceptanceWithRetry({
workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
rewardUnfreezeEnabled: getWorkerFinanceConfig().rewardUnfreezeEnabled,
@@ -213,18 +219,44 @@ export async function submitWorkerOrderAcceptance(
actorType: 'worker',
actorName: worker.display_name || worker.username || '',
})
if (failureReason === 'sharing_pending_submissions') {
if (settlement.failureReason === 'sharing_pending_submissions') {
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
statusCode: 409,
errorCode: 'work_order_sharing_pending_submissions',
})
}
if (!acceptedOrder) {
throw createHttpError('免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_internal_auto_accept_failed',
if (!settlement.acceptedOrder) {
// 并发下可能后台已经先验收了:确认一下,避免给后台落一条假的失败待办。
const latestOrder = settlement.error
? null
: await getRequiredWorkOrder(workOrder.id).catch(() => null)
if (latestOrder?.status === WORK_ORDER_STATUS.ACCEPTED) {
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
})
return {
order: mapWorkOrderForWorker(latestOrder, resolveWorkerPermissions(worker)),
}
}
// 自动结算没成功:验收资料已经落库,订单留在待验收。
// 这里转人工并给后台落待办,不再把 500 抛回打手(重试只会拿到 409)。
await handleAutoAcceptSettlementFailure({
workOrder,
workerName: worker.display_name || worker.username || '',
error: settlement.error,
scope: '[worker/orders/:workOrderId/submit-acceptance]',
})
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
workerIds: [Number(worker.id)],
})
return {
order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(worker)),
autoAcceptDeferred: true,
}
}
const acceptedOrder = settlement.acceptedOrder
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
@@ -265,6 +297,95 @@ export async function submitWorkerOrderAcceptance(
}
}
type AcceptanceSettlementResult = {
acceptedOrder: WorkOrderRow | null
failureReason: 'sharing_pending_submissions' | null
error: unknown
}
/**
* 免审结算遇到瞬时数据库错误(statement_timeout / 死锁)时重试一次。
*
* 结算整体在一个事务里,失败即回滚,重放不会重复入账;重试能扛过库被回填、
* 批量同步压住的短时抖动。
*/
async function settleAcceptanceWithRetry(
input: Parameters<typeof acceptWorkOrderAndSettle>[0],
): Promise<AcceptanceSettlementResult> {
try {
const result = await retryOnTransientDbError(() => acceptWorkOrderAndSettle(input), {
attempts: 2,
delayMs: 400,
onRetry: (error, nextAttempt) => {
logWarn('[worker/acceptance]', '免审结算遇到瞬时数据库错误,准备重试', {
workOrderId: input.workOrderId,
nextAttempt,
error: error instanceof Error ? error.message : String(error),
})
},
})
return { acceptedOrder: result.order, failureReason: result.failureReason, error: null }
} catch (error) {
return { acceptedOrder: null, failureReason: null, error }
}
}
async function settleShareAcceptanceWithRetry(
input: Parameters<typeof acceptWorkOrderShareAndSettle>[0],
): Promise<{
result: Awaited<ReturnType<typeof acceptWorkOrderShareAndSettle>> | null
error: unknown
}> {
try {
const result = await retryOnTransientDbError(() => acceptWorkOrderShareAndSettle(input), {
attempts: 2,
delayMs: 400,
onRetry: (error, nextAttempt) => {
logWarn('[worker/acceptance]', '拼单免审结算遇到瞬时数据库错误,准备重试', {
workOrderId: input.workOrderId,
shareId: input.shareId,
nextAttempt,
error: error instanceof Error ? error.message : String(error),
})
},
})
return { result, error: null }
} catch (error) {
return { result: null, error }
}
}
/** 自动结算最终失败:记日志 + 给后台落人工验收待办,不把 500 抛给打手。 */
async function handleAutoAcceptSettlementFailure(input: {
workOrder: WorkOrderRow
workerName: string
error: unknown
scope: string
shareId?: number | null
}) {
logWarn(input.scope, '免审核自动结算失败,已转人工验收', {
workOrderId: Number(input.workOrder.id),
...(input.shareId ? { shareId: Number(input.shareId) } : {}),
error: input.error instanceof Error ? input.error.message : undefined,
})
try {
const reason =
input.error instanceof Error && input.error.message ? input.error.message : undefined
await createWorkOrderAutoAcceptFailureAdminNotification({
workOrderId: Number(input.workOrder.id),
workOrderNo: input.workOrder.work_order_no,
productName: input.workOrder.product_name,
workerName: input.workerName,
...(reason ? { reason } : {}),
})
} catch (error) {
logWarn(input.scope, '免审失败待办创建失败', {
workOrderId: Number(input.workOrder.id),
error: error instanceof Error ? error.message : String(error),
})
}
}
/** 打手在提交验收后催促后台处理;频率和次数均由服务端控制。 */
export async function remindWorkerOrderAcceptance(
workOrderId: number | string,
@@ -537,7 +658,7 @@ async function submitWorkerSharingAcceptance(
})
}
if (canAutoAcceptWithoutEvidence) {
const accepted = await acceptWorkOrderShareAndSettle({
const settlement = await settleShareAcceptanceWithRetry({
workOrderId: Number(workOrder.id),
shareId: Number(result.share.id),
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
@@ -546,11 +667,24 @@ async function submitWorkerSharingAcceptance(
actorType: 'worker',
actorName: worker.display_name || worker.username || '',
})
if (!accepted.share || !accepted.order) {
throw createHttpError('VIP 免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_sharing_vip_auto_accept_failed',
const accepted = settlement.result
if (!accepted?.share || accepted.share.status !== 'accepted') {
// 份额验收资料已提交,结算失败时留在待人工验收,给后台落待办。
await handleAutoAcceptSettlementFailure({
workOrder,
workerName: worker.display_name || worker.username || '',
error: settlement.error,
scope: '[worker/orders/:workOrderId/submit-acceptance]',
shareId: Number(result.share.id),
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
return {
share: mapWorkOrderShare(result.share),
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
autoAcceptDeferred: true,
}
}
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
@@ -862,6 +996,10 @@ export async function saveWorkerOrderAcceptanceDraft(
errorCode: 'work_order_acceptance_image_required',
})
}
const evidencePatch = resolveVipAutoAcceptEvidencePatch(
previous,
hasAcceptanceEvidence({ imageUrls, video }),
)
const acceptance = {
note: String(payload.note ?? previous.note ?? '').trim(),
files,
@@ -869,6 +1007,7 @@ export async function saveWorkerOrderAcceptanceDraft(
...(video ? { video } : {}),
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
updatedAt: now,
...evidencePatch,
}
const updated = await updateWorkOrderAcceptanceRecord({
workOrderId: workOrder.id,
@@ -961,6 +1100,10 @@ async function saveWorkerSharingAcceptanceDraft(
errorCode: 'work_order_acceptance_image_required',
})
}
const evidencePatch = resolveVipAutoAcceptEvidencePatch(
previous,
hasAcceptanceEvidence({ imageUrls, video }),
)
const acceptance = {
note: String(payload.note ?? previous.note ?? '').trim(),
files,
@@ -968,6 +1111,7 @@ async function saveWorkerSharingAcceptanceDraft(
...(video ? { video } : {}),
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
updatedAt: now,
...evidencePatch,
}
const share = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),