zhizhijie
9 天以前 2b39c4eb64b194e23f338873e4467eda2b18ccc3
feat(报表生成): 汇总大表导出结果落盘缓存,无新导入即秒回复用

- 结果落盘 docs/生成汇总大表/_cache/<key>_v<数据版本>.xlsx(已加 .gitignore);提交顺序 内存缓存 -> 落盘缓存 -> 真算

- 复用判据:同一报表期 + 同一数据版本(MAX(import_batch.id)) + 7 天内;生成成功后写盘并删除同 key 旧版本,超期文件自动清理

- 修落盘命中时 elapsedMs 为负数(任务起点改为生成时间);前端缓存命中文案改为「本次直接沿用 <时间> 生成的结果(期间没有新的数据导入)」+「上次生成于 …」

- 实测:首次 12.4s 写缓存;再次提交 164ms 内存命中;重启后端后 1.19s 落盘命中;临时插入一次导入后 fromCache=false 重新生成(导入即失效)

- 文档:功能测试报告(10 用例) + 日结 1.5 + 交接文档 P2-6 标记已解决
6个文件已修改
1个文件已添加
214 ■■■■■ 已修改文件
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/交接文档_2026-09-21.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/功能测试报告/功能测试报告_汇总大表导出结果落盘缓存复用_2026-09-22.md 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/工作日结/工作日结_2026-09-22.md 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -97,5 +97,8 @@
_*.zip
_bak_*
# æ±‡æ€»å¤§è¡¨å¯¼å‡ºç»“果落盘缓存(复用上一次生成结果用,不入库)
docs/生成汇总大表/_cache/
# Codex åº”用在本仓库根目录产生的产物目录(保留在本地,不入库)
outputs/
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 | å¯¼å‡ºç¼“存是**内存态** | è¿›ç¨‹é‡å¯å³å¤±æ•ˆï¼›å¦‚需跨重启/多人共用的「导出记录」页需另做 | âŒ |
| P2-6 | ~~导出缓存是内存态~~ | **已解决(2026-09-22)**:结果落盘到 `docs/生成汇总大表/_cache/`(文件名带数据版本 `MAX(import_batch.id)`),内存 â†’ è½ç›˜ â†’ çœŸç®—逐级命中,重启后端后仍可秒回复用;有新导入即失效,7 å¤©ä¸Šé™å¹¶è‡ªåŠ¨æ¸…ç†ã€‚é—ç•™ï¼šæ•°æ®ç‰ˆæœ¬æ˜¯å…¨å±€çš„ï¼ˆä¸Žæ±‡æ€»æ— å…³çš„å¯¼å…¥ä¹Ÿä¼šè®©ç¼“å­˜å¤±æ•ˆï¼‰ï¼Œå¦‚éœ€æŒ‰å¯¼å…¥ç±»åž‹ç»†åˆ†å¾…æŽ’æœŸ | âœ…(可优化项见右)|
| P2-7 | å¸‚州单表「多市州块」兜底提示 | æ˜¯å¦åœ¨è¯†åˆ«åˆ°ä¸€ä»½æ–‡ä»¶å«å¤šä¸ªå¸‚州块时弹窗提示/跳过,待确认(与 P0-1 åŒä¸€æ ¹å› ï¼‰ | â¸ |
| P2-8 | ä»“库根目录 `_*.*` ä¸´æ—¶æ–‡ä»¶å¢žå¤š | `_chk_*`(本次汇总大表排查产生,见汇总大表交接第 10 èŠ‚ï¼‰ã€`_hyb_*`、`_loo_*` ç­‰ï¼Œ**经用户同意后再清** | â¸ |
| P2-9 | `2026å¹´8月道路运输量汇总表_版式原型.xlsx` åŽ»ç•™ | è¿è¡Œæ—¶æœªè¢«å¼•用 | â¸ |
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_»ã×Ü´ó±íµ¼³ö½á¹ûÂäÅÌ»º´æ¸´ÓÃ_2026-09-22.md
New file
@@ -0,0 +1,56 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_汇总大表导出结果落盘缓存复用_2026-09-22
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
用户诉求:「我已经下载过了为什么还要重新生成呢?可以弄一个记忆功能吗——我下载过某个数据,第二次下载期间没有新导入数据,就直接沿用上次算好的结果。不然每次下载都要等很久。」
**改造前**:已有「10 åˆ†é’Ÿå†… + æœŸé—´æ— æ–°å¯¼å…¥ã€çš„内存缓存,但①窗口只有 10 åˆ†é’Ÿï¼›â‘¡å­˜åœ¨å†…存里,进程一重启就没了 â†’ æ­£å¸¸åœºæ™¯ä¸‹ç¬¬äºŒæ¬¡ä¸‹è½½ä»è¦é‡ç®— 13~17 ç§’。
**改造后**:
1. ç»“æžœ**落盘**保存到 `docs/生成汇总大表/_cache/`(已加入 `.gitignore`),文件名 `<key>_v<数据版本>.xlsx`;
2. æäº¤å¯¼å‡ºæ—¶ï¼ˆæœªç‚¹ã€Œé‡æ–°ç”Ÿæˆã€ï¼‰ï¼š**内存缓存 â†’ è½ç›˜ç¼“å­˜ â†’ çœŸç®—**;落盘命中要求「同一报表期 + åŒä¸€æ•°æ®ç‰ˆæœ¬ã€ï¼›
3. **数据版本 = `MAX(import_batch.id)`**:只要期间有任何一次数据导入,版本就变,旧缓存自动失效;
4. å¤ç”¨ä¸Šé™ **7 å¤©**(防陈年结果被当新表发出),超期文件在下次提交时自动清理;同一 key åªä¿ç•™æœ€æ–°ç‰ˆæœ¬æ–‡ä»¶ï¼›
5. å‰ç«¯å‘½ä¸­ç¼“存时显示「本次直接沿用 &lt;生成时间&gt; ç”Ÿæˆçš„结果(期间没有新的数据导入)。如需按最新数据重算,请点「重新生成」。」,并把一行元信息改成「报表期 2026-08 上次生成于 â€¦ã€ï¼ˆä¸å†æ˜¾ç¤ºã€Œä¸Šæ¬¡ç”Ÿæˆç”¨æ—¶ 0.0 ç§’」);
6. ä¿®æŽ‰è½ç›˜å‘½ä¸­æ—¶ `elapsedMs` ä¸ºè´Ÿæ•°çš„问题(以生成时间作为任务起点)。
改动文件:`ReportExportTaskService.java`(核心)、`ReportExportController.java`(注释)、`ReportExport.vue`(文案)、`.gitignore`。
## äºŒã€æµ‹è¯•环境
- åŽç«¯ Spring Boot 8090(重启加载新代码);前端 `npm run serve` 8080(`Compiled successfully`)
- æ•°æ®åº“ MySQL 3308 / `traffic_audit`;报表期 2026-08(月报)
- ç¼“存目录:`docs/生成汇总大表/_cache/`;命中判据 `MAX(import_batch.id)`(测试时为 **541**)
## ä¸‰ã€ç”¨ä¾‹è¡¨
| # | ç”¨ä¾‹ | æ“ä½œ | é¢„期 | å®žé™… | ç»“论 |
| --- | --- | --- | --- | --- | --- |
| T01 | é¦–次提交(无缓存,正向) | `GET /api/report/exportTask/summaryWorkbook?period=2026-08` | çœŸç®—,完成后写落盘缓存 | `RUNNING` â†’ `DONE`(12.4 s);生成 `summaryWorkbook_2026-08_month__v541.xlsx`(1,204,461 B) | é€šè¿‡ |
| T02 | å†æ¬¡æäº¤ï¼ˆå†…存命中) | åŒä¸ŠæŠ¥ | ç«‹å³ DONE、fromCache=true | æäº¤è€—æ—¶ **164 ms**,`fromCache=true`;下载 1,204,461 B ä¸Žç¼“存文件一致 | é€šè¿‡ |
| T03 | **重启后端后再提交(落盘命中,核心)** | åœ/起前后端后提交 | ä»ç«‹å³ DONE(不再重算) | æäº¤ **1,194 ms**,`fromCache=true`,日志「命中落盘缓存…」,下载字节一致 | é€šè¿‡ |
| T04 | æœ‰æ–°å¯¼å…¥ï¼ˆè´Ÿå‘) | ä¸´æ—¶æ’入一条 `import_batch`(id å˜ 542)后提交 | ç¼“存失效、重新生成 | `fromCache=false`、`status=RUNNING` | é€šè¿‡ |
| T05 | åŒ key åªç•™æœ€æ–°ç‰ˆæœ¬ | é‡ç®—完成后看缓存目录 | æ—§ç‰ˆæœ¬æ–‡ä»¶è¢«åˆ é™¤ | ç›®å½•只剩 `…_v542.xlsx`(v541 å·²åˆ ï¼‰ | é€šè¿‡ |
| T06 | ç‰ˆæœ¬å›žè½åŽ | åˆ æŽ‰ä¸´æ—¶å¯¼å…¥è¡Œï¼ˆç‰ˆæœ¬å›žåˆ° 541)后再提交 | ä¸å‘½ä¸­ v542,重算并写回 v541 | `fromCache=false` â†’ é‡ç®—完成;目录只剩 `…_v541.xlsx` | é€šè¿‡ |
| T07 | æ—¶é•¿æ˜¾ç¤ºï¼ˆå›žå½’修复) | è½ç›˜å‘½ä¸­æ—¶çœ‹ `elapsedMs` / å‰ç«¯å…ƒä¿¡æ¯ | ä¸ä¸ºè´Ÿæ•° | ä¿®å¤å‰ `elapsedMs=-72937`;修复后 `elapsedMs=0`,前端显示「报表期 2026-08 上次生成于 2026-09-22 11:27:32」 | é€šè¿‡ |
| T08 | æµè§ˆå™¨ç«¯åˆ°ç«¯ï¼ˆæ­£å‘) | admin/123456 â†’ æŠ¥è¡¨ç”Ÿæˆ â†’ æ±‡æ€»å¤§è¡¨ â†’ 2026-08 â†’ å¯¼å‡º | ç§’出并自动下载,文案正确 | ç‚¹å‡»åŽ**立即**「汇总大表已生成」100%;显示「本次直接沿用 2026-09-22 11:27:32 ç”Ÿæˆçš„结果(期间没有新的数据导入)」、「已自动下载:生成_道路运输量汇总表_2026-08.xlsx ï¼ˆ1.15 MB)…」;按钮仅「重新生成 / å…³é—­ã€ï¼›æŽ§åˆ¶å° 0 æŠ¥é”™ | é€šè¿‡ |
| T09 | ç¼“存不串期(静态) | â€”— | ä¸åŒæŠ¥è¡¨æœŸ/模式互不命中 | key(type\|period\|mode\|toMonth)同时进入内存键与落盘文件名前缀 | é€šè¿‡ï¼ˆé™æ€ï¼‰ |
| T10 | è‡ªåŠ¨æ¸…ç† | â€”— | è¶…期文件被删除 | æ¯æ¬¡æäº¤è°ƒç”¨ `cleanupExpired()`:内存条目与磁盘文件超过 7 å¤©å³æ¸… | é€šè¿‡ï¼ˆé™æ€ï¼‰ |
## å››ã€å‘现的问题与处置
| # | é—®é¢˜ | å¤„ç½® |
| --- | --- | --- |
| P1 | è½ç›˜å‘½ä¸­æ—¶å‰ç«¯ã€Œç”¨æ—¶ã€ä¸ºè´Ÿæ•°ï¼ˆ`elapsedMs=-72937`,因为任务起点是现在、结束时间是历史生成时间) | å·²ä¿®ï¼šè½ç›˜å‘½ä¸­æ—¶æŠŠä»»åŠ¡èµ·ç‚¹è®¾ä¸ºç”Ÿæˆæ—¶é—´ï¼›å‰ç«¯ç¼“å­˜å‘½ä¸­ä¸å†æ˜¾ç¤ºç”¨æ—¶ï¼Œæ”¹æ˜¾ç¤ºã€Œä¸Šæ¬¡ç”ŸæˆäºŽ â€¦ã€ |
| P2 | æµ‹è¯•用的临时 `import_batch` è¡Œ | å·²åˆ é™¤ï¼ˆ`MAX(id)` å›žåˆ° 541、批次 419 æ¡ï¼‰ï¼Œä»…用于验证「导入即失效」 |
| P3 | æ•°æ®ç‰ˆæœ¬æ˜¯**全局**的:任何一次导入(含与汇总大表无关的投资/能耗导入)都会让汇总缓存失效 | å±žä¿å®ˆè®¾è®¡ï¼ˆå®å¯é‡ç®—不可出错)。若希望「只有相关数据导入才失效」,需要按 `import_type` ç»†åˆ†ä¾èµ–,待用户确认后排期 |
| P4 | è½ç›˜ç›®å½• | æ–°å¢ž `docs/生成汇总大表/_cache/` å¹¶åŠ å…¥ `.gitignore`(结果文件不入库) |
## äº”、结论
- ã€Œä¸‹è½½è¿‡ä¸€æ¬¡ã€æœŸé—´æ²¡æœ‰æ–°å¯¼å…¥ â†’ ç¬¬äºŒæ¬¡ç›´æŽ¥æ²¿ç”¨ã€å·²å®žçŽ°ï¼Œä¸”**跨后端重启有效**(这是本次的关键增量);
- å¤ç”¨æ­£ç¡®æ€§ç”±ã€Œæ•°æ®ç‰ˆæœ¬ï¼ˆ`MAX(import_batch.id)`)+ 7 å¤©ä¸Šé™ã€åŒé‡æŠŠå…³ï¼šæœ‰æ–°å¯¼å…¥å¿…定重算,测试已覆盖;
- ç”¨æˆ·ä½“验:命中缓存时点导出**秒回**(毫秒级),不再每次等 13~17 ç§’;「重新生成」保留为强制重算入口;
- æµè§ˆå™¨ç«¯åˆ°ç«¯å®žæµ‹é€šè¿‡ï¼Œæ— æŽ§åˆ¶å°æŠ¥é”™ã€‚
测试人:Codex(AI) 测试日期:2026-09-22
docs/¹¤×÷ÈÕ½á/¹¤×÷ÈÕ½á_2026-09-22.md
@@ -69,6 +69,16 @@
- ä¿®å¤ä¸¤ä¸ªå£å¾„坑:货运量周转量表的"行内累计 + æ¹–北省合计行"重复计算(1,149,579 â†’ 130,311.89 ä¸‡å¨ï¼‰ã€ç»“束月无数据自动回退;
- ä¸Žæ‰‹å·¥æ ¸ç®—一致:公交 1-8 æœˆ 110776.34 ä¸‡äººæ¬¡ã€è´§è¿ä¼ä¸š 313,998,248 å¨ã€è´§è¿é‡å‘¨è½¬é‡ 130,311.89 ä¸‡å¨ã€‚
### 5. æ±‡æ€»å¤§è¡¨å¯¼å‡ºç»“果落盘缓存(用户:"下载过就不该再等很久,能不能记住上次结果")
- **背景**:原来只有「10 åˆ†é’Ÿå†… + æœŸé—´æ— æ–°å¯¼å…¥ã€çš„**内存**缓存,窗口短、重启即失效 â†’ ç¬¬äºŒæ¬¡ä¸‹è½½è¿˜è¦ç­‰ 13~17 ç§’。
- **改动**(`ReportExportTaskService.java` ä¸ºä¸»ï¼‰ï¼š
  1. ç»“æžœ**落盘**到 `docs/生成汇总大表/_cache/`,文件名 `<key>_v<数据版本>.xlsx`(数据版本=`MAX(import_batch.id)`),已加 `.gitignore`;
  2. æäº¤é¡ºåºï¼šå†…存缓存 â†’ è½ç›˜ç¼“存(同报表期 + åŒæ•°æ®ç‰ˆæœ¬ + 7 å¤©å†…)→ çœŸç®—;生成成功后写盘并删除同 key æ—§ç‰ˆæœ¬ï¼›
  3. å‰ç«¯æ–‡æ¡ˆï¼šå‘½ä¸­ç¼“存显示「本次直接沿用 <时间> ç”Ÿæˆçš„结果(期间没有新的数据导入)」,元信息改为「上次生成于 â€¦ã€ã€‚
- **顺带修 bug**:落盘命中时 `elapsedMs` ä¸ºè´Ÿæ•°ï¼ˆä»»åŠ¡èµ·ç‚¹ç”¨çŽ°åœ¨ã€ç»“æŸæ—¶é—´æ˜¯åŽ†å²ç”Ÿæˆæ—¶é—´ï¼‰â†’ å·²æ”¹ä¸ºä»¥ç”Ÿæˆæ—¶é—´ä½œèµ·ç‚¹ã€‚
- **实测**:首次 12.4 s å†™ç¼“存;再次提交 **164 ms** å‘½ä¸­å†…存;**重启后端后提交 1.19 s å‘½ä¸­è½ç›˜**;临时插一条导入记录后 `fromCache=false` é‡æ–°ç”Ÿæˆï¼ˆéªŒè¯ã€Œæœ‰æ–°å¯¼å…¥å³å¤±æ•ˆã€ï¼‰ï¼›æµè§ˆå™¨ç‚¹å¯¼å‡ºç§’出、文案与按钮正确、控制台 0 æŠ¥é”™ã€‚
## äºŒã€æµ‹è¯•结果摘要
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_数据查看补齐网约车与个体客运类型_2026-09-22.md`
@@ -77,6 +87,7 @@
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_数据查看口径筛选与表头单位_2026-09-22.md`(接口 7 æ¡ + çœŸæœº 2 æ¡å…¨é€šè¿‡ï¼‰
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_数据查看按市州汇总与导出_2026-09-22.md`(接口 10 æ¡ + çœŸæœº 4 æ¡å…¨é€šè¿‡ï¼‰
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_汇总大表进度弹窗优化_2026-09-22.md`(6 æ¡ç”¨ä¾‹é€šè¿‡ï¼šç”Ÿæˆä¸­çŠ¶æ€ / å®Œæˆæç¤º / å†æ¬¡ä¸‹è½½èµ°ç¼“å­˜ / å…³é—­ / æ— æŽ§åˆ¶å°æŠ¥é”™ / å…¶å®ƒå¯¼å‡ºæœªå—影响)
- æŠ¥å‘Šï¼š`docs/功能测试报告/功能测试报告_汇总大表导出结果落盘缓存复用_2026-09-22.md`(10 æ¡ç”¨ä¾‹é€šè¿‡ï¼šé¦–次生成写缓存 / å†…存命中 164ms / **重启后落盘命中 1.19s** / æœ‰æ–°å¯¼å…¥å³å¤±æ•ˆ / åŒ key åªç•™æœ€æ–°ç‰ˆæœ¬ / æ—¶é•¿æ˜¾ç¤ºä¿®å¤ / æµè§ˆå™¨ç«¯åˆ°ç«¯ï¼‰
- çŽ¯å¢ƒï¼šåŽç«¯ `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/controller/ReportExportController.java
@@ -99,7 +99,7 @@
    /**
     * æ±‡æ€»å¤§è¡¨å¼‚步导出:立即返回任务号,前端轮询进度、完成后再下载文件。
     * ç”Ÿæˆä¸€æ¬¡çº¦ 10~20 ç§’,走异步可避免点击后长时间无响应;同报表期 10 åˆ†é’Ÿå†…的结果直接复用(期间有导入则自动失效),
     * ç”Ÿæˆä¸€æ¬¡çº¦ 10~20 ç§’,走异步可避免点击后长时间无响应;同报表期结果直接复用(落盘保存、进程重启后仍有效;期间有导入则自动失效),
     * refresh=true å¯å¼ºåˆ¶é‡æ–°ç”Ÿæˆã€‚
     */
    @GetMapping("/exportTask/summaryWorkbook")
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
@@ -8,6 +8,7 @@
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import java.io.File;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashMap;
@@ -26,10 +27,11 @@
 * ç›®çš„(2026-09-20 ç”¨æˆ·ä¸ŠæŠ¥ã€Œç‚¹ä¸‹è½½è¦ç­‰ä¸€ä¼šï¼Œæ•°æ®å¤šäº†ä¼šä¸ä¼šå¡æ­»ã€ï¼‰ï¼š
 * 1) ç‚¹å‡»åŽç«‹åˆ»æœ‰åé¦ˆï¼Œä¸å†é•¿æ—¶é—´æ— å“åº”ï¼›
 * 2) ç”Ÿæˆè¿‡ç¨‹ä¸å ç”¨ HTTP è¯·æ±‚线程,避免多人同时点把容器拖死(工作线程固定 1 ä¸ªï¼Œå¤šä½™è¯·æ±‚显示「排队中」);
 * 3) åŒä¸€æŠ¥è¡¨æœŸ 10 åˆ†é’Ÿå†…的结果可复用,避免每次重算整本约 4 ä¸‡æ ¼å…¬å¼ã€‚
 * 3) åŒä¸€æŠ¥è¡¨æœŸçš„结果可复用(**落盘保存,进程重启后依然有效**),避免每次重算整本约 4 ä¸‡æ ¼å…¬å¼ã€‚
 *
 * ç¼“存正确性守卫:只有当「距上次生成不超过 TTL」且「期间没有任何新的数据导入(import_batch æœ€å¤§ id æœªå˜ï¼‰ã€
 * æ—¶æ‰å¤ç”¨ï¼›å¯¼å…¥å³å¤±æ•ˆï¼Œé¿å…æŠŠè¿‡æœŸæŠ¥è¡¨ç»™ç”¨æˆ·ã€‚前端仍提供「重新生成」强制刷新。
 * ç¼“存正确性守卫:只有当「期间没有任何新的数据导入(import_batch æœ€å¤§ id æœªå˜ï¼‰ã€ä¸”「结果不超过 7 å¤©ã€æ—¶æ‰å¤ç”¨ï¼›
 * å¯¼å…¥å³å¤±æ•ˆï¼Œé¿å…æŠŠè¿‡æœŸæŠ¥è¡¨ç»™ç”¨æˆ·ã€‚前端仍提供「重新生成」强制重算(跳过缓存)。
 * è½ç›˜ä½ç½®ï¼š{summary.template-dir}/_cache/<key>_v<数据版本>.xlsx
 */
@Slf4j
@Service
@@ -40,13 +42,41 @@
    /** ç”Ÿæˆç»“果保留时长:期间可反复下载,过后自动清理 */
    private static final long RESULT_TTL_MS = 30 * 60 * 1000L;
    /** åŒä¸€æŠ¥è¡¨æœŸç»“果复用时长(且期间无新导入时才复用) */
    private static final long CACHE_TTL_MS = 10 * 60 * 1000L;
    /** åŒä¸€æŠ¥è¡¨æœŸç»“果复用上限:只要期间没有新导入就一直沿用;超过该天数不再复用(防止陈年结果被当新表发出) */
    private static final long CACHE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000;
    /** è½ç›˜ç¼“存子目录(相对 summary.template-dir) */
    private static final String CACHE_SUBDIR = "_cache";
    @Resource
    private ReportExportService reportService;
    @Resource
    private ImportBatchMapper importBatchMapper;
    /** æ±‡æ€»å·¥ä½œç°¿ç›®å½•(与 ReportExportService åŒé…ç½®ï¼‰ï¼Œè½ç›˜ç¼“存放在其 _cache å­ç›®å½• */
    @org.springframework.beans.factory.annotation.Value("${summary.template-dir:docs/生成汇总大表}")
    private String summaryTemplateDir;
    /** è½ç›˜ç¼“存目录;定位策略与模板一致(配置目录 / user.dir ç›¸å¯¹ / ä¸Šçº§ç›®å½•) */
    private File cacheDir() {
        String rel = summaryTemplateDir;
        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 base = new File(root);
            if (base.isDirectory()) {
                File dir = new File(base, CACHE_SUBDIR);
                if (!dir.isDirectory() && !dir.mkdirs()) continue;
                return dir;
            }
        }
        return null;
    }
    /** ç¼“存文件名前缀(不含数据版本与扩展名):key é‡Œçš„非法字符统一替换 */
    private String cacheFilePrefix(String type, String period, String mode, Integer toMonth) {
        return (cacheKey(type, period, mode, toMonth)).replaceAll("[^0-9A-Za-z_-]", "_");
    }
    private final Map<String, Task> tasks = new ConcurrentHashMap<>();
    /** åŒæŠ¥è¡¨æœŸæœ€è¿‘一次成功结果:key -> task */
@@ -64,7 +94,7 @@
        private final String period;
        private final String mode;
        private final Integer toMonth;
        private final long createdAt = System.currentTimeMillis();
        private volatile long createdAt = System.currentTimeMillis();
        /** ç”Ÿæˆæ—¶çš„æ•°æ®ç‰ˆæœ¬ï¼ˆimport_batch æœ€å¤§ id),用于判断缓存是否过期 */
        private final long importVersion;
        private volatile String status = "PENDING"; // PENDING/RUNNING/DONE/FAILED
@@ -72,6 +102,8 @@
        private volatile String step = "排队中";
        private volatile String error;
        private volatile byte[] data;
        /** è½ç›˜ç¼“存文件:内存数据被清理后仍可从这里取(跨进程重启复用) */
        private volatile File cacheFile;
        private volatile String fileName;
        private volatile boolean fromCache;
        private volatile long finishedAt;
@@ -93,11 +125,24 @@
        public String getStatus() { return status; }
        public boolean isDone() { return "DONE".equals(status) && data != null; }
        public boolean isDone() {
            return "DONE".equals(status) && (data != null || (cacheFile != null && cacheFile.isFile()));
        }
        public boolean isFromCache() { return fromCache; }
        public byte[] getData() { return data; }
        public byte[] getData() {
            if (data != null) return data;
            File f = cacheFile;
            if (f != null && f.isFile()) {
                try {
                    return java.nio.file.Files.readAllBytes(f.toPath());
                } catch (Exception e) {
                    log.warn("读取导出缓存文件失败:{} {}", f.getAbsolutePath(), e.getMessage());
                }
            }
            return null;
        }
        public String getFileName() { return fileName; }
@@ -135,8 +180,16 @@
            Task hit = cache.get(key);
            if (cacheUsable(hit)) {
                hit.fromCache = true;
                log.info("报表导出任务命中缓存:{} {}(生成于 {})", type, period, new Date(hit.finishedAt));
                log.info("报表导出任务命中内存缓存:{} {}(生成于 {})", type, period, new Date(hit.finishedAt));
                return hit;
            }
            Task disk = loadFromDisk(key, type, period, mode, toMonth);
            if (disk != null) {
                tasks.put(disk.id, disk);
                cache.put(key, disk);
                log.info("报表导出任务命中落盘缓存:{} {}(生成于 {},文件 {})", type, period,
                        new Date(disk.finishedAt), disk.cacheFile.getName());
                return disk;
            }
        }
        Task running = findRunning(key);
@@ -168,6 +221,7 @@
            t.status = "DONE";
            t.finishedAt = System.currentTimeMillis();
            cache.put(cacheKey(t.type, t.period, t.mode, t.toMonth), t);
            writeToDisk(t, data);
            log.info("报表导出任务完成:{} {} ç”¨æ—¶ {} ms,{} å­—节", t.type, t.period, t.finishedAt - start, data.length);
        } catch (Throwable e) {
            t.status = "FAILED";
@@ -188,7 +242,7 @@
    private boolean cacheUsable(Task t) {
        if (t == null || !"DONE".equals(t.status) || t.data == null) return false;
        if (System.currentTimeMillis() - t.finishedAt > CACHE_TTL_MS) 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;
@@ -218,7 +272,66 @@
        }
        for (Iterator<Map.Entry<String, Task>> it = cache.entrySet().iterator(); it.hasNext(); ) {
            Task t = it.next().getValue();
            if (t.finishedAt <= 0 || now - t.finishedAt > CACHE_TTL_MS) it.remove();
            if (t.finishedAt <= 0 || now - t.finishedAt > CACHE_MAX_AGE_MS) it.remove();
        }
        File dir = cacheDir();
        if (dir == null) return;
        File[] fs = dir.listFiles((d, n) -> n.endsWith(".xlsx"));
        if (fs == null) return;
        for (File f : fs) {
            if (now - f.lastModified() > CACHE_MAX_AGE_MS && !f.delete()) {
                log.warn("清理过期导出缓存失败:{}", f.getAbsolutePath());
            }
        }
    }
    /** ç”ŸæˆæˆåŠŸåŽæŠŠç»“æžœè½ç›˜ï¼Œä¾›è¿›ç¨‹é‡å¯åŽå¤ç”¨ï¼›å†™ç›˜å¤±è´¥åªå‘Šè­¦ï¼Œä¸å½±å“æœ¬æ¬¡å¯¼å‡º */
    private void writeToDisk(Task t, byte[] data) {
        try {
            File dir = cacheDir();
            if (dir == null) return;
            long version = t.importVersion;
            if (version < 0) return;
            String prefix = cacheFilePrefix(t.type, t.period, t.mode, t.toMonth);
            File target = new File(dir, prefix + "_v" + version + ".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) {
                for (File f : siblings) {
                    if (!f.getName().equals(target.getName()) && !f.delete()) {
                        log.warn("清理旧版本导出缓存失败:{}", f.getAbsolutePath());
                    }
                }
            }
        } catch (Exception e) {
            log.warn("导出结果落盘失败(不影响本次导出,仅无法跨重启复用):{}", e.getMessage());
        }
    }
    /** ä»Žè½ç›˜ç¼“存里找「同一报表期 + åŒä¸€æ•°æ®ç‰ˆæœ¬ + æœªè¶…期」的结果;没有返回 null */
    private Task loadFromDisk(String key, String type, String period, String mode, Integer toMonth) {
        try {
            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");
            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);
            t.cacheFile = f;
            t.createdAt = finished; // å¤ç”¨åŽ†å²ç»“æžœï¼šä»¥ç”Ÿæˆæ—¶é—´ä½œä¸ºèµ·ç‚¹ï¼Œé¿å… elapsedMs å‡ºçŽ°è´Ÿæ•°
            t.fileName = "生成_道路运输量汇总表_" + period + ".xlsx";
            t.status = "DONE";
            t.percent = 100;
            t.step = "完成";
            t.finishedAt = finished;
            t.fromCache = true;
            return t;
        } catch (Exception e) {
            log.warn("读取落盘导出缓存失败:{}", e.getMessage());
            return null;
        }
    }
traffic-audit-web/src/views/ReportExport.vue
@@ -168,8 +168,9 @@
            <el-progress :percentage="taskInfo.percent || 0" :status="taskInfo.status === 'FAILED' ? 'exception' : (taskDone ? 'success' : null)"
                         :stroke-width="14"></el-progress>
            <p class="task-step">{{ taskInfo.step || '排队中' }}</p>
            <p class="task-meta" v-if="taskInfo.period">报表期 {{ taskInfo.period }} {{ taskInfo.fromCache ? '上次生成用时' : '已用' }} {{ (taskInfo.elapsedMs / 1000).toFixed(1) }} ç§’</p>
            <p class="task-meta" v-if="taskInfo.fromCache">本次直接使用 {{ taskCacheTime }} ç”Ÿæˆçš„结果(10 åˆ†é’Ÿå†…且期间没有新导入数据)。如需按最新数据重算,请点「重新生成」。</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.fromCache">本次直接沿用 {{ taskCacheTime }} ç”Ÿæˆçš„结果(期间没有新的数据导入)。如需按最新数据重算,请点「重新生成」。</p>
            <p class="task-done" v-if="taskDownloaded">
              å·²è‡ªåŠ¨ä¸‹è½½ï¼š<b>{{ taskFileName }}</b><span v-if="taskFileSize">({{ taskFileSizeText }})</span>,文件在浏览器默认下载目录。
            </p>