zhizhijie
2026-09-20 35d098a89613c96179391178b7f8143dec393ada
feat(报表): 汇总大表导出异步化 + 结果缓存 + 进度弹窗

用户提问「点导出要等一会才反应,数据多了会不会卡死」:
- 新增 ReportExportTaskService:固定 1 线程池后台生成,提交即返回 taskId,前端轮询进度;
同一报表期并发提交自动去重;结果保留 30 分钟、10 分钟内可复用。
- 缓存带数据版本守卫:版本取 import_batch 最大 id,期间有新导入即失效(取不到版本禁用缓存)。
- ReportExportService 新增带进度回调的 exportSummaryWorkbook 重载(进度点 2/6/12/22/45/70/95%)。
- ReportExportController 新增 /exportTask/summaryWorkbook、/exportTask/{id}、/exportTask/{id}/file 三个接口,原同步接口保留。
- 前端 ReportExport.vue 进度弹窗:步骤/百分比/用时/缓存来源/失败原因 + 保存文件/重新生成/后台运行/关闭。

同时修复一个严重回归:新增 computed 与文件中已有 computed 同名重复(后者覆盖前者),
导致 myModules/visibleModules 丢失、报表生成页 created() 抛错整页白屏;已并入同一 computed 块并浏览器复验。

测试:《功能测试报告_汇总大表异步导出与缓存_2026-09-20.md》17 条用例通过
(接口 8 + 浏览器实测 7 + 复验 2)。
4个文件已修改
3个文件已添加
567 ■■■■■ 已修改文件
docs/功能测试报告/功能测试报告_汇总大表异步导出与缓存_2026-09-20.md 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/功能测试报告/汇总大表异步导出进度弹窗_2026-09-20.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/工作日结/工作日结_2026-09-20.md 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_»ã×Ü´ó±íÒì²½µ¼³öÓ뻺´æ_2026-09-20.md
New file
@@ -0,0 +1,86 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_汇总大表异步导出与缓存_2026-09-20
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
用户上报(问题 3):**「汇总大表点下载后要过一会才反应下载,现在数据库的数据还不是很多,那数据库的数据多了这个程序会卡死吗?」**
背景:汇总大表导出是**同步请求**——点击后浏览器要等 10~20 ç§’才拿到文件(母版读取+回填 6.5s、整本约 4 ä¸‡æ ¼å…¬å¼é‡ç®— 4.1s、写盘传输 3.4s),期间页面无任何反馈;
且生成过程一直占着 HTTP è¯·æ±‚线程,多人同时点会让容器线程池被长任务占满。
本次改造(用户确认「可以」):
1. **异步任务化**:新增 `ReportExportTaskService`(固定 1 çº¿ç¨‹æ±  `report-export-task`,daemon)。
   å‰ç«¯æäº¤åŽç«‹å³æ‹¿åˆ°ã€Œä»»åŠ¡å·ã€ï¼Œå†æŒ‰ 700ms è½®è¯¢è¿›åº¦ï¼ˆæ­¥éª¤ + ç™¾åˆ†æ¯” + å·²ç”¨æ—¶é•¿ï¼‰ï¼Œå®ŒæˆåŽå•独下载文件。
2. **结果缓存**:同一报表期 10 åˆ†é’Ÿå†…的成功结果直接复用(`fromCache=true`),跳过母版读取与公式重算;
   ç¼“存带**数据版本守卫**——数据版本取 `SELECT IFNULL(MAX(id),0) FROM import_batch`,期间只要有新导入就自动失效(取不到版本时禁用缓存)。
3. **去重与排队**:同一报表期已有任务在跑时直接复用该任务,不会重复排队重算。
4. **前端进度弹窗**:进度条 + æ­¥éª¤æ–‡æ¡ˆ + å·²ç”¨æ—¶é•¿ + ç¼“存来源时间 + å¤±è´¥åŽŸå› ï¼›ç”Ÿæˆä¸­å¯ã€ŒåŽå°è¿è¡Œã€ï¼ˆæ”¶å¼¹çª—ï¼Œå®ŒæˆåŽä»è‡ªåŠ¨ä¸‹è½½ï¼‰ï¼Œå®ŒæˆåŽå¯ã€Œä¿å­˜æ–‡ä»¶ / é‡æ–°ç”Ÿæˆ / å…³é—­ã€ã€‚
5. **进度点**:2% æ ¡éªŒæ¯ç‰ˆ â†’ 6% è¯»å–母版 â†’ 12% è¿˜åŽŸæœˆåº¦å— â†’ 22% å›žå¡«å½“月数据 â†’ 45% åˆ·æ–°æŽ’名页与跨页合成页 â†’ 70% é‡ç®—整本公式 â†’ 95% å†™å‡ºæ–‡ä»¶ â†’ 100% å®Œæˆã€‚
回答用户疑问:**数据量增大主要是 SQL èšåˆå˜æ…¢ï¼Œä¸ä¼šã€Œå¡æ­»ã€**;改造后生成走后台线程、同报表期结果可复用、同报表期并发自动去重,
即使多人同时点也只是排队(弹窗显示「排队中」),不会占满 Web çº¿ç¨‹ã€‚
## äºŒã€æµ‹è¯•环境
- åŽç«¯ Spring Boot 8090(`Started TrafficAuditApplication` 15:10:53)、前端 Vue 8080(`Compiled successfully`)
- æ•°æ®åº“ MySQL localhost:3308/traffic_audit
- æŠ¥è¡¨æœŸï¼š2026-08(月报,正常用例)/ 2025-01(无数据,负向用例)
- æŽ¥å£è„šæœ¬ï¼š`_qa_task.ps1`、`_qa_task2.ps1`、`_qa_task3.ps1`(保留未删)
- æµè§ˆå™¨å®žæµ‹ï¼šPlaywright 1.57 + æœ¬æœº Chrome(无头),真实点击「报表生成」页按钮
- è¯æ®æˆªå›¾ï¼š`docs/功能测试报告/汇总大表异步导出进度弹窗_2026-09-20.png`
## ä¸‰ã€ç”¨ä¾‹è¡¨
### 3.1 æŽ¥å£å±‚
| ç¼–号 | ç”¨ä¾‹ | è¾“å…¥ | é¢„期 | å®žé™… | ç»“论 |
| --- | --- | --- | --- | --- | --- |
| T-A1 | æäº¤å¼‚步任务 | `GET /api/report/exportTask/summaryWorkbook?period=2026-08&mode=month` | ç«‹å³è¿”回 taskId,不阻塞 | æäº¤è€—æ—¶ **0.07s**,进度 6→22→45→70→95→100,终态 DONE,**14.4s** | é€šè¿‡ |
| T-A2 | ä¸‹è½½ä»»åŠ¡æ–‡ä»¶ | `GET /api/report/exportTask/{id}/file` | è¿”回 xlsx | 1,325,109 å­—节,**1.27s** | é€šè¿‡ |
| T-A3 | 10 åˆ†é’Ÿå†…同报表期复用 | å†æ¬¡æäº¤åŒå‚æ•° | fromCache=true、同 taskId、近乎瞬时 | fromCache=true,**0.01s**,taskId ç›¸åŒ | é€šè¿‡ |
| T-A4 | å¼ºåˆ¶é‡ç®— | æäº¤ `refresh=true` | æ–°ä»»åŠ¡å·ã€çœŸå®žé‡ç®— | æ–° taskId,14.2s é‡ç®—完成 | é€šè¿‡ |
| T-A5 | è¿è¡Œä¸­å¹¶å‘去重 | è¿žå‘两次 `refresh=true` | è¿”回同一 taskId | åŒä¸€ taskId,11.1s | é€šè¿‡ |
| T-A6 | æ–°å¢žå¯¼å…¥ä½¿ç¼“存失效 | ä¸´æ—¶æ’入一条 `import_batch`(id 533→534)后提交 | fromCache=false,重新生成 | fromCache=false,11.4s é‡ç®—ï¼›**测试后已 DELETE æ¸…回 id=533** | é€šè¿‡ |
| T-A7 | è´Ÿå‘:不存在的 taskId | `GET /exportTask/notexist123` / `.../file` | è¿”回明确错误 | çŠ¶æ€æŽ¥å£ `code=500`「导出任务不存在或已过期(生成结果保留 30 åˆ†é’Ÿï¼‰ã€ï¼›æ–‡ä»¶æŽ¥å£ HTTP 500 + åŒæç¤º | é€šè¿‡ |
| T-A8 | è´Ÿå‘:未完成就下载 | ä»»åŠ¡ RUNNING æ—¶è¯·æ±‚ `/file` | æ‹’绝并说明 | æŠ›ã€Œå¯¼å‡ºä»»åŠ¡å°šæœªå®Œæˆï¼ˆå½“å‰çŠ¶æ€ï¼šRUNNING)」 | é€šè¿‡ |
### 3.2 æµè§ˆå™¨å®žæµ‹ï¼ˆçœŸå®žç‚¹å‡»ï¼‰
| ç¼–号 | ç”¨ä¾‹ | è¾“å…¥ | é¢„期 | å®žé™… | ç»“论 |
| --- | --- | --- | --- | --- | --- |
| UI-1 | è¿›åº¦å¼¹çª— | æŠ¥è¡¨ç”Ÿæˆ â†’ æ±‡æ€»å¤§è¡¨ â†’ 2026-08 â†’ ç‚¹ã€Œå¯¼å‡ºã€ | ç«‹å³å¼¹çª—并逐档推进 | å¼¹çª—即时出现,实测采样:0.7s è¯»å–母版工作簿(6%) â†’ 1.5s å›žå¡«å½“月数据(22%) â†’ 2.2s åˆ·æ–°æŽ’名页与跨页合成页(45%) â†’ 7.2s é‡ç®—整本公式(约 4 ä¸‡æ ¼ï¼‰(70%) â†’ 10.1s å†™å‡ºæ–‡ä»¶(95%) â†’ 11.6s å®Œæˆ(100%) | é€šè¿‡ |
| UI-2 | å®ŒæˆåŽè‡ªåŠ¨ä¸‹è½½ | åŒä¸Š | è‡ªåŠ¨è§¦å‘ä¿å­˜ | ä¸‹è½½ `生成_道路运输量汇总表_2026-08.xlsx`,**1,325,109 å­—节**,与接口产物同哈希 | é€šè¿‡ |
| UI-3 | ç¼“存命中提示 | å…³é—­åŽå†æ¬¡ç‚¹ã€Œå¯¼å‡ºã€ | æç¤ºæ¥æºæ—¶é—´ + ã€Œä¸‹è½½ã€æŒ‰é’® | ã€Œä¸Šæ¬¡ç”Ÿæˆç”¨æ—¶ 11.3 ç§’」「本次直接使用 2026-09-20 15:43:50 ç”Ÿæˆçš„结果(10 åˆ†é’Ÿå†…且期间没有新导入数据)」,按钮变「下载」,秒级完成 | é€šè¿‡ |
| UI-4 | ã€Œé‡æ–°ç”Ÿæˆã€ | ç¼“存态点「重新生成」 | çœŸå®žé‡ç®— | è¿›åº¦ä»Ž 0 é‡æ–°æŽ¨è¿›ï¼ˆã€Œå·²ç”¨ 2.1 ç§’」),按钮区切回「保存文件/重新生成/后台运行」,23.4s å†…完成并自动下载 | é€šè¿‡ |
| UI-5 | ã€ŒåŽå°è¿è¡Œã€ | ç”Ÿæˆä¸­ç‚¹ã€ŒåŽå°è¿è¡Œã€ | æ”¶èµ·å¼¹çª—、任务继续、完成仍自动下载 | å¼¹çª—æ”¶èµ· + æç¤ºã€Œå·²è½¬å…¥åŽå°ç”Ÿæˆï¼Œå®ŒæˆåŽä¼šè‡ªåŠ¨ä¸‹è½½ã€ï¼ŒéšåŽè‡ªåŠ¨ä¸‹è½½ 1,325,109 å­—节 | é€šè¿‡ |
| UI-6 | è´Ÿå‘:无数据报表期 | é€‰ 2025-01 â†’ å¯¼å‡º â†’ ç¡®è®¤ã€Œä»è¦å¯¼å‡ºã€ | æ˜¾ç¤ºå¤±è´¥åŽŸå› ã€å¯å…³é—­ã€ä¸å¯ä¿å­˜ | å¼¹çª—显示「生成失败|未找到模板文件: 2025å¹´1月道路运输量汇总表.xlsx」,X å¯å…³é—­ï¼Œã€Œä¿å­˜æ–‡ä»¶ã€ç¦ç”¨ï¼Œã€Œé‡æ–°ç”Ÿæˆã€å¯ç”¨ | é€šè¿‡ |
| UI-7 | äº§ç‰©ä¸€è‡´æ€§ | å†·å¯åЍ件 vs åˆ·æ–°é‡ç®—ä»¶ | æ•°æ®ä¸€è‡´ | ä¸¤æ¬¡äº§ç‰©è§£åŒ…后**逐条目一致**(无差异条目);三份缓存下载件 SHA256 å®Œå…¨ç›¸åŒ | é€šè¿‡ |
### 3.3 é¡ºæ‰‹å¤éªŒï¼ˆç”¨æˆ·é—®é¢˜ 2)
| ç¼–号 | ç”¨ä¾‹ | è¾“å…¥ | é¢„期 | å®žé™… | ç»“论 |
| --- | --- | --- | --- | --- | --- |
| P-1 | é¢„览同比项是否补全 | æ±‡æ€»å¤§è¡¨é¢„览弹窗 | 8 é¡¹åŒæ¯”都在 | 12 è¡Œï¼ˆè´§è¿/城市客运/中口径 Ã— å½“月·累计 Ã— é‡Â·å‘¨è½¬ï¼‰ï¼ŒåŒæ¯”列齐全 | é€šè¿‡ |
| P-2 | ã€Œç´¯è®¡è´§ç‰©å‘¨è½¬é‡Â·ç´¯è®¡åŒæ¯” 0%」是否为 bug | æŸ¥åº“ `scale_split_transport` | åº”为取数源本身的值 | åº“内 `total_yoy=0.00000346`(约 0.0003%)→ ä¸€ä½å°æ•°å››èˆäº”入即 **0%**,非计算错误 | é€šè¿‡ï¼ˆéžç¼ºé™·ï¼‰ |
## å››ã€å‘现的问题与处置
1. **`ReportExport.vue` å‡ºçŽ°é‡å¤çš„ `computed` é”®ï¼ˆæœ¬æ¬¡æµè§ˆå™¨å®žæµ‹å‘现,属严重回归)**
   æ–°å¢žçš„异步导出计算属性(taskRunning/taskDone/taskCacheTime)另起了一个 `computed: {`,
   JS å¯¹è±¡å­—面量**同名键后者覆盖前者**,导致原有的 `username / realName / myModules / visibleModules` è®¡ç®—属性全部丢失,
   ã€ŒæŠ¥è¡¨ç”Ÿæˆã€é¡µ `created()` ç›´æŽ¥æŠ› `Cannot read properties of undefined (reading 'filter')`,**整页白屏不可用**。
   â†’ å·²æŠŠ 4 ä¸ªè®¡ç®—属性并入原 `computed` å—(并加注释说明不可另起同名 key),编译通过并浏览器复验正常。
   ï¼ˆæŽ¥å£å±‚测试无法发现此类问题,这也是本次坚持做浏览器点击实测的原因。)
2. **提交/轮询失败时弹窗会空转**:后端统一返回 `Result`(错误时 HTTP ä» 200、`data` ä¸ºç©ºï¼‰ï¼Œ
   åŽŸä»£ç ç›´æŽ¥ `this.taskInfo = res.data || {}` ä¼šå¾—到一个空对象 â†’ è¿›åº¦åœåœ¨ 0% ä¸”不给提示。
   â†’ æäº¤ä¸Žè½®è¯¢éƒ½æ”¹ä¸ºå…ˆåˆ¤ `res.code !== 200 || !res.data`:提交失败立即显示失败原因;轮询判为「任务已失效」并停止轮询。
3. **生成中弹窗无出口**:原「关闭」按钮 `:disabled="!taskDone"`,生成中三个按钮全灰、右上角 X ä¹Ÿéšè—ï¼Œç”¨æˆ·è¢«é”åœ¨å¼¹çª—里等 10~20 ç§’。
   â†’ æ”¹ä¸ºç”Ÿæˆä¸­æ˜¾ç¤ºã€ŒåŽå°è¿è¡Œã€ï¼ˆæ”¶å¼¹çª—、继续生成、完成自动下载),结束后才显示「关闭」;X åœ¨æˆåŠŸæˆ–å¤±è´¥åŽå‡å¯è§ã€‚
4. **缓存态文案歧义**:命中缓存时元信息仍写「已用 11.3 ç§’」(其实是上次生成耗时)。
   â†’ ç¼“存态改为「上次生成用时」,避免误以为本次也算了 11.3 ç§’。
## äº”、结论
- æŽ¥å£ç”¨ä¾‹ 8 æ¡ã€æµè§ˆå™¨å®žæµ‹ 7 æ¡ã€å¤éªŒ 2 æ¡ï¼Œ**全部通过**;无遗留未验证项。
- æ±‡æ€»å¤§è¡¨å¯¼å‡ºçŽ°åœ¨æ˜¯ã€Œç‚¹å‡»å³åé¦ˆ â†’ è¿›åº¦å¯è§† â†’ å®Œæˆå³ä¸‹è½½ã€ï¼Œç¼“存命中时秒级返回;同一报表期并发自动去重,生成不占 Web çº¿ç¨‹ã€‚
- é™„带修复了会导致报表生成页整页白屏的重复 `computed` å›žå½’(该问题在接口测试中不可见)。
docs/¹¦ÄܲâÊÔ±¨¸æ/»ã×Ü´ó±íÒì²½µ¼³ö½ø¶Èµ¯´°_2026-09-20.png
docs/¹¤×÷ÈÕ½á/¹¤×÷ÈÕ½á_2026-09-20.md
@@ -68,6 +68,20 @@
  å¹¶æ–°å¢žã€Œå¯¼å‡ºæ˜Žç»†ã€æŒ‰é’®ï¼ˆCSV,带 BOM,可直接 Excel æ‰“开),用于用户自行核对/修改不一致内容。
- è¦†ç›– 4 å¤„:批量导入结果、市州单表批量导入结果、单文件导入失败、投资与投资系统不一致清单。
### 1.7 æ±‡æ€»å¤§è¡¨å¯¼å‡ºå¼‚步化 + ç»“果缓存(用户提问「点下载要等一会,数据多了会不会卡死」)
- **现象**:汇总大表点「导出」后要等 10~20 ç§’才触发下载,期间页面无任何反馈;同步请求一直占着 Web çº¿ç¨‹ã€‚
- **定位**:14 ç§’里母版读取+回填 6.5s、整本约 4 ä¸‡æ ¼å…¬å¼é‡ç®— 4.1s、写盘传输 3.4s——耗时来自 Excel å¤„理,**与数据库行数关系不大**,数据多了主要是 SQL èšåˆå˜æ…¢ï¼Œä¸ä¼šã€Œå¡æ­»ã€ã€‚
- **改动**:
  1. æ–°å¢ž `ReportExportTaskService`(固定 1 çº¿ç¨‹æ±  daemon):提交即返回 taskId,前端 700ms è½®è¯¢ã€Œæ­¥éª¤ + ç™¾åˆ†æ¯” + å·²ç”¨æ—¶é•¿ã€ï¼›ç»“果保留 30 åˆ†é’Ÿï¼ˆå†…存态,进程重启失效)。
  2. **结果缓存 10 åˆ†é’Ÿ + æ•°æ®ç‰ˆæœ¬å®ˆå«**:版本取 `SELECT IFNULL(MAX(id),0) FROM import_batch`,期间只要有新导入就自动失效(取不到版本时禁用缓存);同一报表期并发提交自动去重、复用同一任务。
  3. `ReportExportService` æ–°å¢žå¸¦è¿›åº¦å›žè°ƒçš„ `exportSummaryWorkbook(period, mode, toMonth, progress)` é‡è½½ï¼ˆåŽŸä¸¤ä¸ªé‡è½½å§”æ‰˜å®ƒï¼‰ï¼Œè¿›åº¦ç‚¹ 2%/6%/12%/22%/45%/70%/95%。
  4. `ReportExportController` æ–°å¢ž 3 ä¸ªæŽ¥å£ï¼š`/exportTask/summaryWorkbook`(提交)、`/exportTask/{taskId}`(进度)、`/exportTask/{taskId}/file`(下载);原同步接口保留兼容。
  5. å‰ç«¯ `ReportExport.vue` è¿›åº¦å¼¹çª—:进度条 + æ­¥éª¤ + ç”¨æ—¶ + ç¼“存来源时间 + å¤±è´¥åŽŸå› ï¼Œä»¥åŠã€Œä¿å­˜æ–‡ä»¶ / é‡æ–°ç”Ÿæˆ / åŽå°è¿è¡Œ / å…³é—­ã€ã€‚
- **实测**:提交 0.07s;进度 6→22→45→70→95→100、14.4s å®Œæˆï¼›ç¼“存命中 0.01s;`refresh=true` çœŸå®žé‡ç®—;临时插一条导入后缓存自动失效。
- **同时修掉一个严重回归**:本次新增的 `computed` ä¸Žæ–‡ä»¶ä¸­å·²æœ‰ `computed` **同名重复**,JS å¯¹è±¡åŽè€…覆盖前者 â†’ `username/myModules/visibleModules` å…¨éƒ¨ä¸¢å¤±ï¼Œ
  ã€ŒæŠ¥è¡¨ç”Ÿæˆã€é¡µ `created()` ç›´æŽ¥æŠ›é”™ã€**整页白屏**。接口测试发现不了,是浏览器实测才暴露的(详见测试报告第四节)。
## äºŒã€æµ‹è¯•结果摘要
《功能测试报告_投资明细表就绪与预览类型补齐_2026-09-20.md》:**7 æ¡ç”¨ä¾‹é€šè¿‡ï¼Œ1 æ¡æœªå®žæµ‹**(缺母版提示,未临时移动用户母版文件)。
@@ -109,6 +123,22 @@
| å…¶å®ƒæŠ•资报表回归 | investBillion/County/FiveYearLogistics å„ 136 è¡Œã€ä¸¤å¼ æ˜Žç»†è¡¨ 39/97 è¡Œï¼Œready=true,均可导出 |
| è´Ÿå‘ | `types=foo` â†’ ready=false,note「未知报表类型:foo」 |
《功能测试报告_汇总大表异步导出与缓存_2026-09-20.md》:**17 æ¡ç”¨ä¾‹é€šè¿‡ï¼ˆæŽ¥å£ 8 + æµè§ˆå™¨å®žæµ‹ 7 + å¤éªŒ 2)**。
| æ ¡éªŒé¡¹ | ç»“æžœ |
| --- | --- |
| æäº¤å¼‚步任务 | 0.07s è¿”回 taskId;进度 6→22→45→70→95→100;14.4s å®Œæˆ |
| ä»»åŠ¡æ–‡ä»¶ä¸‹è½½ | 1,325,109 å­—节,1.27s |
| 10 åˆ†é’Ÿå†…缓存复用 | fromCache=true、同 taskId、0.01s |
| refresh=true å¼ºåˆ¶é‡ç®— | æ–°ä»»åŠ¡å·ï¼ŒçœŸå®žé‡ç®— 14.2s |
| è¿è¡Œä¸­å¹¶å‘去重 | ä¸¤æ¬¡æäº¤è¿”回同一 taskId(11.1s) |
| æ–°å¢žå¯¼å…¥ä½¿ç¼“存失效 | import_batch id 533→534 åŽ fromCache=false(测试后已 DELETE æ¸…回 533) |
| è´Ÿå‘:不存在 / æœªå®Œæˆ taskId | ã€Œå¯¼å‡ºä»»åŠ¡ä¸å­˜åœ¨æˆ–å·²è¿‡æœŸã€ã€Œå°šæœªå®Œæˆï¼ˆå½“å‰çŠ¶æ€ï¼šRUNNING)」 |
| æµè§ˆå™¨å®žæµ‹ | è¿›åº¦å¼¹çª—采样、自动下载、缓存提示、重新生成、后台运行、失败提示 6 é¡¹å…¨é€šè¿‡ |
| äº§ç‰©ä¸€è‡´æ€§ | ç¼“存件与重算件解包后逐条目一致;三份缓存件 SHA256 ç›¸åŒ |
| é™„带修复 | `computed` é‡å¤é”®å¯¼è‡´æŠ¥è¡¨ç”Ÿæˆé¡µæ•´é¡µç™½å±ï¼ˆå·²ä¿®å¤å¹¶æµè§ˆå™¨å¤éªŒï¼‰ |
| å¤éªŒï¼ˆç”¨æˆ·é—®é¢˜ 2) | é¢„览同比 12 è¡Œé½å…¨ï¼›ã€Œç´¯è®¡è´§ç‰©å‘¨è½¬é‡ 0%」经查库为取数源本身 `total_yoy=0.00000346`,非缺陷 |
## ä¸‰ã€é—留问题与待办
1. æ­¦æ±‰ç‰©æµé¡¹ç›®æ¸…单口径(已确认忽略)。
@@ -117,10 +147,9 @@
4. ~~C ç»„(城市客运页结构归一,需用户先在 WPS æ•´åˆ—删除 AJ åˆ—)~~ **已完成**(改用 Excel COM æ•´åˆ—删除 + å…¬å¼è¿˜åŽŸï¼‰ï¼›
   D ç»„(增速排名措辞)/B4(导出前基数体检 + å¼¹çª— + ã€Šæ•°æ®ç¼ºå¤±æ¸…单》页)待排期。
5. ç¼ºæ¯ç‰ˆæç¤ºï¼ˆT08)未实测,可在有测试副本时补测。
6. æ±‡æ€»å¤§è¡¨å¯¼å‡ºæ˜¯**同步请求**,当前约 14 ç§’(每次都要重算整本约 4 ä¸‡æ ¼å…¬å¼ï¼‰ï¼šæ•°æ®é‡å†æ¶¨ä¸»è¦æ˜¯ SQL èšåˆå˜æ…¢ï¼Œ
   ä¸ä¼šã€Œå¡æ­»ã€ï¼Œä½†å»ºè®®åŽç»­åšæˆå¼‚步任务 + è¿›åº¦æç¤ºã€‚
7. éƒ¨ç½²åŒ… `deploy/traffic-audit-deploy-20260913/backend/docs/生成汇总大表/` ä¸Ž `_deploytest/` ä¸‹ä»æ˜¯**带 AJ åˆ—的旧母版**,
   ä¸‹æ¬¡æ‰“包时需同步(未经用户确认不擅自改部署产物)。
6. ~~汇总大表导出是同步请求,建议做成异步任务 + è¿›åº¦æç¤º~~ **已完成**(见 1.7)。缓存是内存态,进程重启即失效;如需跨重启/多人共用的「导出记录」页可后补。
7. éƒ¨ç½²åŒ… `deploy/traffic-audit-deploy-20260913/backend/docs/生成汇总大表/` ä¸Ž `_deploytest/` ä¸‹ä»æ˜¯**带 AJ åˆ—的旧母版** â€”—
   ç”¨æˆ· 2026-09-20 æ˜Žç¡®ã€Œéƒ¨ç½²åŒ…先不管」,**暂不处理**(待要发包时再同步)。
8. ä¸­å£å¾„明细 4 æ ¼ï¼ˆåŸŽé™…城乡巡游出租/网约车)因 2025å¹´8月基数为 0,同比显示 `#DIV/0!`(不可比),如需改空白需用户确认口径。
## å››ã€æ˜Žæ—¥è®¡åˆ’
@@ -128,7 +157,7 @@
1. ç­‰å¤©é—¨æºæ•°æ® / è†å·žé»„石口径确认后补导并复验。
2. æŽ¨è¿› C/D ç»„与 B4。
3. ä¸Žç”¨æˆ·ç¡®è®¤æ˜¯å¦æŠŠ 09-18 å¤œé—´èµ·çš„æœ¬åœ°æäº¤ä¸€å¹¶æŽ¨é€åˆ°å…¬å¸ gitea(用户 09-19 æ›¾è¦æ±‚「今天先不推公司」)。
4. è¯„估汇总大表导出异步化(长任务进度条 / ç»“果缓存),并确认部署包母版同步方式。
4. ~~评估汇总大表导出异步化~~ **已完成**(见 1.7:异步任务 + è¿›åº¦å¼¹çª— + 10 åˆ†é’Ÿç»“果缓存 + æ•°æ®ç‰ˆæœ¬å®ˆå«ï¼‰ï¼›éƒ¨ç½²åŒ…母版按用户要求暂不处理。
9. **8 æœˆç‰ˆåå °æ±‡æ€»è¡¨æœªæ¸…理**:用户只清了 7 æœˆç‰ˆï¼ˆ108032→90112 å­—节),8 æœˆç‰ˆï¼ˆmtime 2026-08-20)仍含武汉 27 + é»„石 4 æ®µï¼›
   è‹¥é‡å¯¼ 8 æœˆç‰©æµï¼Œå·²åˆ é™¤çš„ 31 ä¸ªé¡¹ç›®ä¼šé‡æ–°å»ºæ¡£ â†’ é‡å¯¼å‰éœ€åŒæ ·æ¸…理(或由我代做,待用户确认)。
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java
@@ -2,12 +2,14 @@
import com.trafficaudit.common.Result;
import com.trafficaudit.reportexport.service.ReportExportService;
import com.trafficaudit.reportexport.service.ReportExportTaskService;
import com.trafficaudit.system.service.OperationLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -24,6 +26,8 @@
    @Resource
    private ReportExportService reportService;
    @Resource
    private ReportExportTaskService exportTaskService;
    @Resource
    private OperationLogService operationLogService;
@@ -91,6 +95,47 @@
                                                        @RequestParam(value = "toMonth", required = false) Integer toMonth) throws Exception {
        logExport("道路运输量汇总表.xlsx", period);
        return buildResponse(reportService.exportSummaryWorkbook(period, mode, toMonth), String.format("生成_道路运输量汇总表_%s.xlsx", period));
    }
    /**
     * æ±‡æ€»å¤§è¡¨å¼‚步导出:立即返回任务号,前端轮询进度、完成后再下载文件。
     * ç”Ÿæˆä¸€æ¬¡çº¦ 10~20 ç§’,走异步可避免点击后长时间无响应;同报表期 10 åˆ†é’Ÿå†…的结果直接复用(期间有导入则自动失效),
     * refresh=true å¯å¼ºåˆ¶é‡æ–°ç”Ÿæˆã€‚
     */
    @GetMapping("/exportTask/summaryWorkbook")
    public Result<java.util.Map<String, Object>> submitSummaryWorkbookTask(@RequestParam("period") String period,
                                                                          @RequestParam(value = "mode", defaultValue = "month") String mode,
                                                                          @RequestParam(value = "toMonth", required = false) Integer toMonth,
                                                                          @RequestParam(value = "refresh", defaultValue = "false") boolean refresh) {
        ReportExportTaskService.Task task = exportTaskService.submit(
                ReportExportTaskService.TYPE_SUMMARY_WORKBOOK, period, mode, toMonth, refresh);
        if (!task.isFromCache()) {
            logExport("道路运输量汇总表.xlsx", period);
        }
        return Result.ok(task.toView());
    }
    /** æŸ¥è¯¢å¼‚步导出任务进度(结果保留 30 åˆ†é’Ÿï¼‰ */
    @GetMapping("/exportTask/{taskId}")
    public Result<java.util.Map<String, Object>> exportTaskStatus(@PathVariable("taskId") String taskId) {
        ReportExportTaskService.Task task = exportTaskService.get(taskId);
        if (task == null) {
            return Result.error("导出任务不存在或已过期(生成结果保留 30 åˆ†é’Ÿï¼‰");
        }
        return Result.ok(task.toView());
    }
    /** ä¸‹è½½å¼‚步导出任务已生成的文件 */
    @GetMapping("/exportTask/{taskId}/file")
    public ResponseEntity<byte[]> exportTaskFile(@PathVariable("taskId") String taskId) throws Exception {
        ReportExportTaskService.Task task = exportTaskService.get(taskId);
        if (task == null) {
            throw new RuntimeException("导出任务不存在或已过期(生成结果保留 30 åˆ†é’Ÿï¼‰");
        }
        if (!task.isDone()) {
            throw new RuntimeException("导出任务尚未完成(当前状态:" + task.getStatus() + "),请稍后再试");
        }
        return buildResponse(task.getData(), task.getFileName());
    }
    /** ç”Ÿæˆ_网约车客运量拆分表.xlsx(月度:订单附件1 + å…¨çœ pin + å·¡æ¸¸å‡ºç§ŸæœˆæŠ¥ï¼‰ */
@@ -308,4 +353,3 @@
            .body(data);
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -3507,7 +3507,7 @@
     * ä¿ç•™å…¨éƒ¨é¡µç­¾/版式/公式/2025 å¹´ç¼“存值,仅把表头年份动态化为目标年;数据填充按 P2 èµ·é€æ­¥æŽ¥å…¥ã€‚
     */
    public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
        return exportSummaryWorkbook(period, mode, null);
        return exportSummaryWorkbook(period, mode, null, null);
    }
    /** ç”Ÿæˆ_道路运输量汇总表.xlsx(P0-P1 éª¨æž¶ï¼Œæ”¯æŒ toMonth è£å‰ªï¼‰ï¼š
@@ -3515,6 +3515,13 @@
     *  toMonth éžç©ºä¸”小于 M æ—¶ï¼ŒæŠŠ 8 ä¸ªå°è´¦/合成页裁到 toMonth æœˆï¼ˆtoMonth+1..M çš„当月值/同比列清空、保留表头,
     *  ç´¯è®¡ä¸Žç´¯è®¡åŒæ¯”公式改为按 toMonth æœˆé‡ç®—),用于“先生成 1-6 æœˆã€1-7 æœˆæ‰©åˆ—”验证。 */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth) throws Exception {
        return exportSummaryWorkbook(period, mode, toMonth, null);
    }
    /** åŒä¸Šï¼Œé¢å¤–通过 progress å›žè°ƒæŠ¥å‘Šè¿›åº¦ï¼ˆå¼‚步导出任务用:步骤百分比 + æ­¥éª¤æ–‡æ¡ˆï¼‰ */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth,
                                        java.util.function.BiConsumer<Integer, String> progress) throws Exception {
        reportProgress(progress, 2, "校验报表期与母版");
        int year = Integer.parseInt(period.contains("-") ? period.split("-")[0] : period);
        int month = period.contains("-") ? monthOf(period, mode) : 12;
        File template = resolveSummaryMother(period, mode);
@@ -3522,9 +3529,11 @@
            throw new RuntimeException("未找到汇总表母版《" + year + "å¹´" + month + "月道路运输量汇总表.xlsx》(docs/生成汇总大表)");
        }
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        reportProgress(progress, 6, "读取母版工作簿");
        ZipSecureFile.setMinInflateRatio(0.0001); // æ–°ç‰ˆæ¯ç‰ˆ styles.xml ç”± openpyxl é«˜åŽ‹ç¼©ï¼ˆ0.0099<0.01)触发 POI Zip-bomb è¯¯æŠ¥ï¼Œè¯»å–后还原
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            reportProgress(progress, 12, "还原同期月度块");
            File donor = resolveSummaryDonor(year, month);
            if (donor != null) {
                try (InputStream din = new FileInputStream(donor); XSSFWorkbook dwb = new XSSFWorkbook(din)) {
@@ -3543,11 +3552,13 @@
            int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
            List<String> dbProblems = new ArrayList<>();
            String effPeriod = String.format("%04d-%02d", year, effMonth);
            reportProgress(progress, 22, "回填当月数据");
            int dbFilled = summaryWorkbookFiller.fillMonthlyLedger(wb, effPeriod, year, effMonth, dbProblems);
            int yoyFilled = summaryWorkbookFiller.ensureMonthYoyFormulas(wb, year, effMonth);
            int monthFormulaFilled = summaryWorkbookFiller.ensureMonthFormulas(wb, year, effMonth);
            // ç´¯è®¡åŒæ¯”分母修正:母版把「去年各月」与「去年1-M月累计」辅助列同时计入(重复计/漏当月)
            int cumYoyFixed = summaryWorkbookFiller.repairCumulativeYoyFormulas(wb, year, effMonth);
            reportProgress(progress, 45, "刷新排名页与跨页合成页");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // å››å¼ è·¨é¡µåˆæˆé¡µéšæŠ¥è¡¨æœŸåˆ·æ–°
            int widthApplied = applySummaryReferenceLayout(wb);
@@ -3558,16 +3569,28 @@
            applyTwoDecimalFormat(wb); // æ•°å€¼æ˜¾ç¤ºä¸¤ä½å°æ•°ï¼ˆä¿ç•™å…¨ç²¾åº¦ï¼‰ï¼›% / æ—¥æœŸç­‰æ—¢æœ‰æ ·å¼ä¸æ”¹å˜
            int inlineFixed = normalizeStaleInlineStrings(wb); // æ¸…掉「非 inlineStr å´æ®‹ç•™ <is>」的非法格,否则 Excel æ‹’绝打开
            if (inlineFixed > 0) log.warn("汇总工作簿清掉残留 inlineStr éžæ³•æ ¼ {} ä¸ªï¼ˆæ¯ç‰ˆç”± openpyxl å†™å‡ºæ—¶çš„典型问题)", inlineFixed);
            reportProgress(progress, 70, "重算整本公式(约 4 ä¸‡æ ¼ï¼‰");
            int[] recalc = recalcWorkbookFormulas(wb); // å…¬å¼é‡ç®—并写回缓存值:全省行(Σ市州)/累计列/排名 SUM æ‰èƒ½è¢«é¢„览与二次处理读到
            log.info("汇总工作簿公式重算完成:成功 {} æ ¼ï¼Œå¤±è´¥ {} æ ¼ï¼ˆå¤±è´¥æ ¼ä¿ç•™æ¯ç‰ˆæ—§ç¼“存值,打开时按 fullCalcOnLoad é‡ç®—)", recalc[0], recalc[1]);
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // è¿˜åŽŸ/改写公式后打开即重算
            reportProgress(progress, 95, "写出文件");
            return toBytes(wb);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /** å¯¼å‡ºè¿›åº¦å›žè°ƒï¼ˆå¼‚步导出任务用):回调自身异常不影响导出 */
    private static void reportProgress(java.util.function.BiConsumer<Integer, String> progress, int percent, String step) {
        if (progress == null) return;
        try {
            progress.accept(percent, step);
        } catch (Exception ignore) {
            // è¿›åº¦å›žè°ƒå¤±è´¥ä¸å½±å“å¯¼å‡º
        }
    }
    /**
     * æ±‡æ€»å·¥ä½œç°¿å…¬å¼é‡ç®—:POI å†™æ–‡ä»¶ä¸å¸¦ç¼“存值,导致「全省行(Σ17市州)」「累计列」「排名 SUM」
     * åœ¨é¢„览/二次处理里只看到母版上一期的旧值(2026-09-18 ç”¨æˆ·æŠ¥â€œ8月没数据 / å…¨çœè¡Œåœåœ¨ä¸Šä¸€æœŸâ€çš„æ ¹å› ä¹‹ä¸€ï¼‰ã€‚
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportTaskService.java
New file
@@ -0,0 +1,234 @@
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 java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
 * æŠ¥è¡¨å¯¼å‡ºå¼‚步任务:把耗时十几秒的《生成_道路运输量汇总表》生成挪到后台线程,
 * å‰ç«¯åªæ‹¿ã€Œä»»åŠ¡å· + è¿›åº¦ï¼ˆæ­¥éª¤/百分比)」,完成后单独下载文件。
 *
 * ç›®çš„(2026-09-20 ç”¨æˆ·ä¸ŠæŠ¥ã€Œç‚¹ä¸‹è½½è¦ç­‰ä¸€ä¼šï¼Œæ•°æ®å¤šäº†ä¼šä¸ä¼šå¡æ­»ã€ï¼‰ï¼š
 * 1) ç‚¹å‡»åŽç«‹åˆ»æœ‰åé¦ˆï¼Œä¸å†é•¿æ—¶é—´æ— å“åº”ï¼›
 * 2) ç”Ÿæˆè¿‡ç¨‹ä¸å ç”¨ HTTP è¯·æ±‚线程,避免多人同时点把容器拖死(工作线程固定 1 ä¸ªï¼Œå¤šä½™è¯·æ±‚显示「排队中」);
 * 3) åŒä¸€æŠ¥è¡¨æœŸ 10 åˆ†é’Ÿå†…的结果可复用,避免每次重算整本约 4 ä¸‡æ ¼å…¬å¼ã€‚
 *
 * ç¼“存正确性守卫:只有当「距上次生成不超过 TTL」且「期间没有任何新的数据导入(import_batch æœ€å¤§ id æœªå˜ï¼‰ã€
 * æ—¶æ‰å¤ç”¨ï¼›å¯¼å…¥å³å¤±æ•ˆï¼Œé¿å…æŠŠè¿‡æœŸæŠ¥è¡¨ç»™ç”¨æˆ·ã€‚前端仍提供「重新生成」强制刷新。
 */
@Slf4j
@Service
public class ReportExportTaskService {
    /** æ±‡æ€»å·¥ä½œç°¿å¯¼å‡ºä»»åŠ¡ç±»åž‹ */
    public static final String TYPE_SUMMARY_WORKBOOK = "summaryWorkbook";
    /** ç”Ÿæˆç»“果保留时长:期间可反复下载,过后自动清理 */
    private static final long RESULT_TTL_MS = 30 * 60 * 1000L;
    /** åŒä¸€æŠ¥è¡¨æœŸç»“果复用时长(且期间无新导入时才复用) */
    private static final long CACHE_TTL_MS = 10 * 60 * 1000L;
    @Resource
    private ReportExportService reportService;
    @Resource
    private ImportBatchMapper importBatchMapper;
    private final Map<String, Task> tasks = new ConcurrentHashMap<>();
    /** åŒæŠ¥è¡¨æœŸæœ€è¿‘一次成功结果:key -> task */
    private final Map<String, Task> cache = new ConcurrentHashMap<>();
    private final ExecutorService pool = Executors.newFixedThreadPool(1, r -> {
        Thread t = new Thread(r, "report-export-task");
        t.setDaemon(true);
        return t;
    });
    /** ä¸€æ¬¡å¯¼å‡ºä»»åŠ¡ï¼ˆå†…å­˜æ€ï¼Œè¿›ç¨‹é‡å¯å³å¤±æ•ˆï¼‰ */
    public static class Task {
        private final String id = UUID.randomUUID().toString().replace("-", "");
        private final String type;
        private final String period;
        private final String mode;
        private final Integer toMonth;
        private final long createdAt = System.currentTimeMillis();
        /** ç”Ÿæˆæ—¶çš„æ•°æ®ç‰ˆæœ¬ï¼ˆimport_batch æœ€å¤§ id),用于判断缓存是否过期 */
        private final long importVersion;
        private volatile String status = "PENDING"; // PENDING/RUNNING/DONE/FAILED
        private volatile int percent = 0;
        private volatile String step = "排队中";
        private volatile String error;
        private volatile byte[] data;
        private volatile String fileName;
        private volatile boolean fromCache;
        private volatile long finishedAt;
        Task(String type, String period, String mode, Integer toMonth, long importVersion) {
            this.type = type;
            this.period = period;
            this.mode = mode;
            this.toMonth = toMonth;
            this.importVersion = importVersion;
        }
        void progress(int pct, String stepText) {
            if (pct > this.percent) this.percent = Math.min(pct, 99);
            if (stepText != null) this.step = stepText;
        }
        public String getId() { return id; }
        public String getStatus() { return status; }
        public boolean isDone() { return "DONE".equals(status) && data != null; }
        public boolean isFromCache() { return fromCache; }
        public byte[] getData() { return data; }
        public String getFileName() { return fileName; }
        public String getError() { return error; }
        public Map<String, Object> toView() {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("taskId", id);
            m.put("type", type);
            m.put("period", period);
            m.put("mode", mode);
            m.put("toMonth", toMonth);
            m.put("status", status);
            m.put("percent", "DONE".equals(status) ? 100 : percent);
            m.put("step", step);
            m.put("fromCache", fromCache);
            m.put("fileName", fileName);
            m.put("error", error);
            long end = finishedAt > 0 ? finishedAt : System.currentTimeMillis();
            m.put("elapsedMs", end - createdAt);
            m.put("finishedAt", finishedAt > 0 ? new Date(finishedAt) : null);
            return m;
        }
    }
    /**
     * æäº¤å¯¼å‡ºä»»åŠ¡ã€‚forceRefresh=true æ—¶å¿½ç•¥ç¼“存重新生成;
     * å¦åˆ™åœ¨ã€ŒåŒä¸€æŠ¥è¡¨æœŸã€10 åˆ†é’Ÿå†…、期间无新导入」的前提下直接复用上次结果。
     * åŒä¸€æŠ¥è¡¨æœŸå·²æœ‰ä»»åŠ¡åœ¨è·‘æ—¶ç›´æŽ¥è¿”å›žè¯¥ä»»åŠ¡ï¼Œé¿å…é‡å¤æŽ’é˜Ÿã€‚
     */
    public Task submit(String type, String period, String mode, Integer toMonth, boolean forceRefresh) {
        cleanupExpired();
        String key = cacheKey(type, period, mode, toMonth);
        if (!forceRefresh) {
            Task hit = cache.get(key);
            if (cacheUsable(hit)) {
                hit.fromCache = true;
                log.info("报表导出任务命中缓存:{} {}(生成于 {})", type, period, new Date(hit.finishedAt));
                return hit;
            }
        }
        Task running = findRunning(key);
        if (running != null) {
            log.info("报表导出任务已在执行,复用进行中的任务:{} {}({})", type, period, running.id);
            return running;
        }
        Task task = new Task(type, period, mode, toMonth, importVersion());
        tasks.put(task.id, task);
        pool.submit(() -> run(task));
        return task;
    }
    public Task get(String taskId) {
        if (taskId == null) return null;
        cleanupExpired();
        return tasks.get(taskId);
    }
    private void run(Task t) {
        t.status = "RUNNING";
        t.step = "开始生成";
        long start = System.currentTimeMillis();
        try {
            byte[] data = reportService.exportSummaryWorkbook(t.period, t.mode, t.toMonth, t::progress);
            t.data = data;
            t.fileName = "生成_道路运输量汇总表_" + t.period + ".xlsx";
            t.step = "完成";
            t.status = "DONE";
            t.finishedAt = System.currentTimeMillis();
            cache.put(cacheKey(t.type, t.period, t.mode, t.toMonth), t);
            log.info("报表导出任务完成:{} {} ç”¨æ—¶ {} ms,{} å­—节", t.type, t.period, t.finishedAt - start, data.length);
        } catch (Throwable e) {
            t.status = "FAILED";
            t.error = e.getMessage() == null ? e.toString() : e.getMessage();
            t.step = "生成失败";
            t.finishedAt = System.currentTimeMillis();
            log.error("报表导出任务失败:{} {}(用时 {} ms)", t.type, t.period, t.finishedAt - start, e);
        }
    }
    private Task findRunning(String key) {
        for (Task t : tasks.values()) {
            if (!key.equals(cacheKey(t.type, t.period, t.mode, t.toMonth))) continue;
            if ("PENDING".equals(t.status) || "RUNNING".equals(t.status)) return t;
        }
        return null;
    }
    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;
        long now = importVersion();
        if (now < 0 || t.importVersion < 0) return false; // å–不到数据版本时不冒风险复用
        return now == t.importVersion;
    }
    /** æ•°æ®ç‰ˆæœ¬ï¼import_batch æœ€å¤§ id:任何一次导入都会让它变化,取不到返回 -1(此时禁用缓存) */
    private long importVersion() {
        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();
        } catch (Exception e) {
            log.warn("读取导入数据版本失败,本次禁用导出缓存:{}", e.getMessage());
            return -1L;
        }
    }
    private String cacheKey(String type, String period, String mode, Integer toMonth) {
        return type + "|" + period + "|" + mode + "|" + (toMonth == null ? "" : toMonth);
    }
    private void cleanupExpired() {
        long now = System.currentTimeMillis();
        for (Iterator<Map.Entry<String, Task>> it = tasks.entrySet().iterator(); it.hasNext(); ) {
            Task t = it.next().getValue();
            if (t.finishedAt > 0 && now - t.finishedAt > RESULT_TTL_MS) it.remove();
        }
        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();
        }
    }
    @PreDestroy
    public void shutdown() {
        pool.shutdownNow();
        try {
            pool.awaitTermination(3, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
traffic-audit-web/src/views/ReportExport.vue
@@ -160,6 +160,25 @@
            <el-button size="small" type="primary" @click="saveNote">保存</el-button>
          </span>
        </el-dialog>
        <!-- æ±‡æ€»å¤§è¡¨å¼‚步生成进度(生成一次约 10~20 ç§’,避免点击后长时间无响应) -->
        <el-dialog title="正在生成汇总大表" :visible.sync="taskVisible" width="460px"
                   :close-on-click-modal="false" :show-close="taskFinished">
          <div class="task-body">
            <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-error" v-if="taskInfo.status === 'FAILED'">{{ taskInfo.error }}</p>
          </div>
          <span slot="footer">
            <el-button size="small" type="primary" :disabled="!taskDone" @click="downloadTaskFile">{{ taskInfo.fromCache ? '下载' : '保存文件' }}</el-button>
            <el-button size="small" :disabled="taskRunning" @click="restartTask">重新生成</el-button>
            <el-button size="small" v-if="taskRunning" @click="minimizeTask">后台运行</el-button>
            <el-button size="small" v-else @click="closeTask">关闭</el-button>
          </span>
        </el-dialog>
      </el-main>
    </el-container>
  </el-container>
@@ -189,6 +208,11 @@
      noteDialogVisible: false,
      noteEditingKey: '',
      noteDraft: '',
      // æ±‡æ€»å¤§è¡¨å¼‚步生成任务
      taskVisible: false,
      taskInfo: {},
      taskTimer: null,
      taskKey: 'summaryWorkbook',
      modules: [
        {
          key: 'summary', title: '汇总大表', items: [
@@ -277,6 +301,26 @@
        options: months.map(mo => ({ label: y + 'å¹´' + mo + '月', value: y + '-' + String(mo).padStart(2, '0') }))
      }))
    },
    // ===== ä»¥ä¸‹ä¸ºæ±‡æ€»å¤§è¡¨å¼‚步生成弹窗的派生状态(必须并入同一个 computed,另起同名 key ä¼šè¦†ç›–掉上面的计算属性)=====
    taskRunning() {
      const s = this.taskInfo && this.taskInfo.status
      return s === 'PENDING' || s === 'RUNNING'
    },
    taskDone() {
      return this.taskInfo && this.taskInfo.status === 'DONE'
    },
    // ç”Ÿæˆç»“束(成功或失败)后才允许收起弹窗
    taskFinished() {
      const s = this.taskInfo && this.taskInfo.status
      return s === 'DONE' || s === 'FAILED'
    },
    taskCacheTime() {
      const t = this.taskInfo && this.taskInfo.finishedAt
      if (!t) return '上次'
      const d = new Date(t)
      const p = (n) => String(n).padStart(2, '0')
      return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
    }
  },
  watch: {
    activeKey() {
@@ -289,7 +333,11 @@
    this.activeKey = (mine.length ? mine : this.visibleModules.map(m => m.key))[0]
    this.ensureCycle()
    this.refreshAll()
  },  methods: {
  },
  beforeDestroy() {
    this.stopTaskPolling()
  },
  methods: {
    moduleTitle(key) {
      const m = this.modules.find(x => x.key === key)
      return m ? m.title : key
@@ -468,6 +516,86 @@
      if (continuous) return '1~' + cov[cov.length - 1] + '月均有数据'
      return cov.map(x => x + '月').join('、') + ' æœ‰æ•°æ®'
    },
    // ==================== æ±‡æ€»å¤§è¡¨å¼‚步生成(进度弹窗) ====================
    // åŽç«¯ ReportExportTaskService:提交任务 -> è½®è¯¢è¿›åº¦ -> å®ŒæˆåŽä¸‹è½½æ–‡ä»¶ã€‚
    // åŒæŠ¥è¡¨æœŸ 10 åˆ†é’Ÿå†…且期间没有新导入数据时,后端直接返回上次结果(弹窗内会说明来源时间)。
    startSummaryTask(refresh) {
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
      this.stopTaskPolling()
      this.taskInfo = { status: 'PENDING', percent: 0, step: '提交任务', period: this.period, elapsedMs: 0 }
      this.taskVisible = true
      this.loading = 'gen-' + this.curModule.key
      const params = Object.assign({}, this.buildParams(), { refresh: refresh ? 'true' : 'false' })
      api.get('/report/exportTask/summaryWorkbook', { params, timeout: 60000 })
        .then(res => {
          // åŽç«¯ç»Ÿä¸€è¿”回 Result:code éž 200 æ—¶ data ä¸ºç©ºï¼Œå¿…须提示而不是空转
          if (!res || res.code !== 200 || !res.data || !res.data.taskId) {
            this.taskInfo = { status: 'FAILED', percent: 0, step: '提交失败', period: this.period, elapsedMs: 0,
                              error: (res && res.message) || '后端未返回任务号' }
            this.loading = ''
            return
          }
          this.taskInfo = res.data
          if (this.taskInfo.status === 'DONE') { this.loading = ''; this.downloadTaskFile(); return }
          if (this.taskInfo.status === 'FAILED') { this.loading = ''; return }
          this.pollTask()
        })
        .catch(err => {
          this.stopTaskPolling()
          this.loading = ''
          this.taskVisible = false
          this.failWith(err, '生成')
        })
    },
    pollTask() {
      this.stopTaskPolling()
      const id = this.taskInfo && this.taskInfo.taskId
      if (!id) return
      this.taskTimer = setInterval(() => {
        api.get('/report/exportTask/' + id, { timeout: 30000 })
          .then(res => {
            if (!res || res.code !== 200 || !res.data) {
              // ä»»åŠ¡è¢«æ¸…ç†/过期:停止轮询并给出原因,不让弹窗一直空转
              this.stopTaskPolling()
              this.loading = ''
              this.taskInfo = Object.assign({}, this.taskInfo, {
                status: 'FAILED', step: '任务已失效',
                error: (res && res.message) || '导出任务已失效,请重新生成'
              })
              return
            }
            this.taskInfo = res.data
            if (this.taskInfo.status === 'DONE') {
              this.stopTaskPolling()
              this.loading = ''
              this.downloadTaskFile()
            } else if (this.taskInfo.status === 'FAILED') {
              this.stopTaskPolling()
              this.loading = ''
              if (!this.taskVisible) this.$message.error('汇总大表生成失败:' + (this.taskInfo.error || '未知原因'))
            }
          })
          .catch(() => { /* å•次轮询失败忽略,下一轮继续 */ })
      }, 700)
    },
    stopTaskPolling() {
      if (this.taskTimer) { clearInterval(this.taskTimer); this.taskTimer = null }
    },
    downloadTaskFile() {
      const id = this.taskInfo && this.taskInfo.taskId
      if (!id) return
      const name = this.taskInfo.fileName || ('生成_道路运输量汇总表_' + this.period + '.xlsx')
      api.get('/report/exportTask/' + id + '/file', { responseType: 'blob', timeout: 120000 })
        .then(res => { this.downloadBlob(res, name); this.$message.success('报表导出成功') })
        .catch(err => this.failWith(err))
    },
    restartTask() { this.startSummaryTask(true) },
    closeTask() { this.taskVisible = false; this.stopTaskPolling(); this.loading = '' },
    // ç”Ÿæˆä¸­æ”¶èµ·å¼¹çª—:任务继续在后台执行,完成后仍会自动下载(与原来同步导出的「点完即下载」一致)
    minimizeTask() {
      this.taskVisible = false
      this.$message.info('已转入后台生成,完成后会自动下载')
    },
    exportChecked(itemList) {
      const m = this.curModule
      if (!this.period) { this.$message.warning('请先选择周期和月份'); return }
@@ -477,6 +605,8 @@
      const params = this.buildParams()
      const doExport = () => {
        if (items.length === 1) {
          // æ±‡æ€»å¤§è¡¨ç”Ÿæˆä¸€æ¬¡çº¦ 10~20 ç§’,走异步任务(进度弹窗),避免点击后长时间无响应
          if (items[0].key === 'summaryWorkbook') { this.startSummaryTask(false); return }
          api.get('/report/export/' + items[0].key, { params, responseType: 'blob', timeout: 120000 })
            .then(res => { this.downloadBlob(res, this.fileNameOf(items[0])); this.$message.success('报表导出成功'); this.loading = '' })
            .catch(err => this.failWith(err))
@@ -621,6 +751,11 @@
.wb-yoy { text-align:right; color:#606266; }
.wb-yoy.yoy-empty { color:#C0C4CC; }
.wb-note { color:#E6A23C; font-size:12px; margin:2px 0 8px; }
/* å¼‚步生成进度弹窗 */
.task-body { padding: 4px 2px; }
.task-step { margin: 12px 0 4px; font-size: 14px; color:#303133; }
.task-meta { margin: 2px 0; font-size: 12px; color:#909399; line-height: 1.6; }
.task-error { margin: 8px 0 0; font-size: 13px; color:#F56C6C; word-break: break-all; }
</style>
<style>