# 智能清洗融合:前端「智能」按钮 + 后端一个接口跑完双链路 ## Context `module/aiclean`(AI 清洗)后端已落地并在真库/真模型上验证过,但目前**没有任何入口能触发它**: - 前端 `upload.vue:704-709` 的「智能」按钮只是 `go(BASE_CLEAN_PROGRESS)` 跳到进度页,让 `cleanProgress.vue:826` 在 `onMounted` 里自己调 `startClean()` → 只跑既有模板清洗; - 更关键的是 **只有未匹配文件的批次,「智能」按钮是灰的**:`hasSuccessResult`(`upload.vue:258-262`)依赖 `isSuccessStatus`,而 `SHEET_TEMPLATE_MATCHED_FAIL` 被归进 `FAIL_FILE_STATUSES`(`upload.vue:561-566`)→ AI 恰好在没有可用入口。 目标(已确认三点取舍):新增 **一个** 后端接口 `POST /aiClean/smartClean` 一次做完「既有清洗 + AI 清洗」,前端**后台异步 + 轮询状态**,AI 判置信度不够的 sheet **标待人工并给跳手动清洗的入口**。 约束沿用:不改既有清洗执行层与模板库(`GovernService` 那 1 行同步治理挂载是已完成的唯一例外)。 ## 一、融合后的完整流程 ``` 上传页 [智能] ──POST /aiClean/smartClean {fileInfos}──▶ 立即返回 {batchId, stage:RUNNING} │ 后台虚拟线程 + Semaphore(3) ┌────────────────────────────────────────────────────┘ S1 拆分:sheet.templateId != null → A组(既有);== null → B组(交 AI 判定) S2 B组判定:AiCleanService.judge(...) S1探测→S2结构归一→S3匹配→S4规则→S5校验 ├ ExistingHit(templateId, mainId, rules) → 并入 A组(带 tableRuleList) ├ AiTemplatePlan(字段/规则/数据计划) → 出口 B,待 S4 入库 └ NeedReview(reason) → 记录,不写任何表 S3 A组合并成【一次】DmService.doClean(dto) ← 关键:只调一次,SSE 与 closeSee 都只发生一次 S4 出口 B 计划逐个入库 ai_t{n} + 写 table_info 之外的 AI 元数据 + calcAiTree()(负 id 节点) S5 汇总写 ai_clean_batch(各计数 + needReview 明细),供前端轮询 前端轮询 GET /aiClean/smartClean/status?batchId= → 阶段条 + 「N 个表需人工确认」→ 跳手动清洗 ``` **为什么必须合并成一次 `doClean`**:`DmService.doClean`(`:650-676`)是 fire-and-forget 且完成回调里会 `sseService.closeSee()`。若 AI 命中既有模板时由 AI 自己再调一次 `ExistingTemplateDispatcher`(当前实现就是),同一批次会产生多次 doClean → 多个 SSE 会话先后关闭,进度页会中途断流;也就会出现"同一文件被洗两遍"。所以融合场景下 **A 路只出判定、不出手**,把模板与规则交给批次级那一次 `doClean`。 ## 二、后端改动(全在 `module/aiclean`,既有文件 0 改动) ### 1. `service/AiCleanService` 拆分(模块内重构,不改行为) 现在是 `run(jobId)` 一条龙(判定 + 出口 A 直接 dispatch + 出口 B 直接入库)。拆成: ```java public AiJudgeResult judge(Long caseId, FileInfo root, FileInfo sheet) // S1→S5,只判定、不落任何库 public long loadViaAiTemplate(AiJudgeResult plan, ...) // 出口 B:建表 + 写数 + 登记 + 刷树 public AiCleanJob run(Long jobId); // 保留:单 job 串行走完(供 /aiClean/run 与回归用) ``` `AiJudgeResult`(新 sealed 记录)三态:`ExistingHit(templateId, mainId, funcRegx, List)` / `AiTemplatePlan(...)` / `NeedReview(reason)`。 出口 A 的 `TableRuleDTO` 直接复用已有 `AiRulePlan.forExisting(...)`(它产出的就是前端那份报文结构)。 ### 2. `service/AiSmartCleanService`(新) ```java SmartCleanVO submit(Long caseId, Long userId, List fileInfos); // 落 ai_clean_batch + 立即返回 void run(Long batchId); // 后台执行 S1→S5 SmartCleanStatusVO status(Long batchId); ``` - 并发:虚拟线程 + `Semaphore(3)` 公平闸门,照 `FileRecognitionService:98-125` 既有范式;每个任务内 `CaseContextHolder.runWith(caseId, userId, ...)`(虚拟线程不继承 ThreadLocal,`DmService:671-675` 已踩过一次)。 - 阶段字段:`JUDGING → CLEANING_EXISTING → LOADING_AI → DONE / PARTIAL`;判定阶段内部并发,阶段切换靠计数归零。 - A 组为空时**跳过** `doClean`(`buildCleanJobs` 空会 `closeSee`,`DmService:653-658`,白关一次会话)。 - 幂等:`ai_clean_job` 唯一键 `(case_id,file_id,sheet_id,block_no,ver)` 已能防重复判定;`ai_clean_batch` 按 `(case_id,batch_id)` 唯一,重复点按钮返回同一 batchId 而不重跑。 - 不重叠:只处理 `templateId == null` 的 sheet,既有能匹配的绝不插手 → 与手动清洗链路天然互斥。 ### 3. `api/AiSmartCleanController`(新) ``` POST /aiClean/smartClean body {fileInfos:[FileInfo 树]} → {batchId, stage, fileCount, aiSheetCount, matchedSheetCount} GET /aiClean/smartClean/status?batchId= → {stage, total, judged, cleaningExisting, loadedAi, needReview[], failed[]} ``` 入参用 `@RequestBody`(既有约定:漏注解会退化成 `@ModelAttribute` 静默丢 JSON body,`DmController` 类注释已写明)。`needReview[]` 带 `sheetId + fileName + reason`,前端用它拼 focusKey 跳转。 ### 4. 新表 `sql/ai_clean.sql` 追加 `ai_clean_batch` `id, case_id, batch_id, file_count, sheet_total, matched_count, ai_judged_count, existing_hit_count, ai_load_count, need_review_count, failed_count, stage, need_review_json, last_error, create_time, update_time`;唯一键 `(case_id, batch_id)`。DDL 保持 `IF NOT EXISTS`,可重复执行(前 4 张表已在 dev PG 18.4 验过 12/12 语句)。 ### 5. 前端能看到 AI 数据的配套(否则融合结果是"半可见") 治理树节点已是负 id(`AiGovernService`),但 `GovernTreeService.getTreeTablePage` 走 MyBatis-Plus 实体注册,点 AI 节点必 NPE —— 后端 `/aiClean/data/page` 已就绪,前端补一个 `id < 0` 分支即可(见第三节第 4 项)。 ## 三、前端改动 ### 1. `upload.vue`:让按钮可用 + 改为调新接口 - 可用条件:`hasSuccessResult` → **`hasCleanableSheet`**(`upload.vue:270`,语义就是"解析出了 sheet"),否则未匹配批次永远点不动; - `handleSmartClean`:`persistFileInfos(allFileInfos)` → `await smartClean({fileInfos})` → 成功 `allowedToLeave=true; go({path: BASE_CLEAN_PROGRESS, query:{fused:'1', batchId}})`;接口失败留在本页并提示(不清批次)。 - 文案:按钮副标题加一句"未匹配模板的文件将走 AI 自动识别"。 ### 2. `governApi.ts`:新增两个封装 `smartClean(dto)`、`smartCleanStatus(batchId)`,与同文件既有 `doClean:148` 一致的 baseURL 与错误处理写法。 ### 3. `cleanProgress.vue`:fused 模式下不自己发起清洗 `onMounted`(`:829`)里当 `route.query.fused === '1'` 时**跳过** `startClean()`(`:826`),只连接既有 SSE 收后端那一次 `doClean` 的进度;同时新增一个 AI 阶段卡片:轮询 `smartCleanStatus` 直到 `stage ∈ {DONE, PARTIAL}`,展示 `判定 x/y · 交既有清洗 n · AI 入库 m · 待人工 k`,`待人工` 可点 → 复用 `handleRowManualClean` 的 `focusKey` 跳 `BASE_CLEANING`。 ### 4. 治理树 AI 节点取数(`data/index.vue`) 节点点击处分流:`node.id < 0` → 调 `/aiClean/data/page?aiTemplateId=-id`(表头用返回的 `head`),否则走现有 `treeTablePage`。约 10 行,独立分支,不动既有分页逻辑。 ## 四、实施顺序 1. 后端:`AiJudgeResult` + `AiCleanService` 拆分(`judge` / `loadViaAiTemplate`),保持 `/aiClean/run` 行为不变 + 补单测。 2. 后端:`ai_clean_batch` DDL + 实体 + mapper;`AiSmartCleanService`(闸门、阶段机、A 组合并一次 doClean、B 组入库);`AiSmartCleanController`。 3. 前端:`governApi` 两个函数 → `upload.vue` 按钮条件与 handler → `cleanProgress.vue` fused 分支 + AI 阶段卡片轮询。 4. 前端:治理树 `id<0` 分流。 5. 文档:`docs/design/ai-clean-parallel.md` 升到 v7.6,把融合接口、批次状态机、前端接入点与这轮真模型跑出的四个修复(top-12 候选、`#n` 重编号、WEAK 不许走 A、草稿复用)写进「实现进度」。 ## 五、验证 **离线单测(必做,不依赖模型/浏览器)** - `AiSmartCleanServiceTest`(mockito,不起 Spring):① 输入 6 个 sheet(3 有 templateId、3 没有)→ 断言 `doClean` **只被调用一次** 且 dto 里含 3 个既有 + AI 判为命中的那些 sheet,且每个都带 `tableRuleList`;② A 组为空 → 断言 `doClean` **零调用**(不白关 SSE);③ `judge` 返回 NeedReview → 断言不写任何库、`need_review_count` 正确;④ 重复 submit 同一 `(caseId,batchId)` → 返回同一 batchId 且不重跑。 - 阶段机断言:只有当所有判定任务计数归零才切到 `CLEANING_EXISTING`(防止 doClean 早于判定,把 AI 命中的 sheet 漏掉)。 **端到端(真 PG + 真模型 + 真浏览器)** 1. 造两类文件:`messy.xlsx`(列名与模板不一致,POI 生成器已有)与一份能正常匹配模板的文件; 2. 起 `mvn spring-boot:run` 与 `npm run dev`,走登录/开案,上传这两个文件; 3. 断言:批次含未匹配文件时「智能」按钮**可点**(这是本轮修的入口问题); 4. 点击后用 browser-use 观察:进度页不再重复发起清洗(后端日志里 `doClean` 只出现 1 次)、AI 阶段卡片计数推进到 `DONE`; 5. `SELECT * FROM ai_clean_job WHERE batch_id=?` 看 `exit_code` 分布;`ai_clean_batch.stage` 终态正确; 6. 治理树(`data/index.vue`)出现 AI 节点、点开能出数据与表头(`id<0` 分支生效),既有节点行为不变; 7. 待人工场景:把 `LLM_MIN_CONFIDENCE` 临时调高触发 `NEED_REVIEW`,点「去人工确认」落到手动清洗页并定位到该 sheet; 8. 回归:一个全部能匹配模板的批次,走「智能」后的行为与改动前逐行一致(`git diff` 里既有后端文件仍只有 `GovernService` 那 1 行)。 ## 六、风险 | 风险 | 应对 | |---|---| | AI 判定慢(实测单文件几十秒起),批次里文件多时总时长不可控 | 后台异步 + 闸门并发;状态里给"排队中"计数;后续靠 `header_md5` 直通让重复表头零模型调用(P1) | | 判定与清洗的时序写错 → AI 命中的 sheet 被漏洗 | 阶段机:判定计数未归零不许进 `CLEANING_EXISTING`;配单测 ①② 钉死"只调一次" | | 多次 `doClean` 引发 SSE 提前关闭、进度页断流 | 融合路径上 A 路只出判定不出手;`ExistingTemplateDispatcher` 只保留给 `/aiClean/run` 单 job 场景 | | 与手动清洗重复入库 | 只处理 `templateId==null` 的 sheet;用户手动配过并跑过的 sheet 不再被 AI 接管(`AiCleanService` 已有 `SUPERSEDED` 语义) | | 前端 fused 标志丢失导致既不发清洗也不等清洗(卡住) | `cleanProgress` 无 `fused` 时行为完全不变;有 `fused` 时轮询超时给显式错误 + 「改为手动清洗」出口 | | 重复点「智能」 | batchId 幂等 + job 唯一键,返回同一批次 | | AI 表节点在前端点开 500 | 第四节第 5 步的 `id<0` 分流必须与融合入口同批上线,否则数据"看不见" |