feat(报表生成): 导出缓存守卫改用数据指纹(无关导入不再失效 / 换母版必定失效)+ 失效提示

- 指纹 = 依赖源表 CHECKSUM TABLE(可捕捉原地 UPDATE) + 母版目录全部文件与网约车订单 xlsx 的名称/大小/修改时间,SHA-256 前 8 字节写入缓存文件名

- 源表范围用排除法:排除 投资3/能耗2/审核4/系统6/import_batch,其余全纳入(宁可多算不可出错)

- 没命中但存在旧结果时,进度弹窗提示「上次生成的结果已因数据或母版变化失效,本次按最新数据重算」;命中缓存时清零该标记

- 实测:无关导入(投资项目+导入记录)仍命中;相关源表原地 UPDATE、改母版 mtime 必定失效并重算;浏览器实测提示行正常、控制台 0 报错;测试数据已还原

- 文档:功能测试报告(12 用例) + 日结 1.6 + 交接文档 P2-6 闭环
4个文件已修改
1个文件已添加
252 ■■■■■ 已修改文件
docs/交接文档_2026-09-21.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/功能测试报告/功能测试报告_导出缓存改用数据指纹_2026-09-22.md 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/工作日结/工作日结_2026-09-22.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/½»½ÓÎĵµ_2026-09-21.md
@@ -217,7 +217,7 @@
| P2-3 | `applySummaryReferenceLayout()` æŒ‰**列序号**套列宽/隐藏 | å‚照件一加减列就整体错位,应改为按表头文字匹配 | âŒ |
| P2-4 | ç¼ºæ¯ç‰ˆæç¤ºï¼ˆT08)未实测 | æœ‰æµ‹è¯•副本时补测 | âŒ |
| P2-5 | éƒ¨ç½²åŒ…内是**带 AJ åˆ—的旧母版** | `deploy/traffic-audit-deploy-20260913/backend/docs/生成汇总大表/`、`_deploytest/`;用户 09-20 æ˜Žç¡®ã€Œéƒ¨ç½²åŒ…先不管」,发包前必须同步 | â¸ |
| P2-6 | ~~导出缓存是内存态~~ | **已解决(2026-09-22)**:结果落盘到 `docs/生成汇总大表/_cache/`(文件名带数据版本 `MAX(import_batch.id)`),内存 â†’ è½ç›˜ â†’ çœŸç®—逐级命中,重启后端后仍可秒回复用;有新导入即失效,7 å¤©ä¸Šé™å¹¶è‡ªåŠ¨æ¸…ç†ã€‚é—ç•™ï¼šæ•°æ®ç‰ˆæœ¬æ˜¯å…¨å±€çš„ï¼ˆä¸Žæ±‡æ€»æ— å…³çš„å¯¼å…¥ä¹Ÿä¼šè®©ç¼“å­˜å¤±æ•ˆï¼‰ï¼Œå¦‚éœ€æŒ‰å¯¼å…¥ç±»åž‹ç»†åˆ†å¾…æŽ’æœŸ | âœ…(可优化项见右)|
| P2-6 | ~~导出缓存是内存态~~ | **已解决(2026-09-22)**:结果落盘到 `docs/生成汇总大表/_cache/`(文件名带数据版本 `MAX(import_batch.id)`),内存 â†’ è½ç›˜ â†’ çœŸç®—逐级命中,重启后端后仍可秒回复用;有新导入即失效,7 å¤©ä¸Šé™å¹¶è‡ªåŠ¨æ¸…ç†ã€‚é—ç•™é¡¹å·²é—­çŽ¯ï¼ˆ2026-09-22 ä¸‹åˆï¼‰ï¼šç‰ˆæœ¬å·å‡çº§ä¸º**数据指纹**(依赖源表 `CHECKSUM TABLE` + æ¯ç‰ˆç­‰æ–‡ä»¶èº«ä»½ï¼ŒæŽ’除投资/能耗/审核/系统/导入日志),无关导入不再失效、换母版必定失效;失效时进度弹窗会说明原因 | âœ… |
| P2-7 | å¸‚州单表「多市州块」兜底提示 | æ˜¯å¦åœ¨è¯†åˆ«åˆ°ä¸€ä»½æ–‡ä»¶å«å¤šä¸ªå¸‚州块时弹窗提示/跳过,待确认(与 P0-1 åŒä¸€æ ¹å› ï¼‰ | â¸ |
| P2-8 | ä»“库根目录 `_*.*` ä¸´æ—¶æ–‡ä»¶å¢žå¤š | `_chk_*`(本次汇总大表排查产生,见汇总大表交接第 10 èŠ‚ï¼‰ã€`_hyb_*`、`_loo_*` ç­‰ï¼Œ**经用户同意后再清** | â¸ |
| P2-9 | `2026å¹´8月道路运输量汇总表_版式原型.xlsx` åŽ»ç•™ | è¿è¡Œæ—¶æœªè¢«å¼•用 | â¸ |
@@ -321,4 +321,5 @@
| --- | --- |
| 2026-09-21 | é¦–次成文:整合汇总大表交接(`docs/问题汇总/汇总大表问题交接_2026-09-21.md`)与投资/货运/客运各线遗留 |
| 2026-09-21(下午) | P0-1 é—­çŽ¯ï¼ˆåå °æºæ–‡ä»¶å·²åˆ æ®µ + é‡å¯¼éªŒè¯ï¼‰ï¼›æ–°å¢ž P1-13(投资系统新模板支持);数据快照补 `investment_system` è¡Œ |
| 2026-09-22(下午) | å¯¼å‡ºç¼“存:结果落盘 + æ•°æ®æŒ‡çº¹å®ˆå« + å¤±æ•ˆæç¤ºï¼ˆP2-6 é—­çŽ¯ï¼‰ |
| 2026-09-21(下午) | P1-1 é—­çŽ¯ï¼šç”¨æˆ·é‡‡ç”¨æ–¹æ¡ˆ A,中口径排名的 2025 ç´¯è®¡åŸºæ•°æ”¹è¯»æ¯ç‰ˆã€Šä¸­å£å¾„明细》静态列;黄冈累计同比由 7.74% ä¿®æ­£ä¸º 7.641733%,双页一致性回归通过 |
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_µ¼³ö»º´æ¸ÄÓÃÊý¾ÝÖ¸ÎÆ_2026-09-22.md
New file
@@ -0,0 +1,61 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_导出缓存改用数据指纹_2026-09-22
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
用户诉求:下载过一次、期间没有新导入数据就应直接沿用上次结果(已实现落盘缓存);随后用户认可「按我说的做」,要求继续解决缓存守卫的两个问题:
1. **误失效**:原来用全局 `MAX(import_batch.id)` å½“版本号,导入投资/能耗这类与汇总大表无关的数据也会让缓存失效,白等十几秒;
2. **漏失效(正确性漏洞)**:版本号只反映数据库导入记录,**不含母版文件**——换母版/改参照件时版本号不变,会把旧结果当新表发出。
**改动**:
| # | æ”¹åЍ | è¯´æ˜Ž |
| --- | --- | --- |
| 1 | ç”¨**数据指纹**替代全局版本 | æŒ‡çº¹ = ã€Œä¾èµ–源表的内容校验和」+「母版等文件身份」,SHA-256 å–前 8 å­—节(16 ä½åå…­è¿›åˆ¶ï¼‰ï¼Œå†™è¿›ç¼“存文件名 |
| 2 | æºè¡¨èŒƒå›´ï¼**排除法** | åº“内所有基表,**排除**投资(3)/能耗(2)/审核(4)/系统(6)/`import_batch`(1) å…± 16 å¼ ï¼›å…¶ä½™ä¸€å¾‹çº³å…¥ï¼ˆå®å¯å¤šç®—不可出错) |
| 3 | ç”¨ `CHECKSUM TABLE` å–校验和 | èƒ½æ•捉**原地 UPDATE**(行数、max(id) éƒ½ä¸å˜çš„æƒ…况),比 `count+max(id)` æ›´ä¸¥æ ¼ |
| 4 | æ–‡ä»¶èº«ä»½ | æ¯ç‰ˆç›®å½•下全部文件(母版 / å‚ç…§ä»¶ / `_备份_` å›žå¡«ä»¶ï¼‰+《网约车订单及全省总量.xlsx》,取「名称+大小+修改时间」 |
| 5 | æ–°å¢ž**失效提示**(第 3 æ­¥çš„æç¤ºç‰ˆï¼‰ | æ²¡å‘½ä¸­ä¸”存在旧结果时,进度弹窗显示「上次生成的结果已因数据或母版变化失效,本次按最新数据重算。」 |
> æç¤ºæ”¾åœ¨**导出时的进度弹窗**(而不是导入结果弹窗):① æ­£å¥½å‡ºçŽ°åœ¨ç”¨æˆ·ç­‰å¾…çš„æ—¶å€™ï¼›â‘¡ å…åŽ»ã€Œæ¯æ¬¡å¯¼å…¥éƒ½è·‘ä¸€éå…¨è¡¨æ ¡éªŒå’Œã€çš„å¼€é”€ã€‚
## äºŒã€æµ‹è¯•环境
- åŽç«¯ Spring Boot 8090(重启加载新代码);前端 8080;MySQL 3308 / `traffic_audit`
- æŠ¥è¡¨æœŸ 2026-08(月报);缓存目录 `docs/生成汇总大表/_cache/`
- éªŒè¯æœŸé—´å¦ä¸€ä¸ªä¼šè¯é‡å¯è¿‡åŽç«¯ä¸¤æ¬¡ï¼Œä¸ªåˆ«æ­¥éª¤ä¸­æ–­åŽé‡è·‘(不影响结论)
## ä¸‰ã€ç”¨ä¾‹è¡¨
| # | ç”¨ä¾‹ | æ“ä½œ | é¢„期 | å®žé™… | ç»“论 |
| --- | --- | --- | --- | --- | --- |
| T01 | é¦–次生成(新指纹) | æäº¤å¯¼å‡º | çœŸç®—并落盘,旧版本文件被清理 | `DONE` 21 s;生成 `…_v227060ce99767ced.xlsx`,原 `…_v541.xlsx` å·²åˆ  | é€šè¿‡ |
| T02 | å†æ¬¡æäº¤ | åŒä¸ŠæŠ¥ | å†…存命中 | **241 ms**,`fromCache=true` | é€šè¿‡ |
| T03 | **无关导入不影响缓存(核心)** | å¾€ `investment_project`、`import_batch` å„插 1 è¡ŒåŽæäº¤ | ä»å‘½ä¸­ | `fromCache=true`(改造前必然失效) | é€šè¿‡ |
| T04 | åˆ é™¤ä¸Šè¿°ä¸¤è¡ŒåŽæäº¤ | åŒä¸Š | ä»å‘½ä¸­ | `fromCache=true` | é€šè¿‡ |
| T05 | **相关源表原地 UPDATE** | `wyc_order_monthly.pin_k +0.001`(行数/`max(id)` ä¸å˜ï¼‰åŽæäº¤ | å¿…须失效 | `fromCache=false`、`RUNNING`;缓存文件名换成新指纹 `…_v68b6bc0c…` | é€šè¿‡ |
| T06 | è¿˜åŽŸè¯¥å€¼åŽæäº¤ | åŒä¸Š | ä»ä¸å‘½ä¸­ï¼ˆç¼“存是改后指纹) | `fromCache=false` â†’ é‡ç®—;指纹回到 `227060ce99767ced` | é€šè¿‡ |
| T07 | **换母版必须失效(原漏洞)** | æ”¹æ¯ç‰ˆæ–‡ä»¶ä¿®æ”¹æ—¶é—´ï¼ˆå†…容不变)后提交 | å¿…须失效 | `fromCache=false`、`RUNNING`;指纹变 `…_v7d65cbb6…` | é€šè¿‡ |
| T08 | æ¢å¤æ¯ç‰ˆæ—¶é—´åŽæäº¤ | åŒä¸Š | å¤±æ•ˆâ†’重算→再提交命中 | é‡ç®—后 `fromCache=true` | é€šè¿‡ |
| T09 | å¤±æ•ˆæç¤ºï¼ˆæŽ¥å£ï¼‰ | å­˜åœ¨æ—§ç»“果且指纹不符时提交 | `staleCache=true` | `status=RUNNING fromCache=false staleCache=true` | é€šè¿‡ |
| T10 | å‘½ä¸­ç¼“存无提示(回归修复) | å‘½ä¸­æ—¶æäº¤ | `staleCache=false` | ä¿®å¤å‰å‘½ä¸­ä»å¸¦ `staleCache=true` â†’ å·²ä¿®ä¸ºå‘½ä¸­å³æ¸…é›¶ | é€šè¿‡ |
| T11 | **浏览器实测提示行** | admin/123456 â†’ æŠ¥è¡¨ç”Ÿæˆ â†’ æ±‡æ€»å¤§è¡¨ â†’ 2026-08 â†’ å¯¼å‡ºï¼ˆå…ˆæ”¹æ¯ç‰ˆ mtime åˆ¶é€ å¤±æ•ˆï¼‰ | å¼¹çª—出现失效提示 | å¼¹çª—显示「上次生成的结果已因数据或母版变化失效,本次按最新数据重算。」;生成 13.2 s、自动下载 1.15 MB、按钮「重新生成/关闭」、控制台 0 æŠ¥é”™ | é€šè¿‡ |
| T12 | æ•°æ®è¿˜åŽŸæ ¸å¯¹ | æŸ¥åº“ | æ— æµ‹è¯•残留 | `import_batch` 419、`investment_project` 152、`wyc_order_monthly.2025-12 pin_k=3492.63`、无「_指纹测试项目」 | é€šè¿‡ |
## å››ã€å‘现的问题与处置
| # | é—®é¢˜ | å¤„ç½® |
| --- | --- | --- |
| P1 | å‘½ä¸­ç¼“存时残留上一次的「已失效」提示标记 | å·²ä¿®ï¼šå‘½ä¸­åˆ†æ”¯æ¸…é›¶ `staleCache` |
| P2 | æ–‡ä»¶èº«ä»½ç”¨æ¯«ç§’级 mtime:把母版 mtime「恢复成同一秒」时,亚秒差异仍会产生新指纹(验证中实际遇到) | å±ž**预期行为**(保守优先:宁可多算一次)。若将来觉得太敏感,可改为「秒级 mtime」或对母版做内容哈希 |
| P3 | æµ‹è¯•用的临时数据(1 ä¸ªæŠ•资项目、1 æ¡å¯¼å…¥è®°å½•、`wyc pin Â±0.001`) | å·²å…¨éƒ¨è¿˜åŽŸ/删除,T12 å·²æ ¸å¯¹ |
| P4 | æ•°æ®æŒ‡çº¹è®¡ç®—成本 | çº¦ 11 å¼ è¡¨ CHECKSUM + 13 ä¸ªæ–‡ä»¶ stat,实测可为毫秒级(3 å¼ è¡¨ 48 ms);仅在提交导出时算一次 |
## äº”、结论
- ç¼“存守卫从「全局导入版本」升级为「**数据指纹**」后:**无关导入不再让缓存失效**(T03/T04),**换母版、改源表(含原地 UPDATE)必定失效**(T05/T07),两个方向都实测通过;
- ç”Ÿæˆä¸€æ¬¡çš„æˆæœ¬åªèŠ±åœ¨çœŸæ­£éœ€è¦é‡ç®—çš„æ—¶å€™ï¼›æ­£å¸¸ã€Œä¸‹è½½è¿‡å†çœ‹ä¸€æ¬¡ã€ä»æ˜¯æ¯«ç§’çº§å‘½ä¸­ï¼›
- å¤±æ•ˆæ—¶ä¼šåœ¨è¿›åº¦å¼¹çª—明确说明原因,用户不会误以为缓存坏了;
- æµ‹è¯•数据已全部还原,库内状态与验证前一致。
测试人:Codex(AI) 测试日期:2026-09-22
docs/¹¤×÷ÈÕ½á/¹¤×÷ÈÕ½á_2026-09-22.md
@@ -80,6 +80,15 @@
- **顺带修 bug**:落盘命中时 `elapsedMs` ä¸ºè´Ÿæ•°ï¼ˆä»»åŠ¡èµ·ç‚¹ç”¨çŽ°åœ¨ã€ç»“æŸæ—¶é—´æ˜¯åŽ†å²ç”Ÿæˆæ—¶é—´ï¼‰â†’ å·²æ”¹ä¸ºä»¥ç”Ÿæˆæ—¶é—´ä½œèµ·ç‚¹ã€‚
- **实测**:首次 12.4 s å†™ç¼“存;再次提交 **164 ms** å‘½ä¸­å†…存;**重启后端后提交 1.19 s å‘½ä¸­è½ç›˜**;临时插一条导入记录后 `fromCache=false` é‡æ–°ç”Ÿæˆï¼ˆéªŒè¯ã€Œæœ‰æ–°å¯¼å…¥å³å¤±æ•ˆã€ï¼‰ï¼›æµè§ˆå™¨ç‚¹å¯¼å‡ºç§’出、文案与按钮正确、控制台 0 æŠ¥é”™ã€‚
### 6. å¯¼å‡ºç¼“存守卫升级为「数据指纹」+ å¤±æ•ˆæç¤ºï¼ˆç”¨æˆ·ï¼šæŒ‰ä½ è¯´çš„做)
- **背景**:上一版缓存守卫用全局 `MAX(import_batch.id)`,①导入投资/能耗等无关数据会白失效;②**不含母版文件**,换母版时会错误命中旧结果(正确性漏洞)。
- **改动**(`ReportExportTaskService.java`):
  1. æŒ‡çº¹ =「依赖源表 `CHECKSUM TABLE` æ ¡éªŒå’Œï¼ˆèƒ½æ•捉原地 UPDATE)」+「母版目录全部文件 + ç½‘约车订单 xlsx çš„名称/大小/修改时间」,SHA-256 å‰ 8 å­—节写进缓存文件名;
  2. æºè¡¨èŒƒå›´ç”¨**排除法**:排除投资 3 / èƒ½è€— 2 / å®¡æ ¸ 4 / ç³»ç»Ÿ 6 / `import_batch`,其余全纳入(宁可多算不可出错);
  3. å¤±æ•ˆæç¤ºï¼ˆç¬¬ 3 æ­¥æç¤ºç‰ˆï¼‰ï¼šæ²¡å‘½ä¸­ä¸”有旧结果时,进度弹窗显示「上次生成的结果已因数据或母版变化失效,本次按最新数据重算。」
- **实测**:无关导入(投资项目 + å¯¼å…¥è®°å½•各插一行)**仍命中**缓存;相关源表原地 UPDATE、改母版 mtime **必定失效**并重算;命中缓存时不再残留提示;浏览器实测提示行显示正常、控制台 0 æŠ¥é”™ï¼›æµ‹è¯•数据已全部还原(419 æ‰¹æ¬¡ / 152 é¡¹ç›®ï¼‰ã€‚
## äºŒã€æµ‹è¯•结果摘要
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_数据查看补齐网约车与个体客运类型_2026-09-22.md`
@@ -89,6 +98,7 @@
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_数据查看按市州汇总与导出_2026-09-22.md`(接口 10 æ¡ + çœŸæœº 4 æ¡å…¨é€šè¿‡ï¼‰
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_汇总大表进度弹窗优化_2026-09-22.md`(6 æ¡ç”¨ä¾‹é€šè¿‡ï¼šç”Ÿæˆä¸­çŠ¶æ€ / å®Œæˆæç¤º / å†æ¬¡ä¸‹è½½èµ°ç¼“å­˜ / å…³é—­ / æ— æŽ§åˆ¶å°æŠ¥é”™ / å…¶å®ƒå¯¼å‡ºæœªå—影响)
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_汇总大表导出结果落盘缓存复用_2026-09-22.md`(10 æ¡ç”¨ä¾‹é€šè¿‡ï¼šé¦–次生成写缓存 / å†…存命中 164ms / **重启后落盘命中 1.19s** / æœ‰æ–°å¯¼å…¥å³å¤±æ•ˆ / åŒ key åªç•™æœ€æ–°ç‰ˆæœ¬ / æ—¶é•¿æ˜¾ç¤ºä¿®å¤ / æµè§ˆå™¨ç«¯åˆ°ç«¯ï¼‰
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_导出缓存改用数据指纹_2026-09-22.md`(12 æ¡ç”¨ä¾‹é€šè¿‡ï¼šæ— å…³å¯¼å…¥ä»å‘½ä¸­ / ç›¸å…³æºè¡¨åŽŸåœ° UPDATE å¿…失效 / **换母版必失效** / å¤±æ•ˆæç¤ºæ˜¾ç¤º / å‘½ä¸­æ— æç¤º / æ•°æ®è¿˜åŽŸæ ¸å¯¹ï¼‰
- çŽ¯å¢ƒï¼šåŽç«¯ `mvn spring-boot:run`(8090,`Started TrafficAuditApplication`);前端 `npm run serve`(8080,`Compiled successfully in 4769ms`);MySQL `localhost:3308/traffic_audit`;登录 admin/123456 å– JWT。
- ç»“果:3 ä¸ªæ–°ç±»åž‹æ­£å‘(含期间/关键字筛选)、负向(未知类型、无数据期、无 token)全部通过;入库抽查条数与库内一致(20 / 20 / 139);原有 15 ç±»å›žå½’正常。
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
@@ -1,19 +1,27 @@
package com.trafficaudit.reportexport.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.trafficaudit.dataimport.entity.ImportBatch;
import com.trafficaudit.dataimport.mapper.ImportBatchMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import javax.sql.DataSource;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -29,9 +37,11 @@
 * 2) ç”Ÿæˆè¿‡ç¨‹ä¸å ç”¨ HTTP è¯·æ±‚线程,避免多人同时点把容器拖死(工作线程固定 1 ä¸ªï¼Œå¤šä½™è¯·æ±‚显示「排队中」);
 * 3) åŒä¸€æŠ¥è¡¨æœŸçš„结果可复用(**落盘保存,进程重启后依然有效**),避免每次重算整本约 4 ä¸‡æ ¼å…¬å¼ã€‚
 *
 * ç¼“存正确性守卫:只有当「期间没有任何新的数据导入(import_batch æœ€å¤§ id æœªå˜ï¼‰ã€ä¸”「结果不超过 7 å¤©ã€æ—¶æ‰å¤ç”¨ï¼›
 * å¯¼å…¥å³å¤±æ•ˆï¼Œé¿å…æŠŠè¿‡æœŸæŠ¥è¡¨ç»™ç”¨æˆ·ã€‚前端仍提供「重新生成」强制重算(跳过缓存)。
 * è½ç›˜ä½ç½®ï¼š{summary.template-dir}/_cache/<key>_v<数据版本>.xlsx
 * ç¼“存正确性守卫=**数据指纹**:指纹由「汇总大表依赖的源表内容校验和」+「母版等文件身份(大小/修改时间)」算出,
 * åªæœ‰æŒ‡çº¹ä¸€è‡´ï¼ˆä¸”结果不超过 7 å¤©ï¼‰æ‰å¤ç”¨ã€‚因此:
 *   - æŠ•资/能耗/审核等**与汇总大表无关**的导入不再让缓存失效;
 *   - æ¢æ¯ç‰ˆã€æ”¹å‚照件、动相关源表(含原地 UPDATE)都会让缓存失效,不会把旧表当新表发出。
 * å‰ç«¯ä»æä¾›ã€Œé‡æ–°ç”Ÿæˆã€å¼ºåˆ¶é‡ç®—(跳过缓存)。落盘位置:{summary.template-dir}/_cache/<key>_v<指纹>.xlsx
 */
@Slf4j
@Service
@@ -50,11 +60,24 @@
    @Resource
    private ReportExportService reportService;
    @Resource
    private ImportBatchMapper importBatchMapper;
    private DataSource dataSource;
    /** æ±‡æ€»å·¥ä½œç°¿ç›®å½•(与 ReportExportService åŒé…ç½®ï¼‰ï¼Œè½ç›˜ç¼“存放在其 _cache å­ç›®å½• */
    @org.springframework.beans.factory.annotation.Value("${summary.template-dir:docs/生成汇总大表}")
    private String summaryTemplateDir;
    /** åŸŽå¸‚客运目录:网约车订单及全省总量.xlsx(库里缺该月时的兜底输入) */
    @org.springframework.beans.factory.annotation.Value("${city-passenger.template-dir:docs/城市客运}")
    private String cityPassengerTemplateDir;
    /** è®¡ç®—指纹时**排除**的表:投资线、能耗线、审核线、系统表、导入日志。
     *  è¿™äº›è¡¨å˜åŒ–不影响汇总大表内容,没必要让缓存失效;其余表一律纳入(宁可多算不可出错)。 */
    private static final Set<String> FINGERPRINT_EXCLUDE = new HashSet<>(Arrays.asList(
        "investment_project", "investment_monthly", "investment_system",
        "h204_vehicle_quarterly", "h204_auth_vehicle",
        "audit_result", "audit_run", "audit_rule", "audit_explanation",
        "sys_user", "sys_role", "sys_user_role", "sys_dept", "sys_operation_log",
        "llm_desensitize_map", "import_batch"));
    /** è½ç›˜ç¼“存目录;定位策略与模板一致(配置目录 / user.dir ç›¸å¯¹ / ä¸Šçº§ç›®å½•) */
    private File cacheDir() {
@@ -95,8 +118,8 @@
        private final String mode;
        private final Integer toMonth;
        private volatile long createdAt = System.currentTimeMillis();
        /** ç”Ÿæˆæ—¶çš„æ•°æ®ç‰ˆæœ¬ï¼ˆimport_batch æœ€å¤§ id),用于判断缓存是否过期 */
        private final long importVersion;
        /** ç”Ÿæˆæ—¶çš„æ•°æ®æŒ‡çº¹ï¼ˆæºè¡¨æ ¡éªŒå’Œ + æ¯ç‰ˆæ–‡ä»¶èº«ä»½ï¼‰ï¼Œç”¨äºŽåˆ¤æ–­ç¼“存是否过期 */
        private final String fingerprint;
        private volatile String status = "PENDING"; // PENDING/RUNNING/DONE/FAILED
        private volatile int percent = 0;
        private volatile String step = "排队中";
@@ -106,14 +129,16 @@
        private volatile File cacheFile;
        private volatile String fileName;
        private volatile boolean fromCache;
        /** æœ¬æ¬¡ã€Œæ²¡å‘½ä¸­ç¼“存」是因为存在旧结果但数据/母版已变(用于给用户一句解释) */
        private volatile boolean staleCache;
        private volatile long finishedAt;
        Task(String type, String period, String mode, Integer toMonth, long importVersion) {
        Task(String type, String period, String mode, Integer toMonth, String fingerprint) {
            this.type = type;
            this.period = period;
            this.mode = mode;
            this.toMonth = toMonth;
            this.importVersion = importVersion;
            this.fingerprint = fingerprint;
        }
        void progress(int pct, String stepText) {
@@ -159,6 +184,7 @@
            m.put("percent", "DONE".equals(status) ? 100 : percent);
            m.put("step", step);
            m.put("fromCache", fromCache);
            m.put("staleCache", staleCache);
            m.put("fileName", fileName);
            m.put("error", error);
            long end = finishedAt > 0 ? finishedAt : System.currentTimeMillis();
@@ -170,20 +196,22 @@
    /**
     * æäº¤å¯¼å‡ºä»»åŠ¡ã€‚forceRefresh=true æ—¶å¿½ç•¥ç¼“存重新生成;
     * å¦åˆ™åœ¨ã€ŒåŒä¸€æŠ¥è¡¨æœŸã€10 åˆ†é’Ÿå†…、期间无新导入」的前提下直接复用上次结果。
     * å¦åˆ™åœ¨ã€ŒåŒä¸€æŠ¥è¡¨æœŸ + æ•°æ®æŒ‡çº¹ä¸€è‡´ + 7 å¤©å†…」的前提下直接复用上次结果。
     * åŒä¸€æŠ¥è¡¨æœŸå·²æœ‰ä»»åŠ¡åœ¨è·‘æ—¶ç›´æŽ¥è¿”å›žè¯¥ä»»åŠ¡ï¼Œé¿å…é‡å¤æŽ’é˜Ÿã€‚
     */
    public Task submit(String type, String period, String mode, Integer toMonth, boolean forceRefresh) {
        cleanupExpired();
        String key = cacheKey(type, period, mode, toMonth);
        String fingerprint = dataFingerprint();
        if (!forceRefresh) {
            Task hit = cache.get(key);
            if (cacheUsable(hit)) {
            if (cacheUsable(hit, fingerprint)) {
                hit.fromCache = true;
                hit.staleCache = false; // å‘½ä¸­ç¼“存时不能残留上一次「结果已失效」的提示
                log.info("报表导出任务命中内存缓存:{} {}(生成于 {})", type, period, new Date(hit.finishedAt));
                return hit;
            }
            Task disk = loadFromDisk(key, type, period, mode, toMonth);
            Task disk = loadFromDisk(key, type, period, mode, toMonth, fingerprint);
            if (disk != null) {
                tasks.put(disk.id, disk);
                cache.put(key, disk);
@@ -197,7 +225,9 @@
            log.info("报表导出任务已在执行,复用进行中的任务:{} {}({})", type, period, running.id);
            return running;
        }
        Task task = new Task(type, period, mode, toMonth, importVersion());
        Task task = new Task(type, period, mode, toMonth, fingerprint);
        // æœ‰æ—§ç»“果但没命中(数据或母版变了)→ å‰ç«¯æç¤ºä¸€å¥ï¼Œé¿å…ç”¨æˆ·ä»¥ä¸º"缓存坏了"
        task.staleCache = !forceRefresh && hasStaleCache(type, period, mode, toMonth, fingerprint);
        tasks.put(task.id, task);
        pool.submit(() -> run(task));
        return task;
@@ -240,23 +270,99 @@
        return null;
    }
    private boolean cacheUsable(Task t) {
        if (t == null || !"DONE".equals(t.status) || t.data == null) return false;
    private boolean cacheUsable(Task t, String fingerprint) {
        if (t == null || !"DONE".equals(t.status)) return false;
        if (t.data == null && (t.cacheFile == null || !t.cacheFile.isFile())) return false;
        if (System.currentTimeMillis() - t.finishedAt > CACHE_MAX_AGE_MS) return false;
        long now = importVersion();
        if (now < 0 || t.importVersion < 0) return false; // å–不到数据版本时不冒风险复用
        return now == t.importVersion;
        if (fingerprint == null || t.fingerprint == null) return false; // ç®—不出指纹时不冒风险复用
        return fingerprint.equals(t.fingerprint);
    }
    /** æ•°æ®ç‰ˆæœ¬ï¼import_batch æœ€å¤§ id:任何一次导入都会让它变化,取不到返回 -1(此时禁用缓存) */
    private long importVersion() {
    /**
     * æ±‡æ€»å¤§è¡¨æ•°æ®æŒ‡çº¹ï¼ã€Œä¾èµ–的源表内容校验和」+「母版等文件身份」。
     * - æºè¡¨ï¼šåº“内除 FINGERPRINT_EXCLUDE(投资/能耗/审核/系统/导入日志)以外的全部表,用 CHECKSUM TABLE å–校验和,
     *   èƒ½æ•捉行数变化、新增删除、**原地 UPDATE**;
     * - æ–‡ä»¶ï¼šæ¯ç‰ˆç›®å½•下全部文件(母版 / å‚ç…§ä»¶ / _备份_ å›žå¡«ä»¶ï¼‰+《网约车订单及全省总量.xlsx》,取「名称+大小+修改时间」。
     * ä»»ä¸€é¡¹å˜åŒ–即指纹变化 â†’ ç¼“存失效。计算失败返回 null(此时禁用缓存,只重算不复用)。
     */
    private String dataFingerprint() {
        try {
            List<Object> v = importBatchMapper.selectObjs(new QueryWrapper<ImportBatch>().select("IFNULL(MAX(id),0)"));
            if (v == null || v.isEmpty() || v.get(0) == null) return 0L;
            return ((Number) v.get(0)).longValue();
            List<String> parts = new ArrayList<>();
            try (Connection conn = dataSource.getConnection()) {
                List<String> tables = new ArrayList<>();
                try (Statement st = conn.createStatement();
                     ResultSet rs = st.executeQuery("SELECT table_name FROM information_schema.tables "
                             + "WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name")) {
                    while (rs.next()) tables.add(rs.getString(1));
                }
                for (String t : tables) {
                    if (FINGERPRINT_EXCLUDE.contains(t)) continue;
                    try (Statement st = conn.createStatement();
                         ResultSet rs = st.executeQuery("CHECKSUM TABLE `" + t + "`")) {
                        if (rs.next()) parts.add("T:" + t + ":" + rs.getString(2));
                    }
                }
            }
            for (File f : listIdentityFiles(resolveBaseDir(summaryTemplateDir))) {
                parts.add("F:" + f.getName() + ":" + f.length() + ":" + f.lastModified());
            }
            File wyc = new File(resolveBaseDir(cityPassengerTemplateDir), "网约车订单及全省总量.xlsx");
            if (wyc.isFile()) parts.add("F:" + wyc.getName() + ":" + wyc.length() + ":" + wyc.lastModified());
            return sha256Hex(String.join("|", parts)).substring(0, 16);
        } catch (Exception e) {
            log.warn("读取导入数据版本失败,本次禁用导出缓存:{}", e.getMessage());
            return -1L;
            log.warn("计算数据指纹失败,本次禁用导出缓存:{}", e.getMessage());
            return null;
        }
    }
    /** æ¯ç‰ˆç›®å½•等基础目录定位(与模板解析同策略:配置目录 / user.dir ç›¸å¯¹ / ä¸Šçº§ç›®å½•) */
    private File resolveBaseDir(String dir) {
        if (dir == null) return null;
        String rel = dir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {rel, System.getProperty("user.dir") + "/" + rel, System.getProperty("user.dir") + "/../" + rel};
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File f = new File(root);
            if (f.isDirectory()) return f;
        }
        return null;
    }
    /** ç›®å½•下的文件(按名字排序,排除子目录,保证指纹稳定) */
    private List<File> listIdentityFiles(File dir) {
        List<File> out = new ArrayList<>();
        if (dir == null) return out;
        File[] fs = dir.listFiles(File::isFile);
        if (fs == null) return out;
        Arrays.sort(fs, Comparator.comparing(File::getName));
        out.addAll(Arrays.asList(fs));
        return out;
    }
    private String sha256Hex(String text) throws Exception {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] d = md.digest(text.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 8; i++) sb.append(String.format("%02x", d[i])); // å–前 8 å­—节=16 ä½åå…­è¿›åˆ¶ï¼Œå¤Ÿç”¨ä¸”文件名短
        return sb.toString();
    }
    /** è¯¥æŠ¥è¡¨æœŸæ˜¯å¦å­˜åœ¨ã€Œä¸Žå½“前指纹不一致」的旧结果(用于提示「上次结果已失效,正在按最新数据重算」) */
    private boolean hasStaleCache(String type, String period, String mode, Integer toMonth, String fingerprint) {
        try {
            File dir = cacheDir();
            if (dir == null) return false;
            String prefix = cacheFilePrefix(type, period, mode, toMonth);
            File[] fs = dir.listFiles((d, n) -> n.startsWith(prefix + "_v") && n.endsWith(".xlsx"));
            if (fs == null || fs.length == 0) return false;
            String current = prefix + "_v" + fingerprint + ".xlsx";
            for (File f : fs) {
                if (!f.getName().equals(current)) return true;
            }
            return false;
        } catch (Exception e) {
            return false;
        }
    }
@@ -290,10 +396,10 @@
        try {
            File dir = cacheDir();
            if (dir == null) return;
            long version = t.importVersion;
            if (version < 0) return;
            String fingerprint = t.fingerprint;
            if (fingerprint == null) return;
            String prefix = cacheFilePrefix(t.type, t.period, t.mode, t.toMonth);
            File target = new File(dir, prefix + "_v" + version + ".xlsx");
            File target = new File(dir, prefix + "_v" + fingerprint + ".xlsx");
            java.nio.file.Files.write(target.toPath(), data);
            File[] siblings = dir.listFiles((d, n) -> n.startsWith(prefix + "_v") && n.endsWith(".xlsx"));
            if (siblings != null) {
@@ -308,18 +414,17 @@
        }
    }
    /** ä»Žè½ç›˜ç¼“存里找「同一报表期 + åŒä¸€æ•°æ®ç‰ˆæœ¬ + æœªè¶…期」的结果;没有返回 null */
    private Task loadFromDisk(String key, String type, String period, String mode, Integer toMonth) {
    /** ä»Žè½ç›˜ç¼“存里找「同一报表期 + åŒä¸€æ•°æ®æŒ‡çº¹ + æœªè¶…期」的结果;没有返回 null */
    private Task loadFromDisk(String key, String type, String period, String mode, Integer toMonth, String fingerprint) {
        try {
            if (fingerprint == null) return null; // ç®—不出指纹时不冒风险复用
            File dir = cacheDir();
            if (dir == null) return null;
            long version = importVersion();
            if (version < 0) return null; // å–不到数据版本时不冒风险复用
            File f = new File(dir, cacheFilePrefix(type, period, mode, toMonth) + "_v" + version + ".xlsx");
            File f = new File(dir, cacheFilePrefix(type, period, mode, toMonth) + "_v" + fingerprint + ".xlsx");
            if (!f.isFile()) return null;
            long finished = f.lastModified();
            if (finished <= 0 || System.currentTimeMillis() - finished > CACHE_MAX_AGE_MS) return null;
            Task t = new Task(type, period, mode, toMonth, version);
            Task t = new Task(type, period, mode, toMonth, fingerprint);
            t.cacheFile = f;
            t.createdAt = finished; // å¤ç”¨åŽ†å²ç»“æžœï¼šä»¥ç”Ÿæˆæ—¶é—´ä½œä¸ºèµ·ç‚¹ï¼Œé¿å… elapsedMs å‡ºçŽ°è´Ÿæ•°
            t.fileName = "生成_道路运输量汇总表_" + period + ".xlsx";
traffic-audit-web/src/views/ReportExport.vue
@@ -170,6 +170,7 @@
            <p class="task-step">{{ taskInfo.step || '排队中' }}</p>
            <p class="task-meta" v-if="taskInfo.period && !taskInfo.fromCache">报表期 {{ taskInfo.period }} 已用 {{ ((taskInfo.elapsedMs || 0) / 1000).toFixed(1) }} ç§’</p>
            <p class="task-meta" v-else-if="taskInfo.period">报表期 {{ taskInfo.period }} 上次生成于 {{ taskCacheTime }}</p>
            <p class="task-meta" v-if="taskInfo.staleCache">上次生成的结果已因数据或母版变化失效,本次按最新数据重算。</p>
            <p class="task-meta" v-if="taskInfo.fromCache">本次直接沿用 {{ taskCacheTime }} ç”Ÿæˆçš„结果(期间没有新的数据导入)。如需按最新数据重算,请点「重新生成」。</p>
            <p class="task-done" v-if="taskDownloaded">
              å·²è‡ªåŠ¨ä¸‹è½½ï¼š<b>{{ taskFileName }}</b><span v-if="taskFileSize">({{ taskFileSizeText }})</span>,文件在浏览器默认下载目录。