From ac4379012705f710335dcaf69b2de0771e0695b6 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Fri, 24 Apr 2026 15:40:47 +0800 Subject: [PATCH] Add cleaner session refresh and ERP diagnostics --- config.template.yaml | 1 + src/main/schemas/cleaner.schema.ts | 3 +- .../cleaner/cleaner-application-service.ts | 11 +- src/main/services/config/config-manager.ts | 3 +- src/main/services/erp/cleaner.ts | 511 ++++++++++++++++-- src/main/services/erp/erp-auth.ts | 9 + src/main/services/erp/page-state.ts | 214 ++++++++ src/main/types/cleaner.types.ts | 1 + src/main/types/config.schema.ts | 3 +- src/renderer/src/hooks/cleaner/api.ts | 7 +- src/renderer/src/hooks/cleaner/types.ts | 1 + src/renderer/src/hooks/useCleaner.ts | 5 + 12 files changed, 719 insertions(+), 50 deletions(-) create mode 100644 src/main/services/erp/page-state.ts diff --git a/config.template.yaml b/config.template.yaml index 760da7b..7dce15e 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -65,6 +65,7 @@ orderResolution: cleaner: queryBatchSize: 100 processConcurrency: 1 + sessionRefreshOrderThreshold: 160 # 会在 batch 边界检查;达到或超过阈值后,在当前 batch 完成后重建浏览器会话 logging: level: info diff --git a/src/main/schemas/cleaner.schema.ts b/src/main/schemas/cleaner.schema.ts index 66d0247..cfc1df2 100644 --- a/src/main/schemas/cleaner.schema.ts +++ b/src/main/schemas/cleaner.schema.ts @@ -14,7 +14,8 @@ export const CleanerInputSchema = z.object({ materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')), dryRun: z.boolean(), queryBatchSize: z.number().int().min(1).max(100).optional().default(100), - processConcurrency: z.number().int().min(1).max(20).optional().default(1) + processConcurrency: z.number().int().min(1).max(20).optional().default(1), + sessionRefreshOrderThreshold: z.number().int().positive().optional().default(160) // Note: onProgress is a function, not validated via Zod }) diff --git a/src/main/services/cleaner/cleaner-application-service.ts b/src/main/services/cleaner/cleaner-application-service.ts index 31f4bfc..b7ff5f0 100644 --- a/src/main/services/cleaner/cleaner-application-service.ts +++ b/src/main/services/cleaner/cleaner-application-service.ts @@ -148,6 +148,9 @@ export class CleanerApplicationService { log.info('Login successful') const totalOrders = validOrderNumbers.length + const cleanerConfig = configManager.getConfig().cleaner + const effectiveSessionRefreshOrderThreshold = + input.sessionRefreshOrderThreshold ?? cleanerConfig.sessionRefreshOrderThreshold this.sendProgress(eventSender, 'ERP 登录成功', (1 / (1 + totalOrders)) * 100, { phase: 'login', currentOrderIndex: 0, @@ -159,6 +162,7 @@ export class CleanerApplicationService { const modifiedInput: CleanerInput = { ...input, orderNumbers: validOrderNumbers, + sessionRefreshOrderThreshold: effectiveSessionRefreshOrderThreshold, onProgress: (message, progress, extra) => { this.sendProgress(eventSender, message, progress ?? 0, extra) } @@ -168,7 +172,8 @@ export class CleanerApplicationService { batchId, orderCount: validOrderNumbers.length, queryBatchSize: input.queryBatchSize ?? 100, - processConcurrency: input.processConcurrency ?? 1 + processConcurrency: input.processConcurrency ?? 1, + sessionRefreshOrderThreshold: effectiveSessionRefreshOrderThreshold }) let cleaner = new CleanerService(authService) @@ -450,12 +455,16 @@ export class CleanerApplicationService { : result.errors.length > 0 ? AuditStatus.FAILURE : AuditStatus.SUCCESS + const cleanerConfig = ConfigManager.getInstance().getConfig().cleaner + const effectiveSessionRefreshOrderThreshold = + input.sessionRefreshOrderThreshold ?? cleanerConfig.sessionRefreshOrderThreshold logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, { orderCount, dryRun: input.dryRun ?? false, queryBatchSize: input.queryBatchSize ?? 100, processConcurrency: input.processConcurrency ?? 1, + sessionRefreshOrderThreshold: effectiveSessionRefreshOrderThreshold, materialsDeleted: result.materialsDeleted, materialsSkipped: result.materialsSkipped, errorCount: result.errors.length diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 5b8b861..a113125 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -99,7 +99,8 @@ const DEFAULT_CONFIG: FullConfig = { }, cleaner: { queryBatchSize: 100, - processConcurrency: 1 + processConcurrency: 1, + sessionRefreshOrderThreshold: 160 }, orderResolution: { tableName: '', diff --git a/src/main/services/erp/cleaner.ts b/src/main/services/erp/cleaner.ts index c99dcd3..ff9ec75 100644 --- a/src/main/services/erp/cleaner.ts +++ b/src/main/services/erp/cleaner.ts @@ -8,9 +8,10 @@ import type { OrderCleanDetail } from '../../types/cleaner.types' import type { ErpSession } from '../../types/erp.types' -import type { FrameLocator, Locator, Page } from 'playwright' +import type { BrowserContext, FrameLocator, Locator, Page } from 'playwright' import { createLogger, run, trackDuration } from '../logger' import { capturePageContext } from './erp-error-context' +import { capturePageState } from './page-state' const log = createLogger('CleanerService') @@ -18,6 +19,7 @@ const DEFAULT_QUERY_BATCH_SIZE = 100 const MAX_QUERY_BATCH_SIZE = 100 const DEFAULT_PROCESS_CONCURRENCY = 1 const MAX_PROCESS_CONCURRENCY = 20 +const DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD = 160 interface RetryResult { retriedOrders: number @@ -67,37 +69,86 @@ class ConcurrencyTracker { private activeWorkers = 0 private waitQueue = 0 private mutexWaitCount = 0 + private currentOwnerWorkerId: number | null = null + private currentOwnerOrderNumber: string | null = null + private waitStartedAt = new Map() - workerStarted() { + workerStarted(workerId: number, orderNumber: string) { this.activeWorkers++ log.verbose('[CONCURRENCY] Worker started', { + workerId, + orderNumber, activeWorkers: this.activeWorkers, waitQueue: this.waitQueue, waitingForPopupMutex: this.mutexWaitCount > 0 }) } - workerCompleted() { + workerCompleted(workerId: number, orderNumber: string) { this.activeWorkers-- log.verbose('[CONCURRENCY] Worker completed', { + workerId, + orderNumber, activeWorkers: this.activeWorkers, queueRemaining: this.waitQueue }) } - waitingForMutex() { + waitingForMutex(workerId: number, orderNumber: string) { this.mutexWaitCount++ + this.waitQueue++ + this.waitStartedAt.set(workerId, Date.now()) log.warn('[CONCURRENCY] Worker waiting for popup mutex', { + workerId, + orderNumber, mutexWaitCount: this.mutexWaitCount, - activeWorkers: this.activeWorkers + activeWorkers: this.activeWorkers, + queueDepth: this.waitQueue, + currentOwnerWorkerId: this.currentOwnerWorkerId, + currentOwnerOrderNumber: this.currentOwnerOrderNumber }) } - acquiredMutex() { + acquiredMutex(workerId: number, orderNumber: string) { + const waitStartedAt = this.waitStartedAt.get(workerId) + const waitDurationMs = waitStartedAt ? Date.now() - waitStartedAt : 0 + this.waitStartedAt.delete(workerId) this.mutexWaitCount-- + this.waitQueue = Math.max(0, this.waitQueue - 1) + this.currentOwnerWorkerId = workerId + this.currentOwnerOrderNumber = orderNumber log.verbose('[CONCURRENCY] Worker acquired popup mutex', { + workerId, + orderNumber, mutexWaitCount: this.mutexWaitCount, - activeWorkers: this.activeWorkers + activeWorkers: this.activeWorkers, + waitDurationMs, + queueDepth: this.waitQueue + }) + + if (waitDurationMs > 5000) { + log.warn('[CONCURRENCY] Popup mutex slow wait', { + workerId, + orderNumber, + waitDurationMs, + activeWorkers: this.activeWorkers, + queueDepth: this.waitQueue, + currentOwnerWorkerId: this.currentOwnerWorkerId, + currentOwnerOrderNumber: this.currentOwnerOrderNumber + }) + } + } + + releasedMutex(workerId: number, orderNumber: string) { + if (this.currentOwnerWorkerId === workerId) { + this.currentOwnerWorkerId = null + this.currentOwnerOrderNumber = null + } + log.verbose('[CONCURRENCY] Worker released popup mutex', { + workerId, + orderNumber, + activeWorkers: this.activeWorkers, + queueDepth: this.waitQueue }) } } @@ -148,20 +199,20 @@ export function getMissingOrders(inputOrders: string[], processedOrders: Set( items: T[], concurrency: number, - worker: (item: T, index: number) => Promise + worker: (item: T, index: number, workerId: number) => Promise ): Promise { const results = new Array(items.length) const limit = Math.max(1, Math.trunc(concurrency)) let cursor = 0 - const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + const runners = Array.from({ length: Math.min(limit, items.length) }, async (_, workerId) => { while (true) { const current = cursor cursor += 1 if (current >= items.length) { return } - results[current] = await worker(items[current], current) + results[current] = await worker(items[current], current, workerId) } }) @@ -262,6 +313,10 @@ export class CleanerService { 1, MAX_PROCESS_CONCURRENCY ) + const sessionRefreshOrderThreshold = + input.sessionRefreshOrderThreshold && input.sessionRefreshOrderThreshold > 0 + ? Math.trunc(input.sessionRefreshOrderThreshold) + : DEFAULT_SESSION_REFRESH_ORDER_THRESHOLD log.info('Starting cleaner', { totalOrders, @@ -269,6 +324,7 @@ export class CleanerService { dryRun, queryBatchSize, processConcurrency, + sessionRefreshOrderThreshold, orderNumbers: input.orderNumbers, materialCodes: input.materialCodes }) @@ -281,12 +337,13 @@ export class CleanerService { const session = this.authService.getSession() const navigation = await this.navigateToCleanerPage(session) popupPage = navigation.popupPage - const { workFrame } = navigation + let { workFrame } = navigation - await this.setupQueryInterface(workFrame) + await this.setupQueryInterface(workFrame, popupPage) const orderBatches = createBatches(input.orderNumbers, queryBatchSize) const popupMutex = new AsyncMutex() + let ordersProcessedSinceLogin = 0 const progressState: ProgressState = { ordersStarted: 0, ordersCompleted: 0, @@ -329,7 +386,7 @@ export class CleanerService { await trackDuration( async () => { // Phase 1: Query orders - await trackDuration(async () => await this.queryOrders(workFrame, batchOrders), { + await trackDuration(async () => await this.queryOrders(workFrame, popupPage!, batchOrders), { operationName: 'query', message: '执行订单查询', slowThresholdMs: 3000, @@ -345,7 +402,7 @@ export class CleanerService { // Phase 3: Collect query results const collectResult = await trackDuration( - async () => await this.collectQueryResultRows(workFrame), + async () => await this.collectQueryResultRows(workFrame, popupPage!), { operationName: 'collect_results', message: '收集查询结果', @@ -358,20 +415,29 @@ export class CleanerService { // Phase 4: Process all orders in batch await trackDuration( async () => { - await runWithConcurrency(queriedRows, processConcurrency, async (row) => { + await runWithConcurrency(queriedRows, processConcurrency, async (row, _index, workerId) => { const { rowIndex, orderNumber } = row // [新增] Worker 开始追踪 - tracker.workerStarted() + tracker.workerStarted(workerId, orderNumber) try { const openedDetailPage = await popupMutex.runExclusive(async () => { // [新增] Mutex 等待追踪 - tracker.waitingForMutex() - const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex) - // [新增] Mutex 获取追踪 - tracker.acquiredMutex() - return page + tracker.waitingForMutex(workerId, orderNumber) + try { + const page = await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex, { + orderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + workerId + }) + // [新增] Mutex 获取追踪 + tracker.acquiredMutex(workerId, orderNumber) + return page + } finally { + tracker.releasedMutex(workerId, orderNumber) + } }) let detail: OrderCleanDetail @@ -408,7 +474,7 @@ export class CleanerService { result.uncertainDeletions += detail.uncertainDeletions } finally { // [新增] Worker 完成追踪 - tracker.workerCompleted() + tracker.workerCompleted(workerId, orderNumber) } }) @@ -446,6 +512,51 @@ export class CleanerService { // [新增] 清理健康检查定时器 clearInterval(healthCheckInterval) + + ordersProcessedSinceLogin += batchOrders.length + const remainingBatches = orderBatches.length - (batchIndex + 1) + log.info('[SESSION_REFRESH_CHECK] 批次完成,检查是否需要重建会话', { + batchIndex: batchIndex + 1, + totalBatches: orderBatches.length, + batchSize: batchOrders.length, + ordersProcessedSinceLogin, + threshold: sessionRefreshOrderThreshold, + remainingBatches + }) + + if (remainingBatches > 0 && ordersProcessedSinceLogin >= sessionRefreshOrderThreshold) { + log.info('[SESSION_REFRESH_TRIGGERED] 达到阈值,准备重建会话', { + batchIndex: batchIndex + 1, + totalBatches: orderBatches.length, + batchSize: batchOrders.length, + ordersProcessedSinceLogin, + threshold: sessionRefreshOrderThreshold, + remainingBatches + }) + + const refreshedNavigation = await this.refreshSessionAtBatchBoundary({ + batchIndex: batchIndex + 1, + totalBatches: orderBatches.length, + batchSize: batchOrders.length, + threshold: sessionRefreshOrderThreshold, + ordersProcessedSinceLogin, + totalOrders, + completedOrders: progressState.ordersCompleted + }) + + popupPage = refreshedNavigation.popupPage + workFrame = refreshedNavigation.workFrame + ordersProcessedSinceLogin = 0 + } else if (remainingBatches === 0 && ordersProcessedSinceLogin >= sessionRefreshOrderThreshold) { + log.info('[SESSION_REFRESH_SKIPPED] 已达到阈值但无剩余批次,跳过重建', { + batchIndex: batchIndex + 1, + totalBatches: orderBatches.length, + batchSize: batchOrders.length, + ordersProcessedSinceLogin, + threshold: sessionRefreshOrderThreshold, + remainingBatches + }) + } } const retryResult = await this.retryFailedOrders({ @@ -556,6 +667,10 @@ export class CleanerService { elapsedMs: Date.now() - navStartTime, popupOpened: !!popupPage }) + await this.logPageStateSnapshot(popupPage, 'nav.popup_opened', { + level: 'info', + elapsedMs: Date.now() - navStartTime + }) // Step 3: Get forward frame log.debug('[导航 Step 3] 获取 forwardFrame 框架') @@ -612,6 +727,10 @@ export class CleanerService { log.debug('[导航 Step 5] 等待页面就绪标志', { selector: '#hot-key-head_list', timeout: 30000 }) await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 }) const totalNavTime = Date.now() - navStartTime + await this.logPageStateSnapshot(popupPage, 'nav.cleaner_page_ready', { + level: 'info', + elapsedMs: totalNavTime + }) log.info('[导航完成] 已导航到清理页面', { totalNavTimeMs: totalNavTime, isSlow: totalNavTime > 5000 @@ -620,9 +739,13 @@ export class CleanerService { return { popupPage, workFrame } } - private async setupQueryInterface(innerFrame: FrameLocator): Promise { + private async setupQueryInterface(innerFrame: FrameLocator, popupPage: Page): Promise { const setupStartTime = Date.now() log.debug('[查询界面设置开始] 准备配置查询界面') + await this.logPageStateSnapshot(popupPage, 'query.setup.start', { + level: 'debug', + elapsedMs: 0 + }) // Step 1: Click search icon log.debug('[查询设置 Step 1] 点击搜索图标') @@ -647,6 +770,10 @@ export class CleanerService { await inputEl.fill('5000') await inputEl.press('Enter') const totalSetupTime = Date.now() - setupStartTime + await this.logPageStateSnapshot(popupPage, 'query.setup.ready', { + level: 'info', + elapsedMs: totalSetupTime + }) log.debug('[查询设置完成] 查询界面配置完毕', { totalSetupTimeMs: totalSetupTime, queryLimit: 5000, @@ -654,7 +781,11 @@ export class CleanerService { }) } - private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise { + private async queryOrders( + workFrame: FrameLocator, + popupPage: Page, + orderNumbers: string[] + ): Promise { const queryStartTime = Date.now() log.debug('[订单查询开始]', { orderCount: orderNumbers.length, @@ -663,6 +794,10 @@ export class CleanerService { .concat(orderNumbers.length > 5 ? [`... (${orderNumbers.length - 5} more)`] : []), isPreview: orderNumbers.length > 5 }) + await this.logPageStateSnapshot(popupPage, 'query.before_submit', { + level: 'debug', + elapsedMs: 0 + }) const textbox = workFrame.getByRole('textbox', { name: '生产订单号' }) log.debug('[订单查询] 准备填入订单号') @@ -678,9 +813,16 @@ export class CleanerService { elapsedMs: Date.now() - queryStartTime, orderCount: orderNumbers.length }) + await this.logPageStateSnapshot(popupPage, 'query.after_submit', { + level: 'info', + elapsedMs: Date.now() - queryStartTime + }) } - private async collectQueryResultRows(workFrame: FrameLocator): Promise { + private async collectQueryResultRows( + workFrame: FrameLocator, + popupPage: Page + ): Promise { const collectStartTime = Date.now() log.debug('[查询结果收集开始] 准备读取查询结果表格') @@ -707,6 +849,10 @@ export class CleanerService { } const totalCollectTime = Date.now() - collectStartTime + await this.logPageStateSnapshot(popupPage, 'query.results.collected', { + level: 'info', + elapsedMs: totalCollectTime + }) log.info('[查询结果收集完成]', { totalRowsScanned: rowCount, validOrderCount, @@ -754,37 +900,111 @@ export class CleanerService { private async openDetailPageFromRow( workFrame: FrameLocator, popupPage: Page, - rowIndex: number + rowIndex: number, + options?: { + orderNumber?: string + orderIndex?: number + orderPosition?: string + workerId?: number + } ): Promise { + const openStartTime = Date.now() const row = workFrame.locator('tbody tr').nth(rowIndex) await row.waitFor({ state: 'visible', timeout: 15000 }) + log.info('[NAV_EVENT] 准备从查询结果打开详情页', { + step: 'detail.open.from_query_row', + rowIndex, + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId + }) + await this.logPageStateSnapshot(popupPage, 'detail.open.from_query_row.before_click', { + level: 'debug', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId, + elapsedMs: Date.now() - openStartTime + }) const moreButton = row.locator('a.row-more').first() await moreButton.scrollIntoViewIfNeeded() const detailPagePromise = popupPage.waitForEvent('popup') await moreButton.click() - await this.clickMaterialPlanMenu(workFrame) + await this.clickMaterialPlanMenu(workFrame, popupPage, options) - return await detailPagePromise + const detailPage = await detailPagePromise + log.info('[POPUP_EVENT] 详情页弹窗已创建', { + step: 'detail.popup.opened', + rowIndex, + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId, + popupUrl: detailPage.url(), + elapsedMs: Date.now() - openStartTime + }) + await this.logPageStateSnapshot(detailPage, 'detail.popup.opened', { + level: 'info', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId, + elapsedMs: Date.now() - openStartTime + }) + return detailPage } private async openDetailPageFromCurrentQuery( workFrame: FrameLocator, - popupPage: Page + popupPage: Page, + orderNumber?: string ): Promise { + const openStartTime = Date.now() const firstRow = workFrame.locator('tbody tr').first() await firstRow.waitFor({ state: 'visible', timeout: 10000 }) + log.info('[NAV_EVENT] 准备从当前查询结果打开详情页', { + step: 'detail.open.from_current_query', + orderNumber + }) + await this.logPageStateSnapshot(popupPage, 'detail.open.from_current_query.before_click', { + level: 'debug', + orderNumber, + elapsedMs: Date.now() - openStartTime + }) const moreButton = firstRow.locator('a.row-more').first() const detailPagePromise = popupPage.waitForEvent('popup') await moreButton.click() - await this.clickMaterialPlanMenu(workFrame) + await this.clickMaterialPlanMenu(workFrame, popupPage, { orderNumber }) - return await detailPagePromise + const detailPage = await detailPagePromise + log.info('[POPUP_EVENT] 详情页弹窗已创建', { + step: 'detail.popup.opened.retry', + orderNumber, + popupUrl: detailPage.url(), + elapsedMs: Date.now() - openStartTime + }) + await this.logPageStateSnapshot(detailPage, 'detail.popup.opened.retry', { + level: 'info', + orderNumber, + elapsedMs: Date.now() - openStartTime + }) + return detailPage } - private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise { + private async clickMaterialPlanMenu( + workFrame: FrameLocator, + popupPage: Page, + options?: { + orderNumber?: string + orderIndex?: number + orderPosition?: string + workerId?: number + } + ): Promise { const candidates = [ workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({ hasText: /^备料计划$/ @@ -798,7 +1018,21 @@ export class CleanerService { const target = candidate.last() try { await target.waitFor({ state: 'visible', timeout: 2000 }) + log.info('[NAV_EVENT] 点击备料计划菜单', { + step: 'detail.menu.material_plan', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId + }) await target.click() + await this.logPageStateSnapshot(popupPage, 'detail.menu.material_plan.clicked', { + level: 'debug', + orderNumber: options?.orderNumber, + orderIndex: options?.orderIndex, + orderPosition: options?.orderPosition, + workerId: options?.workerId + }) return } catch { // Try next locator candidate @@ -811,6 +1045,106 @@ export class CleanerService { throw new Error('无法定位”备料计划”菜单项(可能菜单结构已变化)') } + private getBrowserContext(): BrowserContext | undefined { + try { + return this.authService.getSession().context + } catch { + return undefined + } + } + + private async logPageStateSnapshot( + page: Page, + step: string, + options: { + level?: 'info' | 'warn' | 'error' | 'debug' + orderNumber?: string + orderIndex?: number + orderPosition?: string + workerId?: number + elapsedMs?: number + includeFrameHierarchy?: boolean + includeBodyTextPreview?: boolean + } = {} + ) { + const pageState = await capturePageState(page, this.getBrowserContext(), { + includeFrameHierarchy: options.includeFrameHierarchy, + includeBodyTextPreview: options.includeBodyTextPreview + }) + + const payload = { + step, + orderNumber: options.orderNumber, + orderIndex: options.orderIndex, + orderPosition: options.orderPosition, + workerId: options.workerId, + elapsedMs: options.elapsedMs, + ...pageState + } + + const level = options.level ?? 'info' + log[level]('[PAGE_STATE] 页面状态快照', payload) + return payload + } + + private async ensureNotRedirectedToLoginPage( + page: Page, + step: string, + expectedOrderNumber: string | undefined, + progressState: ProgressState, + elapsedMs: number + ): Promise { + const pageState = await capturePageState(page, this.getBrowserContext(), { + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + + if (!pageState.isCasLoginRedirect && pageState.pageKind !== 'login') { + return + } + + log.error('[SESSION_LOST] 会话跳转到 CAS 登录页', { + step, + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs, + detectedBy: pageState.isCasLoginRedirect ? 'url_match' : 'login_form_detected', + ...pageState + }) + + throw new Error('ERP 会话已跳转到登录页') + } + + private async refreshSessionAtBatchBoundary(params: { + batchIndex: number + totalBatches: number + batchSize: number + threshold: number + ordersProcessedSinceLogin: number + totalOrders: number + completedOrders: number + }): Promise<{ popupPage: Page; workFrame: FrameLocator }> { + const refreshStartTime = Date.now() + + log.info('[SESSION_REFRESH_START] 开始关闭浏览器并重新登录', { + ...params + }) + + await this.authService.close() + + const session = await this.authService.login() + const navigation = await this.navigateToCleanerPage(session) + await this.setupQueryInterface(navigation.workFrame, navigation.popupPage) + + log.info('[SESSION_REFRESH_SUCCESS] 会话重建成功', { + ...params, + elapsedMs: Date.now() - refreshStartTime + }) + + return navigation + } + private async processDetailPage(params: { detailPage: Page deleteSet: Set @@ -861,16 +1195,40 @@ export class CleanerService { } try { + await this.logPageStateSnapshot(detailPage, 'detail.page.opened', { + level: 'debug', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime + }) + // Step 1: Access forward frame log.debug('[详情页面 Step 1] 准备访问 forwardFrame') + await this.ensureNotRedirectedToLoginPage( + detailPage, + 'detail.step1.forwardFrame', + expectedOrderNumber, + progressState, + Date.now() - processStartTime + ) const detailMainFrame = detailPage.locator('#forwardFrame') const dFrame = await detailMainFrame.contentFrame() if (!dFrame) { - const errorMsg = '无法访问详情页面的 forwardFrame' - log.error('[详情页面失败] forwardFrame 访问失败', { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step1.forwardFrame', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, elapsedMs: Date.now() - processStartTime, - pageUrl: detailPage.url(), + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + const errorMsg = '无法访问详情页面的 forwardFrame' + log.error('[DETAIL_PAGE_INVALID] 详情页未建立', { + failureKind: 'forward_frame_missing', + ...pageState, contextData: await capturePageContext( detailPage, undefined, @@ -888,15 +1246,49 @@ export class CleanerService { // Step 2: Access inner frame log.debug('[详情页面 Step 2] 等待并获取 mainiframe 内部框架', { timeout: 30000 }) + await this.ensureNotRedirectedToLoginPage( + detailPage, + 'detail.step2.mainiframe', + expectedOrderNumber, + progressState, + Date.now() - processStartTime + ) const detailInnerLocator = dFrame.locator('#mainiframe') - await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 }) + try { + await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 }) + } catch (error) { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step2.mainiframe', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime, + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', { + failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'mainiframe_missing', + ...pageState, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } const detailInnerFrame = await detailInnerLocator.contentFrame() if (!detailInnerFrame) { - const errorMsg = '无法访问详情页面的内部框架' - log.error('[详情页面失败] 内部框架访问失败', { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step2.detailInnerFrame', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, elapsedMs: Date.now() - processStartTime, - pageUrl: detailPage.url(), + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + const errorMsg = '无法访问详情页面的内部框架' + log.error('[DETAIL_PAGE_INVALID] 详情页未建立', { + failureKind: 'mainiframe_missing', + ...pageState, contextData: await capturePageContext( detailPage, undefined, @@ -917,9 +1309,36 @@ export class CleanerService { selector: '离散备料计划维护', timeout: 30000 }) - await detailInnerFrame - .getByText(/^离散备料计划维护:/) - .waitFor({ state: 'visible', timeout: 30000 }) + await this.ensureNotRedirectedToLoginPage( + detailPage, + 'detail.step3.header', + expectedOrderNumber, + progressState, + Date.now() - processStartTime + ) + try { + await detailInnerFrame.getByText(/^离散备料计划维护:/).waitFor({ + state: 'visible', + timeout: 30000 + }) + } catch (error) { + const pageState = await this.logPageStateSnapshot(detailPage, 'detail.step3.header', { + level: 'error', + orderNumber: expectedOrderNumber, + orderIndex: progressState.ordersStarted, + orderPosition: `${progressState.ordersStarted + 1}/${progressState.totalOrders}`, + elapsedMs: Date.now() - processStartTime, + includeFrameHierarchy: true, + includeBodyTextPreview: true + }) + log.error('[DETAIL_PAGE_TIMEOUT] 详情页等待超时', { + failureKind: pageState.isCasLoginRedirect ? 'redirected_to_cas' : 'detail_header_missing', + expectedMarker: '离散备料计划维护:', + ...pageState, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } log.debug('[详情页面 Step 3 完成] 页面标题已显示', { elapsedMs: Date.now() - processStartTime }) @@ -1743,7 +2162,7 @@ export class CleanerService { try { // Step 1: Re-query order log.debug('[重试查询] 重新查询订单', { orderNumber, attempt }) - await this.queryOrders(workFrame, [orderNumber]) + await this.queryOrders(workFrame, popupPage, [orderNumber]) await this.waitForLoading(workFrame) log.debug('[重试查询完成] 查询加载完成', { orderNumber, @@ -1768,7 +2187,11 @@ export class CleanerService { // Step 3: Open detail page log.debug('[重试详情] 打开订单详情页', { orderNumber, attempt }) - const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage) + const detailPage = await this.openDetailPageFromCurrentQuery( + workFrame, + popupPage, + orderNumber + ) log.debug('[重试详情] 详情页已打开', { orderNumber, attempt, diff --git a/src/main/services/erp/erp-auth.ts b/src/main/services/erp/erp-auth.ts index 5ba502e..c6cca49 100644 --- a/src/main/services/erp/erp-auth.ts +++ b/src/main/services/erp/erp-auth.ts @@ -3,6 +3,7 @@ import type { ErpConfig, ErpSession } from '../../types/erp.types' import { createLogger } from '../logger' import { capturePageContext } from './erp-error-context' import { attachPageDiagnostics, attachContextDiagnostics } from './page-diagnostics' +import { capturePageState } from './page-state' const log = createLogger('ErpAuthService') @@ -64,6 +65,10 @@ export class ErpAuthService { await page.goto(loginUrl) log.debug('已导航到登录页面') + log.info('[PAGE_STATE] 页面状态快照', { + step: 'auth.login_page_loaded', + ...(await capturePageState(page, context)) + }) // Wait for page to load await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT }) @@ -156,6 +161,10 @@ export class ErpAuthService { }) await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame) + log.info('[PAGE_STATE] 页面状态快照', { + step: 'auth.login_result_confirmed', + ...(await capturePageState(page, context, { includeFrameHierarchy: true })) + }) // Create session with mainFrame (Python returns main_frame as part of login result) this.session = { diff --git a/src/main/services/erp/page-state.ts b/src/main/services/erp/page-state.ts new file mode 100644 index 0000000..a95dada --- /dev/null +++ b/src/main/services/erp/page-state.ts @@ -0,0 +1,214 @@ +import type { BrowserContext, Page } from 'playwright' + +export type ErpPageKind = 'login' | 'home' | 'query' | 'detail' | 'cas_login' | 'unknown' + +export interface CapturePageStateOptions { + includeFrameHierarchy?: boolean + includeBodyTextPreview?: boolean + bodyTextPreviewLength?: number +} + +export interface ErpPageState { + pageUrl?: string + pageTitle?: string + pageKind: ErpPageKind + hasForwardFrame: boolean + hasMainIframe: boolean + hasLoginForm: boolean + hasWorkbenchMarker: boolean + hasQueryMarker: boolean + hasDetailHeader: boolean + isCasLoginRedirect: boolean + frameCount?: number + popupCount?: number + visibleMarkers?: string[] + frameHierarchy?: Array<{ name: string; url: string }> + bodyTextPreview?: string +} + +async function safePageUrl(page: Page): Promise { + try { + return page.url() + } catch { + return undefined + } +} + +async function safePageTitle(page: Page): Promise { + try { + const title = await page.title() + return title.slice(0, 200) + } catch { + return undefined + } +} + +async function safeFrameCount(page: Page): Promise { + try { + return page.frames().length + } catch { + return undefined + } +} + +async function safePopupCount(context?: BrowserContext): Promise { + try { + return context?.pages().length + } catch { + return undefined + } +} + +async function safeLocatorExists(locator: ReturnType): Promise { + try { + return (await locator.count()) > 0 + } catch { + return false + } +} + +async function safeBodyPreview(page: Page, maxLength: number): Promise { + try { + const text = await page.locator('body').innerText({ timeout: 1000 }) + return text.replace(/\s+/g, ' ').trim().slice(0, maxLength) + } catch { + return undefined + } +} + +export async function capturePageState( + page: Page, + context?: BrowserContext, + options: CapturePageStateOptions = {} +): Promise { + const pageUrl = await safePageUrl(page) + const pageTitle = await safePageTitle(page) + const isCasLoginRedirect = !!pageUrl?.includes('euc.yonyoucloud.com/cas/login') + + let hasForwardFrame = false + let hasMainIframe = false + let hasLoginForm = false + let hasWorkbenchMarker = false + let hasQueryMarker = false + let hasDetailHeader = false + + try { + hasForwardFrame = await safeLocatorExists(page.locator('#forwardFrame')) + } catch { + hasForwardFrame = false + } + + let forwardFrame: Awaited['contentFrame']>> | null = null + if (hasForwardFrame) { + try { + forwardFrame = await page.locator('#forwardFrame').contentFrame() + } catch { + forwardFrame = null + } + } + + if (forwardFrame) { + try { + hasMainIframe = (await forwardFrame.locator('#mainiframe').count()) > 0 + } catch { + hasMainIframe = false + } + + try { + hasWorkbenchMarker = (await forwardFrame.locator('.nc-workbench-icon').count()) > 0 + } catch { + hasWorkbenchMarker = false + } + + try { + hasLoginForm = + (await forwardFrame.getByRole('textbox', { name: '用户名' }).count()) > 0 || + (await forwardFrame.getByRole('textbox', { name: '密码' }).count()) > 0 + } catch { + hasLoginForm = false + } + } + + let innerFrame: Awaited< + ReturnType['locator']>['contentFrame']> + > | null = null + if (forwardFrame && hasMainIframe) { + try { + innerFrame = await forwardFrame.locator('#mainiframe').contentFrame() + } catch { + innerFrame = null + } + } + + if (innerFrame) { + try { + hasQueryMarker = + (await innerFrame.getByText('订单号查询').count()) > 0 || + (await innerFrame.locator('#rc_select_0').count()) > 0 + } catch { + hasQueryMarker = false + } + + try { + hasDetailHeader = (await innerFrame.getByText(/^离散备料计划维护:/).count()) > 0 + } catch { + hasDetailHeader = false + } + } + + if (!hasLoginForm) { + try { + hasLoginForm = + (await page.getByRole('textbox', { name: '用户名' }).count()) > 0 || + (await page.getByRole('textbox', { name: '密码' }).count()) > 0 + } catch { + hasLoginForm = false + } + } + + const visibleMarkers: string[] = [] + if (isCasLoginRedirect) visibleMarkers.push('cas_login_url') + if (hasLoginForm) visibleMarkers.push('login_form') + if (hasWorkbenchMarker) visibleMarkers.push('workbench_icon') + if (hasQueryMarker) visibleMarkers.push('query_marker') + if (hasDetailHeader) visibleMarkers.push('detail_header') + if (hasForwardFrame) visibleMarkers.push('forwardFrame') + if (hasMainIframe) visibleMarkers.push('mainiframe') + + let pageKind: ErpPageKind = 'unknown' + if (isCasLoginRedirect) pageKind = 'cas_login' + else if (hasLoginForm) pageKind = 'login' + else if (hasDetailHeader) pageKind = 'detail' + else if (hasQueryMarker) pageKind = 'query' + else if (hasWorkbenchMarker) pageKind = 'home' + + const state: ErpPageState = { + pageUrl, + pageTitle, + pageKind, + hasForwardFrame, + hasMainIframe, + hasLoginForm, + hasWorkbenchMarker, + hasQueryMarker, + hasDetailHeader, + isCasLoginRedirect, + frameCount: await safeFrameCount(page), + popupCount: await safePopupCount(context), + visibleMarkers + } + + if (options.includeFrameHierarchy) { + try { + state.frameHierarchy = page.frames().map((frame) => ({ name: frame.name(), url: frame.url() })) + } catch { + state.frameHierarchy = undefined + } + } + + if (options.includeBodyTextPreview) { + state.bodyTextPreview = await safeBodyPreview(page, options.bodyTextPreviewLength ?? 500) + } + + return state +} diff --git a/src/main/types/cleaner.types.ts b/src/main/types/cleaner.types.ts index 6e99de0..5c73cc7 100644 --- a/src/main/types/cleaner.types.ts +++ b/src/main/types/cleaner.types.ts @@ -18,6 +18,7 @@ export interface CleanerInput { headless?: boolean queryBatchSize?: number processConcurrency?: number + sessionRefreshOrderThreshold?: number onProgress?: (message: string, progress?: number, extra?: Partial) => void } diff --git a/src/main/types/config.schema.ts b/src/main/types/config.schema.ts index c016782..c1ec970 100644 --- a/src/main/types/config.schema.ts +++ b/src/main/types/config.schema.ts @@ -117,7 +117,8 @@ export const validationConfigSchema = z.object({ */ export const cleanerConfigSchema = z.object({ queryBatchSize: z.number().int().min(1).max(100).default(100), - processConcurrency: z.number().int().min(1).max(20).default(1) + processConcurrency: z.number().int().min(1).max(20).default(1), + sessionRefreshOrderThreshold: z.number().int().positive().default(160) }) export type CleanerConfig = z.infer diff --git a/src/renderer/src/hooks/cleaner/api.ts b/src/renderer/src/hooks/cleaner/api.ts index 28df043..a05489d 100644 --- a/src/renderer/src/hooks/cleaner/api.ts +++ b/src/renderer/src/hooks/cleaner/api.ts @@ -61,7 +61,8 @@ export async function loadCleanerConfig(): Promise { return { queryBatchSize: result.data.queryBatchSize, - processConcurrency: result.data.processConcurrency + processConcurrency: result.data.processConcurrency, + sessionRefreshOrderThreshold: result.data.sessionRefreshOrderThreshold } } @@ -120,6 +121,7 @@ export async function runCleanerExecution(params: { headless: boolean queryBatchSize: number processConcurrency: number + sessionRefreshOrderThreshold: number selectedManagers: string[] }): Promise { const cleanerDataResult = await window.electron.validation.getCleanerData({ @@ -149,7 +151,8 @@ export async function runCleanerExecution(params: { dryRun: params.dryRun, headless: params.headless, queryBatchSize: params.queryBatchSize, - processConcurrency: params.processConcurrency + processConcurrency: params.processConcurrency, + sessionRefreshOrderThreshold: params.sessionRefreshOrderThreshold }) const cleanerRunData = response.success ? (response.data as CleanerRunPayload | null) : null diff --git a/src/renderer/src/hooks/cleaner/types.ts b/src/renderer/src/hooks/cleaner/types.ts index 4aed1d8..59d3db6 100644 --- a/src/renderer/src/hooks/cleaner/types.ts +++ b/src/renderer/src/hooks/cleaner/types.ts @@ -60,6 +60,7 @@ export interface CleanerInitializationResult { export interface CleanerConfigResult { queryBatchSize: number processConcurrency: number + sessionRefreshOrderThreshold: number } // Cleaner operation history types (mirrors preload/index.d.ts) diff --git a/src/renderer/src/hooks/useCleaner.ts b/src/renderer/src/hooks/useCleaner.ts index 3882ca9..e152dc1 100644 --- a/src/renderer/src/hooks/useCleaner.ts +++ b/src/renderer/src/hooks/useCleaner.ts @@ -62,6 +62,7 @@ export function useCleaner() { const [headless, setHeadless] = useState(() => getStoredBoolean('cleaner_headless', true)) const [queryBatchSize, setQueryBatchSize] = useState(100) const [processConcurrency, setProcessConcurrency] = useState(1) + const [sessionRefreshOrderThreshold, setSessionRefreshOrderThreshold] = useState(160) const [showSettingsMenu, setShowSettingsMenu] = useState(false) // Inline editing state for manager field (Admin only) @@ -138,6 +139,7 @@ export function useCleaner() { if (result) { setQueryBatchSize(result.queryBatchSize) setProcessConcurrency(result.processConcurrency) + setSessionRefreshOrderThreshold(result.sessionRefreshOrderThreshold) } } catch (err) { logger.error('Failed to load cleaner config', { @@ -377,6 +379,7 @@ export function useCleaner() { headless, queryBatchSize, processConcurrency, + sessionRefreshOrderThreshold, selectedManagers: Array.from(selectedManagers) }) setReportData(result) @@ -440,6 +443,8 @@ export function useCleaner() { setQueryBatchSize, processConcurrency, setProcessConcurrency, + sessionRefreshOrderThreshold, + setSessionRefreshOrderThreshold, updateProcessConcurrency, showSettingsMenu, setShowSettingsMenu,