feat(cleaner): add per-order video recording retention
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { IDatabaseService } from '../../types/database.types'
|
||||
import { ErpAuthService } from '../erp/erp-auth'
|
||||
import { CleanerService } from '../erp/cleaner'
|
||||
@@ -11,6 +13,7 @@ import { ResultExporter } from '../excel/result-exporter'
|
||||
import { SessionManager } from '../user/session-manager'
|
||||
import { UserErpConfigService } from '../user/user-erp-config-service'
|
||||
import { createLogger } from '../logger'
|
||||
import { getLogDir } from '../logger/shared'
|
||||
import { logAuditWithCurrentUser } from '../logger/audit-logger'
|
||||
import { AuditAction, AuditStatus } from '../../types/audit.types'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
@@ -132,7 +135,8 @@ export class CleanerApplicationService {
|
||||
url: erpConfig.url,
|
||||
username: erpConfig.username,
|
||||
password: erpConfig.password,
|
||||
headless: input.headless ?? true
|
||||
headless: input.headless ?? true,
|
||||
recordVideoDir: input.recordVideo ? await this.prepareVideoTempDir(batchId, 1) : undefined
|
||||
})
|
||||
|
||||
log.info('Logging in to ERP...')
|
||||
@@ -159,6 +163,8 @@ export class CleanerApplicationService {
|
||||
const modifiedInput: CleanerInput = {
|
||||
...input,
|
||||
orderNumbers: validOrderNumbers,
|
||||
videoBatchId: batchId,
|
||||
videoAttemptNumber: 1,
|
||||
onProgress: (message, progress, extra) => {
|
||||
this.sendProgress(eventSender, message, progress ?? 0, extra)
|
||||
}
|
||||
@@ -180,6 +186,7 @@ export class CleanerApplicationService {
|
||||
|
||||
// Save attempt 1 result as crashed
|
||||
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
|
||||
await cleaner.finalizeVideoArtifacts(result.details)
|
||||
|
||||
this.sendProgress(eventSender, '流程崩溃,正在重新登录并重试...', 0, {
|
||||
phase: 'retry',
|
||||
@@ -200,7 +207,8 @@ export class CleanerApplicationService {
|
||||
url: erpConfig.url,
|
||||
username: erpConfig.username,
|
||||
password: erpConfig.password,
|
||||
headless: input.headless ?? true
|
||||
headless: input.headless ?? true,
|
||||
recordVideoDir: input.recordVideo ? await this.prepareVideoTempDir(batchId, 2) : undefined
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -238,10 +246,14 @@ export class CleanerApplicationService {
|
||||
}
|
||||
|
||||
cleaner = new CleanerService(authService)
|
||||
result = await cleaner.clean(modifiedInput)
|
||||
result = await cleaner.clean({
|
||||
...modifiedInput,
|
||||
videoAttemptNumber: 2
|
||||
})
|
||||
|
||||
// Save attempt 2 result
|
||||
await this.saveAttemptToDatabase(historyDao, batchId, 2, result)
|
||||
await cleaner.finalizeVideoArtifacts(result.details)
|
||||
|
||||
log.info('Outer retry completed', {
|
||||
batchId,
|
||||
@@ -252,6 +264,7 @@ export class CleanerApplicationService {
|
||||
} else {
|
||||
// No crash — save attempt 1 result
|
||||
await this.saveAttemptToDatabase(historyDao, batchId, 1, result)
|
||||
await cleaner.finalizeVideoArtifacts(result.details)
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
@@ -300,6 +313,18 @@ export class CleanerApplicationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareVideoTempDir(batchId: string, attemptNumber: number): Promise<string> {
|
||||
const tempDir = path.join(
|
||||
getLogDir(),
|
||||
'clearner-video-records',
|
||||
batchId,
|
||||
'.tmp',
|
||||
`attempt-${attemptNumber}`
|
||||
)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
return tempDir
|
||||
}
|
||||
|
||||
async exportResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
|
||||
log.info('Exporting validation results', { count: items.length })
|
||||
if (!items || items.length === 0) {
|
||||
|
||||
@@ -99,7 +99,8 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
},
|
||||
cleaner: {
|
||||
queryBatchSize: 100,
|
||||
processConcurrency: 1
|
||||
processConcurrency: 1,
|
||||
recordVideo: false
|
||||
},
|
||||
orderResolution: {
|
||||
tableName: '',
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
} from '../../types/cleaner.types'
|
||||
import type { ErpSession } from '../../types/erp.types'
|
||||
import type { FrameLocator, Locator, Page } from 'playwright'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { createLogger, run, trackDuration } from '../logger'
|
||||
import { getLogDir } from '../logger/shared'
|
||||
import { capturePageContext } from './erp-error-context'
|
||||
|
||||
const log = createLogger('CleanerService')
|
||||
@@ -39,6 +42,12 @@ interface QueryResultRow {
|
||||
orderNumber: string
|
||||
}
|
||||
|
||||
interface CleanerVideoArtifact {
|
||||
orderNumber: string
|
||||
sequence: number
|
||||
finalPath: string
|
||||
}
|
||||
|
||||
class AsyncMutex {
|
||||
private queue: Promise<void> = Promise.resolve()
|
||||
|
||||
@@ -176,6 +185,8 @@ export async function runWithConcurrency<T, R>(
|
||||
export class CleanerService {
|
||||
private authService: ErpAuthService
|
||||
private dryRun: boolean
|
||||
private readonly recordedVideos = new Map<string, CleanerVideoArtifact[]>()
|
||||
private readonly videoSequenceCounters = new Map<string, number>()
|
||||
|
||||
constructor(authService: ErpAuthService, options: CleanerOptions = {}) {
|
||||
this.authService = authService
|
||||
@@ -374,7 +385,10 @@ export class CleanerService {
|
||||
return page
|
||||
})
|
||||
|
||||
let detail: OrderCleanDetail
|
||||
let detail: OrderCleanDetail = this.createErrorDetail(
|
||||
orderNumber,
|
||||
'详情页处理未返回结果'
|
||||
)
|
||||
try {
|
||||
detail = await this.processDetailPage({
|
||||
detailPage: openedDetailPage,
|
||||
@@ -388,12 +402,14 @@ export class CleanerService {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
detail = this.createErrorDetail(orderNumber, message)
|
||||
} finally {
|
||||
progressState.ordersStarted += 1
|
||||
progressState.ordersCompleted += 1
|
||||
progressState.lastCompletedOrder = orderNumber
|
||||
lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间
|
||||
await this.captureRecordedVideo(openedDetailPage, input, detail?.orderNumber || orderNumber)
|
||||
}
|
||||
|
||||
progressState.ordersStarted += 1
|
||||
progressState.ordersCompleted += 1
|
||||
progressState.lastCompletedOrder = orderNumber
|
||||
lastActivityTime = Date.now() // [新增] 健康检查:更新活动时间
|
||||
|
||||
result.details.push(detail)
|
||||
|
||||
if (detail.errors.length > 0) {
|
||||
@@ -456,6 +472,7 @@ export class CleanerService {
|
||||
),
|
||||
deleteSet,
|
||||
dryRun,
|
||||
input,
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
|
||||
@@ -1678,13 +1695,14 @@ export class CleanerService {
|
||||
failedDetails: OrderCleanDetail[]
|
||||
deleteSet: Set<string>
|
||||
dryRun: boolean
|
||||
input: CleanerInput
|
||||
onProgress?: (
|
||||
message: string,
|
||||
progress?: number,
|
||||
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
|
||||
) => void
|
||||
}): Promise<RetryResult> {
|
||||
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, onProgress } = params
|
||||
const { workFrame, popupPage, failedDetails, deleteSet, dryRun, input, onProgress } = params
|
||||
|
||||
const result: RetryResult = {
|
||||
retriedOrders: 0,
|
||||
@@ -1802,6 +1820,7 @@ export class CleanerService {
|
||||
skipped: retryDetail.materialsSkipped,
|
||||
elapsedMs: Date.now() - attemptStartTime
|
||||
})
|
||||
await this.captureRecordedVideo(detailPage, input, retryDetail.orderNumber)
|
||||
|
||||
// Success - update counters
|
||||
retryResult.successfulRetries += 1
|
||||
@@ -1897,4 +1916,97 @@ export class CleanerService {
|
||||
|
||||
return trackedResult.result
|
||||
}
|
||||
|
||||
public async finalizeVideoArtifacts(details: OrderCleanDetail[]): Promise<void> {
|
||||
if (this.recordedVideos.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const detail of details) {
|
||||
const artifacts = this.recordedVideos.get(detail.orderNumber)
|
||||
if (!artifacts || artifacts.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (detail.errors.length === 0) {
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
await fs.unlink(artifact.finalPath)
|
||||
} catch (error) {
|
||||
log.warn('删除成功订单视频失败', {
|
||||
orderNumber: detail.orderNumber,
|
||||
filePath: artifact.finalPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.recordedVideos.delete(detail.orderNumber)
|
||||
continue
|
||||
}
|
||||
|
||||
log.info('保留失败订单视频', {
|
||||
orderNumber: detail.orderNumber,
|
||||
videoCount: artifacts.length,
|
||||
files: artifacts.map((artifact) => artifact.finalPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async captureRecordedVideo(
|
||||
page: Page,
|
||||
input: CleanerInput,
|
||||
orderNumber: string
|
||||
): Promise<void> {
|
||||
if (!input.recordVideo || !input.videoBatchId) {
|
||||
return
|
||||
}
|
||||
|
||||
const video = page.video()
|
||||
if (!video) {
|
||||
return
|
||||
}
|
||||
|
||||
const finalDir = this.getVideoBatchDir(input.videoBatchId)
|
||||
const normalizedOrderNumber = this.sanitizeVideoFileName(orderNumber || 'UNKNOWN_ORDER')
|
||||
const sequence = (this.videoSequenceCounters.get(normalizedOrderNumber) ?? 0) + 1
|
||||
this.videoSequenceCounters.set(normalizedOrderNumber, sequence)
|
||||
|
||||
const fileName =
|
||||
sequence === 1 ? `${normalizedOrderNumber}.webm` : `${normalizedOrderNumber}__${sequence}.webm`
|
||||
const finalPath = path.join(finalDir, fileName)
|
||||
|
||||
try {
|
||||
await fs.mkdir(finalDir, { recursive: true })
|
||||
await video.saveAs(finalPath)
|
||||
|
||||
const artifacts = this.recordedVideos.get(orderNumber) ?? []
|
||||
artifacts.push({
|
||||
orderNumber,
|
||||
sequence,
|
||||
finalPath
|
||||
})
|
||||
this.recordedVideos.set(orderNumber, artifacts)
|
||||
|
||||
log.info('订单视频已归档', {
|
||||
orderNumber,
|
||||
filePath: finalPath,
|
||||
sequence
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn('订单视频归档失败', {
|
||||
orderNumber,
|
||||
filePath: finalPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getVideoBatchDir(batchId: string): string {
|
||||
return path.join(getLogDir(), 'clearner-video-records', batchId)
|
||||
}
|
||||
|
||||
private sanitizeVideoFileName(value: string): string {
|
||||
return value.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chromium } from 'playwright'
|
||||
import * as fs from 'fs/promises'
|
||||
import type { ErpConfig, ErpSession } from '../../types/erp.types'
|
||||
import { createLogger } from '../logger'
|
||||
import { capturePageContext } from './erp-error-context'
|
||||
@@ -52,7 +53,15 @@ export class ErpAuthService {
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
ignoreHTTPSErrors: true, // Ignore SSL certificate errors
|
||||
// Disable web security for internal VPN
|
||||
javaScriptEnabled: true
|
||||
javaScriptEnabled: true,
|
||||
...(this.config.recordVideoDir
|
||||
? {
|
||||
recordVideo: {
|
||||
dir: this.config.recordVideoDir,
|
||||
size: { width: 1920, height: 1080 }
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
@@ -223,6 +232,17 @@ export class ErpAuthService {
|
||||
await this.session.context.close()
|
||||
await this.session.browser.close()
|
||||
this.session = null
|
||||
|
||||
if (this.config.recordVideoDir) {
|
||||
try {
|
||||
await fs.rm(this.config.recordVideoDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
log.warn('清理视频临时目录失败', {
|
||||
recordVideoDir: this.config.recordVideoDir,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface CleanerInput {
|
||||
headless?: boolean
|
||||
queryBatchSize?: number
|
||||
processConcurrency?: number
|
||||
recordVideo?: boolean
|
||||
videoBatchId?: string
|
||||
videoAttemptNumber?: number
|
||||
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
recordVideo: z.boolean().default(false)
|
||||
})
|
||||
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ErpConfig {
|
||||
username: string
|
||||
password: string
|
||||
headless?: boolean // Optional: override default headless setting
|
||||
recordVideoDir?: string
|
||||
}
|
||||
|
||||
export interface ErpSession {
|
||||
|
||||
@@ -10,6 +10,8 @@ interface CleanerExecutionBarProps {
|
||||
setHeadless: (value: boolean) => void
|
||||
processConcurrency: number
|
||||
updateProcessConcurrency: (value: number) => void
|
||||
recordVideo: boolean
|
||||
updateRecordVideo: (value: boolean) => void
|
||||
showSettingsMenu: boolean
|
||||
setShowSettingsMenu: (open: boolean) => void
|
||||
handleExecuteDeletion: () => Promise<void>
|
||||
@@ -26,6 +28,8 @@ export function CleanerExecutionBar({
|
||||
setHeadless,
|
||||
processConcurrency,
|
||||
updateProcessConcurrency,
|
||||
recordVideo,
|
||||
updateRecordVideo,
|
||||
showSettingsMenu,
|
||||
setShowSettingsMenu,
|
||||
handleExecuteDeletion,
|
||||
@@ -82,6 +86,22 @@ export function CleanerExecutionBar({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 pt-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-800">录制处理视频</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
为每个订单详情页生成视频,仅保留最终失败订单的视频
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void updateRecordVideo(!recordVideo)}
|
||||
className={`transition-colors flex-shrink-0 ml-4 ${recordVideo ? 'text-emerald-500' : 'text-slate-300'}`}
|
||||
>
|
||||
{recordVideo ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 pt-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-800">并行处理数量</div>
|
||||
|
||||
@@ -61,7 +61,8 @@ export async function loadCleanerConfig(): Promise<CleanerConfigResult | null> {
|
||||
|
||||
return {
|
||||
queryBatchSize: result.data.queryBatchSize,
|
||||
processConcurrency: result.data.processConcurrency
|
||||
processConcurrency: result.data.processConcurrency,
|
||||
recordVideo: result.data.recordVideo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +121,7 @@ export async function runCleanerExecution(params: {
|
||||
headless: boolean
|
||||
queryBatchSize: number
|
||||
processConcurrency: number
|
||||
recordVideo: boolean
|
||||
selectedManagers: string[]
|
||||
}): Promise<CleanerReportData> {
|
||||
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,
|
||||
recordVideo: params.recordVideo
|
||||
})
|
||||
|
||||
const cleanerRunData = response.success ? (response.data as CleanerRunPayload | null) : null
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface CleanerInitializationResult {
|
||||
export interface CleanerConfigResult {
|
||||
queryBatchSize: number
|
||||
processConcurrency: number
|
||||
recordVideo: boolean
|
||||
}
|
||||
|
||||
// Cleaner operation history types (mirrors preload/index.d.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 [recordVideo, setRecordVideo] = useState(false)
|
||||
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)
|
||||
setRecordVideo(result.recordVideo)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to load cleaner config', {
|
||||
@@ -169,6 +171,18 @@ export function useCleaner() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateRecordVideo = async (value: boolean) => {
|
||||
setRecordVideo(value)
|
||||
try {
|
||||
await window.electron.config.updateCleaner({ recordVideo: value })
|
||||
} catch (err) {
|
||||
logger.error('Failed to update record video setting', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('cleaner_validationMode', valMode)
|
||||
}, [valMode])
|
||||
@@ -377,6 +391,7 @@ export function useCleaner() {
|
||||
headless,
|
||||
queryBatchSize,
|
||||
processConcurrency,
|
||||
recordVideo,
|
||||
selectedManagers: Array.from(selectedManagers)
|
||||
})
|
||||
setReportData(result)
|
||||
@@ -441,6 +456,8 @@ export function useCleaner() {
|
||||
processConcurrency,
|
||||
setProcessConcurrency,
|
||||
updateProcessConcurrency,
|
||||
recordVideo,
|
||||
updateRecordVideo,
|
||||
showSettingsMenu,
|
||||
setShowSettingsMenu,
|
||||
filteredResults,
|
||||
|
||||
@@ -42,6 +42,8 @@ const CleanerPage: React.FC = () => {
|
||||
setHeadless,
|
||||
processConcurrency,
|
||||
updateProcessConcurrency,
|
||||
recordVideo,
|
||||
updateRecordVideo,
|
||||
showSettingsMenu,
|
||||
setShowSettingsMenu,
|
||||
filteredResults,
|
||||
@@ -126,6 +128,8 @@ const CleanerPage: React.FC = () => {
|
||||
setHeadless={setHeadless}
|
||||
processConcurrency={processConcurrency}
|
||||
updateProcessConcurrency={updateProcessConcurrency}
|
||||
recordVideo={recordVideo}
|
||||
updateRecordVideo={updateRecordVideo}
|
||||
showSettingsMenu={showSettingsMenu}
|
||||
setShowSettingsMenu={setShowSettingsMenu}
|
||||
handleExecuteDeletion={handleExecuteDeletion}
|
||||
|
||||
Reference in New Issue
Block a user