# Cleaner 数据库持久化设计 ## 背景 Cleaner 当前使用 Markdown 文件做执行记录持久化,通过 RustFS 上传存储。存在以下问题: - 报告是非结构化文本,无法程序化查询和统计 - 历史记录无法按用户、时间、状态筛选 - 重试时依赖文件名去重,覆盖了首次执行的崩溃信息 - 前端需要通过 RustFS 下载报告再解析展示,链路长且脆弱 Extractor 已有成熟的数据库持久化模式(`ExtractorOperationHistory` 表 + DAO + 前端弹窗),Cleaner 应复用相同模式。 ## 设计决策 | 决策项 | 选择 | 理由 | |--------|------|------| | 表结构 | 独立建表,不与 Extractor 共用 | Cleaner 数据结构差异大(双层、物料级详情),独立更清晰 | | 记录粒度 | 执行 + 订单 + 物料三层 | 执行表存全局信息,订单表存订单汇总,物料表存操作明细 | | 批次标识 | `BatchId`(UUID),与 Extractor 一致 | 标准、简洁,不需要嵌入时间戳 | | 重试记录 | 不覆盖,每次尝试独立写入,用 `AttemptNumber` 区分 | 保留完整审计链,为后续智能跳过提供数据基础 | | 报告文件 | 移除 Markdown 报告和 RustFS 上传 | 数据库完全替代,报告相关代码(CleanerReportGenerator、generateAndUploadReport)删除 | | 前端历史 | 独立 CleanerOperationHistoryModal,复用 Extractor 的 UI 模式 | 放在 CleanerPage 上,与 Extractor 的"操作历史"按钮对齐 | ## 数据库表结构 所有表的 schema 为 `ERPAuto`。 ### 1. `CleanerExecution`(执行级) 全限定名:`ERPAuto.CleanerExecution` 一次清理操作(含重试)的全局信息。每次尝试一行记录。 | 列名 | 类型 | 说明 | |------|------|------| | ID | INT IDENTITY | 自增主键 | | BatchId | UNIQUEIDENTIFIER | 批次 ID,一次清理操作(含重试)共享 | | AttemptNumber | INT | 第几次尝试(1=首次,2=外层重试) | | UserId | INT | 操作用户 ID | | Username | NVARCHAR(255) | 操作用户名 | | OperationTime | DATETIME | 操作时间 | | EndTime | DATETIME | 结束时间 | | Status | NVARCHAR(50) | pending / success / failed / partial / crashed | | IsDryRun | BIT | 是否模拟运行 | | TotalOrders | INT | 订单总数 | | OrdersProcessed | INT | 已处理订单数 | | TotalMaterialsDeleted | INT | 总删除物料数 | | TotalMaterialsSkipped | INT | 总跳过物料数 | | TotalMaterialsFailed | INT | 总失败物料数 | | TotalUncertainDeletions | INT | 总不确定删除数 | | ErrorMessage | NVARCHAR(MAX) | 全局错误信息(如外层崩溃原因) | | AppVersion | NVARCHAR(20) | 应用版本号 | ### 2. `CleanerOrderHistory`(订单级) 全限定名:`ERPAuto.CleanerOrderHistory` 每个订单在每次尝试中的执行结果。每个订单每次尝试一行记录。 | 列名 | 类型 | 说明 | |------|------|------| | ID | INT IDENTITY | 自增主键 | | BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | | AttemptNumber | INT | 关联执行表 AttemptNumber | | OrderNumber | NVARCHAR(255) | 订单号 | | Status | NVARCHAR(50) | pending / success / failed | | MaterialsDeleted | INT | 删除物料数 | | MaterialsSkipped | INT | 跳过物料数 | | MaterialsFailed | INT | 删除失败物料数 | | UncertainDeletions | INT | 不确定删除数 | | RetryCount | INT | 内层重试次数 | | RetrySuccess | BIT | 内层重试是否成功 | | ErrorMessage | NVARCHAR(MAX) | 错误信息 | 关联方式:`BatchId + AttemptNumber` 关联执行表。 ### 3. `CleanerMaterialDetail`(物料级) 全限定名:`ERPAuto.CleanerMaterialDetail` 每个物料在每次尝试中的操作明细。 | 列名 | 类型 | 说明 | |------|------|------| | ID | INT IDENTITY | 自增主键 | | BatchId | UNIQUEIDENTIFIER | 关联执行表 BatchId | | AttemptNumber | INT | 关联执行表 AttemptNumber | | OrderNumber | NVARCHAR(255) | 所属订单号 | | MaterialCode | NVARCHAR(255) | 物料代码 | | MaterialName | NVARCHAR(255) | 物料名称 | | RowNumber | INT | 行号 | | Result | NVARCHAR(50) | deleted / skipped / failed / uncertain | | Reason | NVARCHAR(MAX) | 跳过/失败原因 | | AttemptCount | INT | 删除尝试次数 | | FinalErrorCategory | NVARCHAR(50) | 最终错误分类 | 关联方式:`BatchId + AttemptNumber + OrderNumber` 关联订单表。 ### 数据示例 首次执行到第 80 个订单时崩溃,外层重试成功完成全部 211 个订单: **CleanerExecution** ``` BatchId=uuid-1, Attempt=1, Status=crashed, TotalOrders=211, Processed=80, ... BatchId=uuid-1, Attempt=2, Status=success, TotalOrders=211, Processed=211, ... ``` **CleanerOrderHistory**(Attempt=1 中部分记录) ``` BatchId=uuid-1, Attempt=1, Order=SC001, Status=success, Deleted=5, Skipped=1 BatchId=uuid-1, Attempt=1, Order=SC080, Status=crashed, Error=查询超时 ``` **CleanerOrderHistory**(Attempt=2 中部分记录) ``` BatchId=uuid-1, Attempt=2, Order=SC001, Status=success, Deleted=5, Skipped=1 BatchId=uuid-1, Attempt=2, Order=SC080, Status=success, Deleted=3, Skipped=0 BatchId=uuid-1, Attempt=2, Order=SC211, Status=success, Deleted=2, Skipped=0 ``` **CleanerMaterialDetail**(SC080 在 Attempt=2 中的物料) ``` BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-001, Result=deleted BatchId=uuid-1, Attempt=2, Order=SC080, Material=MAT-002, Result=skipped, Reason=不可删除 ``` ## 写入时机 ``` 用户点击"执行清理" → IPC: cleaner:run → cleaner-handler.ts → ① BatchId = randomUUID() → ② 插入 CleanerExecution(Status=pending) → ③ 插入 CleanerOrderHistory(所有订单,Status=pending) → ④ 执行清理(CleanerApplicationService.runCleaner) → ⑤ 更新 CleanerExecution(Status=success/failed/partial/crashed) → ⑥ 更新 CleanerOrderHistory(每个订单的结果) → ⑦ 插入 CleanerMaterialDetail(每个物料的操作明细) → ⑧ 如果 crashed → 外层重试 → 插入新的 CleanerExecution(AttemptNumber=2, Status=pending) → 插入新的 CleanerOrderHistory(AttemptNumber=2, Status=pending) → 重新执行 → 更新执行表和订单表状态 → 插入物料明细 ``` - 步骤 ②③:在 `cleaner-handler.ts` 中,执行前写入,记录操作人、全局配置、待处理订单 - 步骤 ⑤⑥⑦:在 `CleanerApplicationService` 中,执行完成后回调 DAO 写入结果 - 步骤 ⑧:外层重试时,三张表都新增 AttemptNumber=2 的记录,首次尝试的数据完整保留 ## 变更清单 ### 新增文件 1. **`src/main/services/database/cleaner-operation-history-dao.ts`** - `CleanerOperationHistoryDAO` 类 - 执行表操作:insertExecution、updateExecutionStatus - 订单表操作:insertOrderRecords、updateOrderStatus - 物料表操作:insertMaterialDetails - 查询操作:getBatches、getBatchDetails(含订单+物料)、deleteBatch - 参考 `ExtractorOperationHistoryDAO` 的模式,表名使用 `ERPAuto.CleanerExecution`、`ERPAuto.CleanerOrderHistory`、`ERPAuto.CleanerMaterialDetail` 2. **`src/main/types/cleaner-history.types.ts`** - `CleanerExecutionRecord`、`CleanerOrderRecord`、`CleanerMaterialRecord` - `CleanerBatchStats`、`InsertCleanerExecutionInput`、`InsertOrderInput`、`InsertMaterialDetailInput` 3. **`src/renderer/src/components/CleanerOperationHistoryModal.tsx`** - 操作历史弹窗,复用 ExtractorOperationHistoryModal 的 UI 模式 - 批次列表(按 BatchId 聚合,显示操作时间、用户、状态、成功/失败数,区分多次尝试) - 展开明细(订单列表,每订单的删除/跳过/失败数) - 物料级详情(第二层展开,显示每个物料的操作结果) - 管理员可按用户筛选、可删除批次 ### 修改文件 4. **`src/main/ipc/cleaner-handler.ts`** - `CLEANER_RUN` handler 中:执行前插入 execution + order 的 pending 记录,执行后更新结果 - 新增 IPC handlers:`CLEANER_HISTORY_BATCHES`、`CLEANER_HISTORY_DETAILS`、`CLEANER_HISTORY_DELETE` 5. **`src/main/services/cleaner/cleaner-application-service.ts`** - `runCleaner` 接收 `batchId` 参数 - 移除 `generateExecutionId()` 函数 - 移除 `generateAndUploadReport()` 方法 - 移除 `executionId` 相关逻辑 - 外层重试时,通过 DAO 写入 AttemptNumber=2 的执行记录和订单记录,不覆盖首次尝试 - 执行完成后回调 DAO 写入订单结果和物料明细 6. **`src/main/ipc/index.ts`** - 注册新的 cleaner history IPC handlers 7. **`src/preload/api/cleaner.ts`** - 新增 IPC 调用方法:getBatches、getBatchDetails、deleteBatch 8. **`src/preload/index.d.ts`** - `CleanerAPI` 接口新增 getBatches、getBatchDetails、deleteBatch 类型声明 9. **`src/renderer/src/pages/CleanerPage.tsx`** - 新增"操作历史"按钮 - 引入 CleanerOperationHistoryModal ### 删除文件 10. **`src/main/services/report/cleaner-report-generator.ts`** - 整个文件删除,报告生成逻辑不再需要 ### 可选清理 11. **`src/renderer/src/components/ReportViewerDialog.tsx`** - 基于 RustFS 文件的报告查看器,Cleaner 不再使用 - 如果 Extractor 不共用此组件,可删除 12. **`src/renderer/src/components/ReportAnalysisDialog.tsx`** - 基于报告文件的分析,Cleaner 不再使用 - 后续可基于数据库重新实现统计分析 ## 移除的概念 | 概念 | 原因 | |------|------| | ExecutionId(CLN-时间戳-随机) | 为文件名设计,数据库用 UUID | | generateExecutionId() | 随 ExecutionId 一起移除 | | CleanerReportGenerator | Markdown 报告生成器,被数据库替代 | | generateAndUploadReport() | RustFS 上传链路,被数据库写入替代 | | 报告文件名去重 | 数据库 UUID 天然唯一 | | 重试覆盖旧报告 | 数据库保留所有尝试记录 | ## 不涉及的部分 - Extractor 的持久化逻辑不变 - 数据库 schema 迁移(需 DBA 创建表,应用层只做 CRUD) - 后续智能跳过功能(基于已有 success 记录跳过已成功的订单) - 内层重试逻辑(订单级/物料级)不变