refactor: extract cleaner retry handler

This commit is contained in:
Misaka
2026-04-28 21:22:09 +08:00
parent 9c8d010055
commit 3cfd3229ff
2 changed files with 271 additions and 216 deletions

View File

@@ -10,6 +10,7 @@ import { AsyncMutex, ConcurrencyTracker } from './cleaner/concurrency'
import { evaluateDeletionSignals as evaluateDeletionSignalsHelper } from './cleaner/deletion-signals'
import { MaterialDeletionVerifier } from './cleaner/material-deletion-verifier'
import { CleanerNavigation } from './cleaner/navigation'
import { CleanerRetryHandler } from './cleaner/retry-handler'
import { getSkipReason, shouldDeleteMaterial } from './cleaner/rules'
import {
clampNumber,
@@ -46,6 +47,7 @@ export class CleanerService {
private dryRun: boolean
private deletionVerifier: MaterialDeletionVerifier
private navigation: CleanerNavigation
private retryHandler: CleanerRetryHandler
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
this.authService = authService
@@ -54,6 +56,14 @@ export class CleanerService {
this.navigation = new CleanerNavigation(log, (page, step, snapshotOptions) =>
this.logPageStateSnapshot(page, step, snapshotOptions)
)
this.retryHandler = new CleanerRetryHandler(log, {
queryOrders: (workFrame, popupPage, orderNumbers) =>
this.queryOrders(workFrame, popupPage, orderNumbers),
waitForLoading: (workFrame) => this.waitForLoading(workFrame),
openDetailPageFromCurrentQuery: (workFrame, popupPage, orderNumber) =>
this.openDetailPageFromCurrentQuery(workFrame, popupPage, orderNumber),
processDetailPage: (params) => this.processDetailPage(params)
})
}
/**
@@ -1290,221 +1300,6 @@ export class CleanerService {
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<RetryResult> {
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
const result: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
if (failedDetails.length === 0) {
log.info('[重试机制] 没有需要重试的订单', { checkedCount: failedDetails.length })
return result
}
const retryStartTime = Date.now()
log.info('[重试机制开始] 准备重试失败的订单', {
totalFailures: failedDetails.length,
failedOrders: failedDetails
.map((d) => d.orderNumber)
.slice(0, 5)
.concat(failedDetails.length > 5 ? [`... (${failedDetails.length - 5} more)`] : [])
})
const MAX_RETRIES = 2
// Track overall retry process duration
const trackedResult = await trackDuration(
async () => {
const retryResult: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
const failedDetail = failedDetails[detailIndex]
const orderNumber = failedDetail.orderNumber
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
const retryLoopStartTime = Date.now()
log.info('[重试订单] 开始处理失败订单的重试', {
orderNumber,
failureReason: failedDetail.errors.join('; '),
index: detailIndex + 1,
total: failedDetails.length
})
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const attemptStartTime = Date.now()
log.info('[重试尝试] 开始第 {attempt} 次重试', {
orderNumber,
attempt,
maxRetries: MAX_RETRIES,
elapsedMs: attemptStartTime - retryLoopStartTime,
interpolatedAttempt: attempt
})
try {
// Step 1: Re-query order
log.debug('[重试查询] 重新查询订单', { orderNumber, attempt })
await this.queryOrders(workFrame, popupPage, [orderNumber])
await this.waitForLoading(workFrame)
log.debug('[重试查询完成] 查询加载完成', {
orderNumber,
attempt,
elapsedMs: Date.now() - attemptStartTime
})
// Step 2: Check query results
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
const errorMsg = '订单重试查询无结果'
log.error('[重试失败] 重试查询返回空结果', {
orderNumber,
attempt,
rowCount,
elapsedMs: Date.now() - attemptStartTime
})
throw new Error(errorMsg)
}
log.debug('[重试查询] 查询到 {rowCount} 行结果', { orderNumber, attempt, rowCount })
// Step 3: Open detail page
log.debug('[重试详情] 打开订单详情页', { orderNumber, attempt })
const detailPage = await this.openDetailPageFromCurrentQuery(
workFrame,
popupPage,
orderNumber
)
log.debug('[重试详情] 详情页已打开', {
orderNumber,
attempt,
elapsedMs: Date.now() - attemptStartTime
})
// Step 4: Process detail page
log.debug('[重试处理] 开始处理详情页', { orderNumber, attempt })
const retryDetail = await this.processDetailPage({
detailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState: {
ordersStarted: detailIndex,
ordersCompleted: 0,
totalOrders: failedDetails.length
},
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
progress,
extra ? { ...extra, phase: 'processing' as const } : undefined
)
}
})
log.info('[重试处理完成] 详情页处理完毕', {
orderNumber,
attempt,
deleted: retryDetail.materialsDeleted,
skipped: retryDetail.materialsSkipped,
elapsedMs: Date.now() - attemptStartTime
})
// Success - update counters
retryResult.successfulRetries += 1
retryResult.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
retryResult.retriedOrders += 1
log.info('[重试成功] 订单重试成功', {
orderNumber,
attempt,
cumulativeSuccesses: retryResult.successfulRetries,
cumulativeRetried: retryResult.retriedOrders,
deletedMaterials: retryDetail.materialsDeleted,
elapsedMs: Date.now() - retryLoopStartTime
})
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const attemptElapsed = Date.now() - attemptStartTime
log.warn('[重试失败] 当前重试尝试失败', {
orderNumber,
attempt,
maxRetries: MAX_RETRIES,
error: message,
elapsedMs: attemptElapsed,
remainingAttempts: MAX_RETRIES - attempt
})
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
if (attempt === MAX_RETRIES) {
log.error('[重试彻底失败] 所有重试均已失败', {
orderNumber,
totalAttempts: MAX_RETRIES,
errors: retryAttempts.map((a) => a.error),
elapsedMs: Date.now() - retryLoopStartTime,
finalOutcome: 'exhausted_all_retries',
successRate: `${((retryResult.successfulRetries / (detailIndex + 1)) * 100).toFixed(1)}%`
})
retryResult.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
retryResult.retriedOrders += 1
}
}
}
}
const totalRetryTime = Date.now() - retryStartTime
const finalSuccessRate =
failedDetails.length > 0
? ((retryResult.successfulRetries / failedDetails.length) * 100).toFixed(1) + '%'
: 'N/A'
const avgTimePerRetry = totalRetryTime / (failedDetails.length || 1)
log.info('[重试机制完成] 所有重试订单处理完毕', {
totalRetryOrders: failedDetails.length,
successfulRetries: retryResult.successfulRetries,
failedRetries: failedDetails.length - retryResult.successfulRetries,
successRate: finalSuccessRate,
totalElapsedTimeMs: totalRetryTime,
avgTimePerRetry,
isSlow: totalRetryTime > 30000
})
return retryResult
},
{
operationName: 'retry-failed-orders',
message: 'Retry failed orders',
slowThresholdMs: 5000,
context: {
totalRetryOrders: failedDetails.length,
dryRun
}
}
)
return trackedResult.result
return this.retryHandler.retryFailedOrders(params)
}
}

View File

@@ -0,0 +1,260 @@
import type { FrameLocator, Page } from 'playwright'
import type { CleanerProgress, OrderCleanDetail, RetryAttempt } from '../../../types/cleaner.types'
import { trackDuration } from '../../logger'
import type { ProgressState, RetryResult } from './types'
type CleanerLogger = {
debug: (message: string, meta?: Record<string, unknown>) => void
info: (message: string, meta?: Record<string, unknown>) => void
warn: (message: string, meta?: Record<string, unknown>) => void
error: (message: string, meta?: Record<string, unknown>) => void
}
export interface CleanerRetryDependencies {
queryOrders: (workFrame: FrameLocator, popupPage: Page, orderNumbers: string[]) => Promise<void>
waitForLoading: (workFrame: FrameLocator) => Promise<void>
openDetailPageFromCurrentQuery: (
workFrame: FrameLocator,
popupPage: Page,
orderNumber?: string
) => Promise<Page>
processDetailPage: (params: {
detailPage: Page
deleteSet: Set<string>
dryRun: boolean
progressState: ProgressState
expectedOrderNumber?: string
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
}) => Promise<OrderCleanDetail>
}
export class CleanerRetryHandler {
private static readonly MAX_RETRIES = 2
constructor(
private readonly log: CleanerLogger,
private readonly dependencies: CleanerRetryDependencies
) {}
async retryFailedOrders(params: {
workFrame: FrameLocator
popupPage: Page
failedDetails: OrderCleanDetail[]
deleteSet: Set<string>
dryRun: boolean
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
}): Promise<RetryResult> {
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
const result: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
if (failedDetails.length === 0) {
this.log.info('[重试机制] 没有需要重试的订单', { checkedCount: failedDetails.length })
return result
}
const retryStartTime = Date.now()
this.log.info('[重试机制开始] 准备重试失败的订单', {
totalFailures: failedDetails.length,
failedOrders: failedDetails
.map((detail) => detail.orderNumber)
.slice(0, 5)
.concat(failedDetails.length > 5 ? [`... (${failedDetails.length - 5} more)`] : [])
})
const trackedResult = await trackDuration(
async () => {
const retryResult: RetryResult = {
retriedOrders: 0,
successfulRetries: 0,
updatedDetails: []
}
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
const failedDetail = failedDetails[detailIndex]
const orderNumber = failedDetail.orderNumber
const retryAttempts: RetryAttempt[] = []
const retryLoopStartTime = Date.now()
this.log.info('[重试订单] 开始处理失败订单的重试', {
orderNumber,
failureReason: failedDetail.errors.join('; '),
index: detailIndex + 1,
total: failedDetails.length
})
for (let attempt = 1; attempt <= CleanerRetryHandler.MAX_RETRIES; attempt++) {
const attemptStartTime = Date.now()
this.log.info('[重试尝试] 开始第 {attempt} 次重试', {
orderNumber,
attempt,
maxRetries: CleanerRetryHandler.MAX_RETRIES,
elapsedMs: attemptStartTime - retryLoopStartTime,
interpolatedAttempt: attempt
})
try {
this.log.debug('[重试查询] 重新查询订单', { orderNumber, attempt })
await this.dependencies.queryOrders(workFrame, popupPage, [orderNumber])
await this.dependencies.waitForLoading(workFrame)
this.log.debug('[重试查询完成] 查询加载完成', {
orderNumber,
attempt,
elapsedMs: Date.now() - attemptStartTime
})
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
const errorMsg = '订单重试查询无结果'
this.log.error('[重试失败] 重试查询返回空结果', {
orderNumber,
attempt,
rowCount,
elapsedMs: Date.now() - attemptStartTime
})
throw new Error(errorMsg)
}
this.log.debug('[重试查询] 查询到 {rowCount} 行结果', {
orderNumber,
attempt,
rowCount
})
this.log.debug('[重试详情] 打开订单详情页', { orderNumber, attempt })
const detailPage = await this.dependencies.openDetailPageFromCurrentQuery(
workFrame,
popupPage,
orderNumber
)
this.log.debug('[重试详情] 详情页已打开', {
orderNumber,
attempt,
elapsedMs: Date.now() - attemptStartTime
})
this.log.debug('[重试处理] 开始处理详情页', { orderNumber, attempt })
const retryDetail = await this.dependencies.processDetailPage({
detailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState: {
ordersStarted: detailIndex,
ordersCompleted: 0,
totalOrders: failedDetails.length
},
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${CleanerRetryHandler.MAX_RETRIES}] ${message}`,
progress,
extra ? { ...extra, phase: 'processing' as const } : undefined
)
}
})
this.log.info('[重试处理完成] 详情页处理完毕', {
orderNumber,
attempt,
deleted: retryDetail.materialsDeleted,
skipped: retryDetail.materialsSkipped,
elapsedMs: Date.now() - attemptStartTime
})
retryResult.successfulRetries += 1
retryResult.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true,
retryAttempts
})
retryResult.retriedOrders += 1
this.log.info('[重试成功] 订单重试成功', {
orderNumber,
attempt,
cumulativeSuccesses: retryResult.successfulRetries,
cumulativeRetried: retryResult.retriedOrders,
deletedMaterials: retryDetail.materialsDeleted,
elapsedMs: Date.now() - retryLoopStartTime
})
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const attemptElapsed = Date.now() - attemptStartTime
this.log.warn('[重试失败] 当前重试尝试失败', {
orderNumber,
attempt,
maxRetries: CleanerRetryHandler.MAX_RETRIES,
error: message,
elapsedMs: attemptElapsed,
remainingAttempts: CleanerRetryHandler.MAX_RETRIES - attempt
})
retryAttempts.push({
attempt,
error: message,
timestamp: Date.now()
})
if (attempt === CleanerRetryHandler.MAX_RETRIES) {
this.log.error('[重试彻底失败] 所有重试均已失败', {
orderNumber,
totalAttempts: CleanerRetryHandler.MAX_RETRIES,
errors: retryAttempts.map((retryAttempt) => retryAttempt.error),
elapsedMs: Date.now() - retryLoopStartTime,
finalOutcome: 'exhausted_all_retries',
successRate: `${((retryResult.successfulRetries / (detailIndex + 1)) * 100).toFixed(1)}%`
})
retryResult.updatedDetails.push({
...failedDetail,
retryCount: CleanerRetryHandler.MAX_RETRIES,
retryAttempts,
retriedAt: Date.now(),
retrySuccess: false
})
retryResult.retriedOrders += 1
}
}
}
}
const totalRetryTime = Date.now() - retryStartTime
const finalSuccessRate =
failedDetails.length > 0
? `${((retryResult.successfulRetries / failedDetails.length) * 100).toFixed(1)}%`
: 'N/A'
const avgTimePerRetry = totalRetryTime / (failedDetails.length || 1)
this.log.info('[重试机制完成] 所有重试订单处理完毕', {
totalRetryOrders: failedDetails.length,
successfulRetries: retryResult.successfulRetries,
failedRetries: failedDetails.length - retryResult.successfulRetries,
successRate: finalSuccessRate,
totalElapsedTimeMs: totalRetryTime,
avgTimePerRetry,
isSlow: totalRetryTime > 30000
})
return retryResult
},
{
operationName: 'retry-failed-orders',
message: 'Retry failed orders',
slowThresholdMs: 5000,
context: {
totalRetryOrders: failedDetails.length,
dryRun
}
}
)
return trackedResult.result
}
}