zhizhijie
2026-10-06 bee9e18c3af284ff432a4472f1d409f5a6dc09a7
Merge remote-tracking branch 'origin/main'
63个文件已添加
29个文件已修改
9948 ■■■■■ 已修改文件
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
AGENTS.md 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md 377 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/README-部署说明.txt 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/application.yml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/install-autostart.ps1 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/install-service.cmd 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/install-service.ps1 330 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend-service.cmd 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend.cmd 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend.sh 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/trafficAudit.xml 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/init.sql 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday.sql 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_comparison_base_migration.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_decimal_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_previous_day_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_observation_station.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/jar-application.yml 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/Excel浮点数存储.txt 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/database.md 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/init.sql 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday.sql 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_comparison_base_migration.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_decimal_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_previous_day_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_vehicle_adjustment_migration.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_observation_station.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/备份/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/备份/湖北省国庆假期综合运输客流快报模板.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/打包部署说明.md 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pack-deploy.ps1 237 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-deploy.py 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/pom.xml 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java 554 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java 369 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java 1186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java 450 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java 535 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java 1083 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/resources/app-version.properties 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/resources/application.yml.example 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/AppHeader.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/main.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/router/index.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Dashboard.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ExplainReview.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 690 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Login.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/RuleManage.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/SystemConfig.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/UserManage.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -72,6 +72,9 @@
web/
traffic-audit-server/web/
# ==== Windows æœåŠ¡åŒ…è£…å™¨ï¼ˆWinSW äºŒè¿›åˆ¶ï¼Œ17.4MB;由 pack-deploy.ps1 ä¸‹è½½å¹¶æ ¡éªŒ SHA-256,不入库)====
deploy-templates/tools/
# ==== éƒ¨ç½²äº§ç‰©ï¼ˆä¸å…¥åº“)====
deploy/
traffic-audit-deploy-*.zip
AGENTS.md
@@ -33,6 +33,12 @@
## éªŒè¯æ³¨æ„
- æ²™ç®±å†… `Get-NetTCPConnection` ä¼šå› æƒé™â€œæ‹’绝访问”而静默失败,**不要**据此判定端口未监听;判断服务状态以 backend.log / frontend.log ä¸ºå‡†ã€‚
- ç”¨æˆ·è¦æ±‚:临时脚本/文件未经同意不删;导入失败要在页面显示行号;AI åˆ†æžç»“果要给结构化排版 + é€å­—输出动画。
- **数据导入权限(2026-10-04 ç”¨æˆ·æ˜Žç¡®ï¼‰**:除非用户明确要求助手导入,否则不要执行任何数据导入、覆盖或重导操作;用户自行导入的数据默认视为已完成,助手只做只读核对。
## é‡è¦èŠ‚å‡æ—¥å¿«æŠ¥ä¸Žåˆ†æžæŠ¥å‘Šå£å¾„ï¼ˆ2026-10-04 èµ·ï¼‰
- å¿«æŠ¥ç™¾åˆ†æ¯”必须先按导入的 `2026年十一数据.xlsx` å¯¹åº”单元格的数字格式生成显示值,再按快报统一 1 ä½å°æ•°å–整。当前源表不是全局同一精度:多数单元格是 `0.0%`;`人员流动量汇总` çš„当日环比 D/P/J、累计同比 V/W/Y/Z/AB/AC,道路 `H`(同比2024年),以及水路当日环比 D æ˜¯ `0.00%`。
- `人员流动量汇总` çš„累计值 U/X/AA å­˜çš„æ˜¯äº¿å•位,单元格格式为 `0.0000000_`;快报必须在换算万人次前先按亿单位 7 ä½å°æ•°ç”Ÿæˆæ˜¾ç¤ºå€¼ï¼Œå†ä¹˜ 10000 å¹¶æŒ‰å¿«æŠ¥ 2 ä½è¾“出。对应总计、公路合计、非营业性小客车三项。
- æ ¸å¯¹é¢†å¯¼åˆ†æžæŠ¥å‘Šæ—¶ï¼ŒæŒ‰è¯¥åˆ†æžæŠ¥å‘Šè‡ªèº«æ˜¾ç¤ºçš„小数位核对,不套用快报的 1 ä½å°æ•°å£å¾„;目标是复现人工在文档/Excel ä¸Šçœ‹åˆ°çš„值。
## å·¥ä½œè§„范(2026-09-02 èµ·ï¼›2026-09-27 æŒ‰åŒäº‹æäº¤æ–¹å¼è°ƒæ•´ï¼‰
HANDOFF.md
@@ -4,6 +4,378 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
## æœ€æ–°ï¼ˆ2026-10-06)
### 10-06:折线图纵向间距加高(验收通过)
- **用户背景**:客运量图线间距较好、标签摆放合理;其他多序列折线图因线距偏近,标签仍可能压线。用户同意适当增加图高,Word åªæ˜¯å–图来源,不要求当前分页为最终版,但图不能过高。
- **实现**:
  - `HolidayTrendChartService`:折线纵轴留白收紧为 `lowerMargin=0.04 / upperMargin=0.07`;`LINE_CHART_HEIGHT_SCALE=1.20`,所有折线 PNG é«˜åº¦æ”¾å¤§çº¦ 20%。
  - `HolidayTrendService`:12 å¼ æŠ˜çº¿å›¾çš„ Word å†…嵌图片高度同步放大约 20%,保持 PNG ä¸Ž Word å›¾ç‰‡æ¡†æ¯”例一致;饼图、堆叠图未改。
  - `HolidayTrendChartServiceTest`:新增折线图高度缩放断言。
- **验收**:用户于 2026-10-06 æŸ¥çœ‹ `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(折线加高20实验).docx` åŽéªŒæ”¶é€šè¿‡ã€‚
- **验证结果**:
  1. 15% åŠ é«˜ç‰ˆåœ¨é“è·¯å®¢è¿é‡å›¾ `image5` æ£€å‡º OCR æ ‡ç­¾æ¡†é‡å ï¼›20% ç‰ˆ 12 å¼ æŠ˜çº¿å›¾ï¼ˆ`image1/2/4/5/6/7/8/9/10/11/14/15`)未检出标签框重叠。
  2. PNG å®½é«˜æ¯”与 Word `wp:extent` å®½é«˜æ¯”最大误差约 `0.162%`,未发生明显拉伸。
  3. Word ä»ä¸º 8 é¡µã€æ¯é¡µ 2 å¼ å›¾ï¼ˆé¦–页 1 å¼ ï¼‰ï¼Œä¸Ž 15% åŠ é«˜å®žéªŒç›¸åŒã€‚
  4. åŽç«¯å…¨é‡ `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ï¼›`HolidayTrendChartServiceTest` èšç„¦æµ‹è¯•通过。
  5. æ ‡ç­¾äº²å’Œåº¦æ£€æŸ¥è®°å½•到 3 å¤„标签框更靠近邻近线(`image1` çš„ `1,518.02`、`image4` çš„ `1,346.17`、`image9` çš„ `248.65`);白描边保证可读,用户整体验收通过并保留该排版策略。
  6. `image13` å †å å›¾æœ‰ 4 å¤„ OCR æ ‡ç­¾é‡å ï¼Œä½†è¯¥å›¾æœªå‚与本实验且代码未改。
- **数据边界**:未导入、覆盖或重导任何业务数据;仅本地只读生成走势图做实验。
### 10-06:平均拥挤度标签恢复为 2 ä½å°æ•°
- **调整**:按用户要求,“普通国省道观测站平均拥挤度”不再单独使用 3 ä½å°æ•°ï¼Œæ¢å¤ä¸ºæŠ˜çº¿å›¾é»˜è®¤ 2 ä½ã€‚
- **一致性**:由于折线图现已统一按显示标签精度绘制,10-04、10-05 çš„æ‹¥æŒ¤åº¦éƒ½ä¼šæ˜¾ç¤ºå¹¶ç»˜åˆ¶ä¸º `0.49`,不会再出现同标签不同高度。
- **验证**:后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚
### 10-06:折线图统一按显示标签精度绘制
- **口径(用户 2026-10-06 ç¡®è®¤ï¼‰**:同一张折线图中,同一个显示标签必须对应同一个点位高度;分析文字不得引用比该图显示精度更细的差异。
- **实现**:`HolidayTrendChartService.lineChart` åœ¨æ‰€æœ‰åŽŸå§‹ç»Ÿè®¡è®¡ç®—å®ŒæˆåŽï¼ŒäºŽæœ€ç»ˆå…¥å›¾è¾¹ç•Œè°ƒç”¨ `displayValue`;普通图按该图 `valueDecimals` åš `HALF_UP` èˆå…¥ï¼Œç™¾åˆ†æ¯”点标签按 1 ä½ç™¾åˆ†æ¯”精度(即原始小数 3 ä½ï¼‰èˆå…¥ã€‚折线坐标和点标签共用同一舍入值,纵轴格式沿用该图精度。
- **边界**:只在最终图表显示层舍入,不改数据库、不改累计、快报、同比、环比或调整口径;该规则用于静态分析报告图,不能让文字引用图上无法分辨的微小差异。
- **验证**:`HolidayTrendChartServiceTest` èšç„¦æµ‹è¯•通过;后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚新增测试覆盖“两个不同原值按 2 ä½æ ‡ç­¾èˆå…¥åŽç”Ÿæˆç›¸åŒå‡ ä½•”和百分比精度换算。测试报告:`docs/功能测试报告/功能测试报告_折线图按标签精度绘制_2026-10-06.md`(本地留存,不入库)。
### 10-06:观测站平均拥挤度走势图改为 3 ä½å°æ•°
- **问题**:用户导入 10-05 è§‚测站数据后反馈,最后一张拥挤度图中 10-04、10-05 æ ‡ç­¾éƒ½æ˜¾ç¤º `0.49`,但点位高度不同。
- **根因**:平均拥挤度原值分别为 `0.490890014472` å’Œ `0.492414492754`,折线按未取整原值绘制,标签原为最多 2 ä½å°æ•°ï¼Œå¯¼è‡´æ ‡ç­¾ç²¾åº¦ä¸Žå›¾å½¢ç²¾åº¦ä¸ä¸€è‡´ï¼Œä¸æ˜¯æ—¥æ±‡æ€»æˆ–点位计算错误。
- **修正**:`HolidayTrendChartService.lineChart` æ–°å¢žå¯é€‰ `valueDecimals`,默认仍为 2;仅“普通国省道观测站平均拥挤度”图传 3 ä½ã€‚10-04、10-05 çŽ°åœ¨åˆ†åˆ«æ˜¾ç¤º `0.491`、`0.492`,其余图口径不变。
- **验证**:后端全量 `mvn -q test -f traffic-audit-server\pom.xml` é€šè¿‡ï¼›é‡å¯åŽåªè¯»ç”Ÿæˆæ–° DOCX,OCR æ ‡ç­¾ä¸º `0.504/0.476/0.467/0.491/0.492`;与用户提供的文档逐图比对,15 å¼ å›¾ä¸­ä»…拥挤度图 `image15` å˜åŒ–,其他 14 å¼  SHA-256 å®Œå…¨ä¸€è‡´ã€‚测试报告:`docs/功能测试报告/功能测试报告_拥挤度走势图精度_2026-10-06.md`(本地留存,不入库)。
- **数据边界**:未导入、覆盖或重导任何业务数据。
### 10-06:往年国省道调整列表补充调整前值
- **改动**:调整列表接口在同一年份、同一日期、普通国省道口径下,从 `holiday_vehicle_flow` è¡¥è¿”回 `originalValue`;没有原始记录时保持空,页面不显示占位符。表格新增“调整前国省道车流量(万辆次)”,原“调整后国省道车流”改为“调整后国省道车流量”。
- **弹窗**:新增/编辑弹窗同步增加只读“调整前国省道车流量(万辆次)”;编辑已有记录时展示 `originalValue`,无原值或新选择日期时不显示内容。切换年份或日期会清空旧原值,避免错日展示。
- **随日期刷新**:新增接口 `GET /api/holiday/adjustments/source-value`,按所选年份和日期实时读取普通国省道原值;新增/编辑弹窗选日期后自动刷新,未查到源数据时显示为空。前端用请求序号丢弃迟到的旧请求,避免快速换日期时串值。
- **边界**:`originalValue` åªæ˜¯åˆ—表展示字段,标记为 `@TableField(exist = false)`,不写库、不参与调整、环比分母、累计或走势图计算。
- **验证**:`mvn -o test` 11 ä¸ªæµ‹è¯•全部通过;`npm run build` é€šè¿‡ï¼›æœ¬åœ°é‡å¯åŽåªè¯»æ ¸å¯¹ 2025-10-03/04/05 åŽŸå€¼åˆ†åˆ«ä¸º `362.0035/337.9501/334.2040`,无源数据的 2025-10-09 è¿”回空。测试报告:`docs/功能测试报告/功能测试报告_往年国省道调整原值展示_2026-10-06.md`(本地留存,不入库)。
### 10-06:方案 D å·²å®žçŽ°ï¼ˆå¾€å¹´å›½çœé“è°ƒæ•´è¡¨ + çŽ¯æ¯”åŸºæœŸåªä½œç”¨äºŽå‰ä¸€æ—¥åˆ†æ¯ï¼‰
- **最终口径**:`HolidayStatService.loadVehicleByDate` å…ˆç”¨ `holiday_vehicle_adjustment` è¦†ç›–对应历史年份的“当日值”,供走势图、同比、累计和快报使用;随后构造“前一日环比分母”,若前一日普通国省道 C åˆ—有值,则用 C æ›¿æ¢è¯¥æ—¥å‰ä¸€æ—¥å›½çœé“值,并据此重算非营业性人员流动量、公路人员流动量和总计。所有环比行只继承这份分母。
- **关键边界**:C åªå½±å“â€œä¸‹ä¸€æ—¥çš„环比分母”,不影响 C æ‰€åœ¨æ—¥çš„当日值、累计或走势图;调整表只维护历史年份(`year < å½“前年份`),2026 å¹´è°ƒæ•´ä»ç”±å¯¼å…¥ Excel çš„ D åˆ—进入当日值。
- **本轮实现**:新增 `holiday_vehicle_adjustment` è¡¨åŠ mapper/service/controller;前端在专项统计-节假日页的“节假日数据”和“观测站分日调查数据”之间新增“往年国省道数据调整”卡片(后续按用户提议保持该位置),支持年份筛选、新增、编辑、删除和原因留痕;部署脚本与 migration æ¸…单已同步。
- **10-04 å¯¼å…¥é¢„检(只读,不写库)**:用 `C:\Users\jcxiong\Downloads\2026年十一数据 (2).xlsx` ä»¥ `allowHistoricalOverwrite=false` è°ƒå¯¼å…¥æŽ¥å£ï¼Œè¿”回确认项:
  - `第4日 2026-10-04 æ™®é€šå›½çœé“:原值 323.6551,导入值 322.7695`
  - `第3日 2026-10-03 æ™®é€šå›½çœé“环比基期:原值 ç©ºï¼Œå¯¼å…¥å€¼ 320.2418`
  å› æ­¤ C5 ç”±ç©ºå˜æœ‰å€¼ä¸ä¼šç¡¬æ‹’绝导入,只会要求历史变更确认;用户确认后即可随调整后的 Excel é‡å¯¼å†™å…¥ã€‚
- **当前库状态**:本地 `traffic_audit.holiday_vehicle_adjustment` å·²æ‰§è¡Œ DDL(0 è¡Œï¼‰ï¼›2026-10-04 ä¸šåŠ¡å€¼ä»ä¸ºæ—§å€¼ `323.6551`,等待用户自行重导调整后的 Excel。助手未执行任何业务数据导入、覆盖或重导。
- **验证**:后端 `mvn -o test` 9 ä¸ªæµ‹è¯•全绿(含 C5 åªæ”¹ 10-04 çŽ¯æ¯”åˆ†æ¯ã€åŽ†å²è°ƒæ•´è¿›å…¥ 2025 å½“日值)、前端 `npm run build` é€šè¿‡ï¼›é¡µé¢å®žæµ‹â€œå¾€å¹´å›½çœé“数据调整”卡片与新增弹窗正常。
- **取整口径(用户 2026-10-06 ç¡®è®¤ï¼‰**:不做“从原始值一次取整”的备选。唯一要求是与 Excel æ˜¾ç¤ºå€¼ä¸€è‡´ï¼šå…ˆæŒ‰æº Excel å•元格格式生成显示值,再按快报统一 1 ä½å°æ•°ã€‚10-04 æ€»è®¡çŽ¯æ¯”åŽŸå§‹å€¼çº¦ `4.5481%`,源格显示 `4.55%`,快报取 1 ä½å¾—到 `4.6%`;这属于既定口径,不是需要修正的边界问题。
> è¯´æ˜Žï¼šæœ¬æ–‡ä»¶æ—©å‰â€œçŽ¯æ¯”åŸºæœŸåªä½œå¤‡ç”¨ã€ä¸å‚ä¸Žè®¡ç®—â€çš„æ®µè½å·²ä½œåºŸã€‚è¯¥ç‰ˆæœ¬é’ˆå¯¹çš„æ˜¯æ—§å®žçŽ°è¯¯æŠŠ C æ”¾åˆ° C æ‰€åœ¨æ—¥æœ¬èº«è®¡ç®—的问题;自方案 D èµ·ï¼ŒC çš„æ­£ç¡®è¯­ä¹‰æ˜¯**前一日环比分母的调整值**。
### 10-06(已作废,见上方方案 D):普通国省道「环比基期」改为人工备用值(不再参与计算)
- **背景**:10-04 é„‚州樊家庄站中小客流量断崖下跌(35419 -> 8856),领导要求剔除。手工稿快报的做法是:先用剔站后的数值算出 10-04 çŽ¯æ¯”ï¼ŒæŠŠ 10-03 å›½çœé“值挪到「环比基期」C åˆ—备用,再把 10-03 æ¢å¤åŽŸå€¼ä»¥å…å½±å“ç´¯è®¡ã€‚ç”¨æˆ·æ˜Žç¡®å£å¾„ï¼š**「环比基期」只用于数据备用,不参与任何计算**。
- **旧行为(错)**:`HolidayStatService.nationalProvincialRingRatio` åœ¨ 2026 å¹´åªè¦ `comparison_base` æœ‰å€¼å°±æŒ‰ã€Œå½“æ—¥ / çŽ¯æ¯”åŸºæœŸ - 1」算国省道环比;导出时 E åˆ—写 `=(D{行}-C{行})/C{行}`。按手工稿的 C åˆ—摆法(基期放在被比日那行),这样算还会错位(10-03 çŽ¯æ¯”è¢«ç®—æˆ +1.11%)。
- **新行为(本次已改)**:国省道环比固定「当日 / å‰ä¸€æ—¥ - 1」,2024/2025/2026 ä¸€è‡´ï¼›`comparison_base` åªåœ¨å¯¼å…¥/导出/页面展示时原样保存与回写,不参与环比、折算、累计。导出 E åˆ—改为 `=(D{行}-D{上一日行})/D{上一日行}`,C åˆ—照常回写备用值。
- **改动**:`HolidayStatService.java`(`nationalProvincialRingRatio` åŽ»æŽ‰ year/comparisonBase å½¢å‚)、`HolidayWorkbookService.java`(新增 `ringRatioFormula`,导出按上一行同年 D åˆ—)、`docs/database.md`(`comparison_base` ä¸Žè®¡ç®—口径两处)。
- **验证**:新增 `HolidayComparisonBaseTest`:① åº“内存 `comparison_base=320.2418` æ—¶ï¼Œ10-04 å›½çœé“环比仍按前一日 323.7837 è®¡ç®—,且不等于按基期算出的值;② å¯¼å‡ºå…¬å¼ = `=(D6-D5)/D5`。`mvn -o test` å…¨ç»¿ï¼ˆ4 ä¸ªæµ‹è¯•类)。
### 10-06:待确认——10-04 å›½çœé“剔站(鄂州樊家庄)怎么进系统
- **手工口径已逐项核对,与库内 `observation_station_flow` å®Œå…¨å¯¹å¾—上**:
  - 10-04 å›½çœé“值(2026/2025/2024)= åŽŸå€¼ âˆ’ æ¨Šå®¶åº„当日值 -> 322.7695 / 337.5009 / 322.5277 ä¸‡è¾†æ¬¡ï¼ˆæ¨Šå®¶åº„ 8856 / 4492 / 2328)。
  - 10-04 çŽ¯æ¯”åˆ†æ¯ = 2026-10-03 åŽŸå€¼ âˆ’ æ¨Šå®¶åº„ 10-03 å€¼ = 323.7837 âˆ’ 3.5419 = **320.2418** ä¸‡è¾†æ¬¡ -> çŽ¯æ¯” = 322.7695/320.2418 âˆ’ 1 = **+0.79%**。
  - 10-03 æ˜¾ç¤ºå€¼ä¸Žç´¯è®¡ä»ç”¨åŽŸå€¼ 323.7837,被剔的 320.2418 åªè½åˆ° C åˆ—备用。
- **趋势结论(需先跟领导对齐)**:只剔樊家庄,10-04 å›½çœé“环比只有 **+0.79%**;同日高速小客车 **+9.78%**、铁路 +9.94%、民航 +11.69%、道路 +4.99%、水路 +4.33%。**剔一站填不平约 9 ä¸ªç™¾åˆ†ç‚¹çš„趋势差**,与「让国省道和高速趋势一致」的预期并不相符,建议同时请鄂州核实原始记录。
- **待决策的代码方案**:因 `comparison_base` å·²å®šä¸ºçº¯å¤‡ç”¨ï¼Œç³»ç»Ÿæ— æ³•从「当日值」反推剔站基期,需要另设机制。推荐新增「异常站剔除」配置(站点编号 + ç”Ÿæ•ˆæ—¥æœŸ + å¹´ä»½ï¼‰ï¼Œç®—环比分母时用 `observation_station_flow` çš„「该日站点合计 âˆ’ å‰”站值」作基期;显示值与累计仍用原值。
- **决策点**:① åšæˆå¯é…ç½®çš„通用「异常站剔除」(推荐,可复用于其他节假日/站点),还是只按本次 10-04 ä¸€æ¬¡æ€§å¤„理;② å‰”站只影响国省道环比分母,还是也要下沉到非营业性小客车/公路/全社会的环比(手工稿实际把 4.17% / 4.55% ä¹Ÿä¸€èµ·æ”¹äº†ï¼‰ã€‚
## æœ€æ–°ï¼ˆ2026-10-05)
### 10-05 ä¸Šåˆï¼šå…³æœºå¿«ç…§ï¼ˆå½“前状态)
- **已完成**:v1.0.0 æ­£å¼å‘布包 `deploy/traffic-audit-deploy-20261005-v1.0.0/`(zip **77.78 MB**)已生成,去密自检、版本自检、结构核对与真实启动冒烟均通过,待上传服务器。版本管理代码与文档均已提交推送(`main` = `93b36c8`)。
- **运行状态**:关机前本机 dev æœåŠ¡ä»åœ¨è¿è¡Œï¼ˆåŽç«¯ 8090 java PID 64112、前端 8080 node PID 46472),随关机停止,无需处理;打包冒烟用的 8095 å®žä¾‹å·²åœæ­¢ã€ç«¯å£å·²é‡Šæ”¾ã€‚
- **工作区**:除若干本地临时文件(`_tmp_*`)与一份专项统计 xls å¤–,git å·¥ä½œåŒºä¸Žè¿œç«¯ `main` ä¸€è‡´ï¼›`docs/工作日结/`、`docs/功能测试报告/` ç­‰æŒ‰çº¦å®šä¸å…¥åº“。
- **下次接手**:先读根目录 `AGENTS.md` + æœ¬æ–‡ä»¶ï¼›å¾…办见第七节(数值精度统一改造、态势感知图、AI åŠ©æ‰‹ RAG、经济指标试算等),首要事项是跟进正式包在服务器的部署结果。
### 10-05 ä¸Šåˆï¼šæ­£å¼å‘布包按新版本管理重新生成(v1.0.0,单端口瘦包)
- **背景**:`8b71049` å·²æŠŠæ‰“包脚本改为固定 jar å + ç‰ˆæœ¬è‡ªæ£€ï¼ˆ`traffic-audit-server.jar`、包名带 `v{version}`),但正式发布包还没按新脚本重新生成;版本改造前产出的 `deploy/traffic-audit-deploy-20261005-2/` ä»æ˜¯æ—§ jar å `traffic-audit-server-1.0.0-SNAPSHOT.jar`。
- **重新生成**:`powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper`(后端 clean package é‡å»ºã€å‰ç«¯ `npm run build` é‡å»ºï¼‰ã€‚
- **产物**:`deploy/traffic-audit-deploy-20261005-v1.0.0/`(351 æ–‡ä»¶ / 94.58 MB),zip **77.78 MB**。
- **校验**:去密自检 + ç‰ˆæœ¬è‡ªæ£€é€šè¿‡ï¼ˆpom / `app-version.properties` / `MANIFEST.MF` ä¸‰è€…均 `1.0.0`);包内 `backend\traffic-audit-server.jar` ä¸ºå›ºå®šåï¼›æ—  `frontend/`、无 `trafficAudit.exe/.xml/install-service*`;`backend\web` 44 æ–‡ä»¶ä¸Ž `frontend\dist` é€æ–‡ä»¶ SHA-256 ä¸€è‡´ï¼›`README-部署说明.txt` å¤´éƒ¨ä¸ºã€Œ20261005,版本 v1.0.0」并含旧服务器一次性升级说明。
- **启动冒烟(真实包、只读)**:用本地开发库配置在 8095 ç«¯å£å¯åŠ¨åŒ…å†… jar,`GET /api/version` è¿”回 `{"version":"1.0.0",...}`,`GET /` è¿”回登录页 200;验证后已按 PID 58652 ç²¾ç¡®åœæ­¢å†’烟进程。
- **状态**:待用户上传服务器。`deploy/traffic-audit-deploy-20261005-2/`(及更早的 `20261005/`)作废勿用。旧服务器首次升级按 README:先改 `trafficAudit.xml` çš„ `<arguments>` ä¸º `-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"` å†æ¢ jar,以后只替换 jar。
### 10-05:版本管理采用方案 A(固定 jar åï¼Œç”¨æˆ·éªŒæ”¶é€šè¿‡ï¼‰
- **用户决策**:采用方案 A。`traffic-audit-server/pom.xml` ä¸ºå”¯ä¸€ç‰ˆæœ¬æºï¼Œå½“前对外版本 `1.0.0`。
- **构建改造**:`<finalName>traffic-audit-server</finalName>`,产物固定为 `target/traffic-audit-server.jar`;Maven èµ„源过滤生成 `app-version.properties`(Spring Boot çˆ¶ POM ä½¿ç”¨ `@project.version@` åˆ†éš”符),`maven-jar-plugin` å†™å…¥ `Implementation-Version`。
- **页面显示**:新增免登录 `GET /api/version`;登录页和页头显示 `V1.0.0`。同时修复可执行 jar ä¸‹ `buildTime` ä¸º `null` çš„嵌套 URI è§£æžé—®é¢˜ã€‚
- **发布链路**:服务 XML、启动脚本、打包脚本全部改用固定 jar åï¼›`pack-deploy.ps1` ä¼šæ ¡éªŒ pom / `app-version.properties` / `MANIFEST.MF` ä¸‰è€…版本一致,发布包名为 `traffic-audit-deploy-YYYYMMDD-v1.0.0(.zip)`,并输出带 `{version}` çš„部署 README。
- **旧服务器首次升级(仅一次)**:停止服务后,把 `trafficAudit.xml` çš„ `<arguments>` æ”¹ä¸º `-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"`,替换 jar åŽå¯åŠ¨ã€‚ä»¥åŽå‘ç‰ˆåªæ›¿æ¢ jar å¹¶é‡å¯ï¼Œä¸å†æ”¹ XML。
- **验证**:`mvn -o test`、`mvn -o clean package -DskipTests`、固定名 jar å®žé™…启动并访问 `/api/version`、前端 `npm run build`、本地浏览器登录页显示 `V1.0.0`、`pack-deploy.ps1 -SkipFrontend -NoFrontendDist -SkipServiceWrapper` ç»„装/去密/版本自检全部通过;负向验证确认版本不匹配和 `SNAPSHOT` éƒ½ä¼šåœ¨æ‰“包前中止。测试报告:本地 `docs/功能测试报告/功能测试报告_版本管理方案A_2026-10-05.md`(目录不入库)。
- **状态**:版本管理代码提交为 `8b71049`,`main` å·²åŒæ­¥ï¼›`v1.0.0` å·²æŽ¨é€åˆ°å…¬å¸ Gitblit并指向 `21a5bf7`。2026-10-05 ç”¨æˆ·ç¡®è®¤æœ¬åœ°ç™»å½•页及所有登录后页面均正确显示 `V1.0.0`。正式发布包已于 10-05 ä¸Šåˆç”¨æ–°ç‰ˆæ‰“包脚本重新生成(见上方 10-05 ä¸Šåˆä¸€èŠ‚ï¼‰ã€‚
### 10-04 æ·±å¤œï¼šæŠ˜çº¿æ•°å€¼æ ‡ç­¾å›žåˆ°è´´è¿‘本点(v35,待用户验收)
- **用户反馈**:v31「避开折线」版本不如上一版 v27 â€”— è™½ç„¶æ ‡ç­¾ä¸åŽ‹çº¿äº†ï¼Œä½†å¾ˆå¤šæ ‡ç­¾ç¦»è‡ªå·±çš„æ•°æ®ç‚¹å¤ªè¿œï¼Œä¸€çœ¼çœ‹ä¸å‡ºæ ‡çš„æ˜¯å“ªä¸ªç‚¹ã€‚è¦æ±‚å›žåˆ°è´´è¿‘æœ¬ç‚¹çš„æ•ˆæžœã€‚
- **新问题与旧需求的冲突**:更早的原始诉求是「数字压线看不清」;这次诉求是「数字必须贴近本点」。两者并存,必须同时满足。
- **实现(`HolidayTrendChartService`)**:
  - æ ‡ç­¾æŽ’版**完整回退到 v27 ç®—法**:按同日点位距离做代价(`ownDistance Ã—10`),候选含近/远的上、下、左、右及四角,动态规划保证同日标签与点位上下顺序一致(`lineLabelCost`、`lineLabelCandidates`)。v31 çš„线条掩膜、Chamfer è·ç¦»åœºã€é¢œè‰²äº²å’Œé¡¹å…¨éƒ¨ç§»é™¤ã€‚
  - **新增粗白描边(halo)** è§£å†³ã€ŒåŽ‹çº¿çœ‹ä¸æ¸…ã€ï¼š`drawLabelWithHalo` ç”¨ `TextLayout` å–字形轮廓,先以白色 `HALO_OUTLINE_WIDTH=4.0f` åœ†è§’描边,再用本色填充。数字即使落在折线上也清晰可读,从而允许标签贴回本点。折线图、堆叠图外置标签、饼图标签统一走该方法。
  - é¡ºå¸¦æ¸…理调试遗留:删除 `LABEL_DEBUG`/`CHART_SEQ`/`TRENDDBG` æ‰“印,以及不再使用的 `lineNormal(...)` ä¸Ž `DayLabel.normalX/normalY`。
- **量化验证(RapidOCR æ ‡ç­¾åŒ…围框中心 â†” çœŸå®žæ•°æ®ç‚¹åæ ‡ï¼‰**:标签到本点平均距离 v27=`36.8px`、v31=`53.2px`、v35=`36.7px`(越小越贴近本点)。v35 ä¸Žç”¨æˆ·è®¤å¯çš„ v27 åŸºæœ¬ä¸€è‡´ï¼Œæ˜Žæ˜¾ä¼˜äºŽè¢«å¦çš„ v31。
- **其余验证**:`mvn compile`/`mvn test` é€šè¿‡ï¼ˆæ— ç¼–译告警遗留);Word COM 8 é¡µ 15 å›¾ï¼ˆ1+2×7);RapidOCR å¤æ£€ 12 å¼ æŠ˜çº¿å›¾æ•°å€¼æ ‡ç­¾åŒ…围框重叠数 = 0;`label_affinity` å¤æ£€å›¾ 11 æ¯ä¸ªæ•°æ®æ ‡ç­¾åˆ°æœ¬çº¿ 3~8px ä¸”本线是最近线(无「CLOSER TO å…¶ä»–色」);放大 6נ复核图 11 å·¦ä¸Šæ ‡ç­¾ç¡®ä¸º `630.99`(低分辨率 OCR æ›¾è¯¯è¯»ä¸º `1630.99`)。v27↔v35 åƒç´ å·®çº¦ 1%,集中在标签处,即白描边生效且未破坏折线。
- **产物与状态**:`_tmp_holiday_verify/holiday_trend_2026-10-04_v35.docx`;下载副本 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-数字贴近折线-白边清晰).docx`。未导入、覆盖或重导任何数据。待用户验收;验收前不提交、不推送;现有 `deploy/traffic-audit-deploy-20261004.zip` ä»æ˜¯ v24 è¡Œä¸ºï¼ŒéªŒæ”¶åŽéœ€é‡å»ºã€‚
### 10-05:走势图 v35 éªŒæ”¶é€šè¿‡ + å•端口发布包(-NoFrontendDist -SkipServiceWrapper)
- **验收**:v35(折线数值标签回到贴近本点 + ç™½æè¾¹ï¼‰2026-10-05 ç”¨æˆ·éªŒæ”¶é€šè¿‡ã€‚
- **打包需求(用户澄清)**:服务器单端口,只需要 `backend\web`,不要独立的 `frontend/dist`;为保证包完整,`backend\web` + jar + docs éƒ½è¦ã€‚服务器非首次发布、服务已装,可加 `-SkipServiceWrapper` åŽ»æŽ‰æœåŠ¡æ³¨å†Œå™¨ã€‚
- **打包工具修正**:先前加的 `-NoFrontend` è¯­ä¹‰é”™äº†ï¼ˆæŠŠ `backend\web` ä¹ŸåŽ»æŽ‰äº†ï¼‰ï¼ŒçŽ°æ”¹ä¸º `-NoFrontendDist`。它只跳过独立 `frontend\` ç›®å½•(不复制 dist / nginx ç¤ºä¾‹ï¼‰ï¼Œ**仍构建(或要求)dist å¹¶åŒæ­¥ `backend\web`**(单端口必需,否则页面 404)。`-SkipFrontend` ä»ä¸ºâ€œä¸é‡å»ºã€å¤ç”¨çŽ°æœ‰ dist”。
- **发布包**:`deploy/traffic-audit-deploy-20261005-2/`(351 æ–‡ä»¶ / 94.55 MB;zip **77.78 MB**)。已核对:无 `frontend/`、无 `trafficAudit.exe/.xml/install-service*`(服务包装器已排除);`backend\web` 44 æ–‡ä»¶ä¸Ž `frontend\dist` é€æ–‡ä»¶å“ˆå¸Œä¸€è‡´ï¼›jar å†…置配置全占位符(去密自检通过);包内 `HolidayTrendChartService.class` ä¸Ž `target/classes` ä¸€è‡´ï¼ˆå« v35 ä»£ç ï¼‰ã€‚较上一完整包(110.49 MB / zip 83.77 MB)省约 16 MB è§£åŽ‹ / 6 MB åŽ‹ç¼©ã€‚
- **作废**:`deploy/traffic-audit-deploy-20261005/`(错误语义下的产物,缺 `backend\web`)已被 `-2` å–代,勿用。
- **部署注意**:覆盖 jar(及按需 `application.yml`)即可;`backend\web` å·²éšåŒ…提供。服务已装,无需重新注册。
- **状态**:已打包,待用户上传到服务器;该包为版本改造前产物,已由 10-05 ä¸Šåˆçš„ `traffic-audit-deploy-20261005-v1.0.0` å–代(勿用)。
### 10-04 æ·±å¤œï¼šæŠ˜çº¿æ•°å€¼æ ‡ç­¾é¿å¼€æŠ˜çº¿å¹¶è´´è¿‘本线(v31,待用户验收)
- **用户反馈(领导看后,两轮)**:① å„年份数字离本线有远有近,希望贴近各自色线且不错乱;② ä»æœ‰æ•°å­—压在折线上看不清(第一轮只是“贴近”,标签会落在线上)。用户建议:调纵轴刻度拉开折线、或加左右避让让标签不压线。
- **实现(`HolidayTrendChartService`)**:
  - æ ‡ç­¾æŽ’版改为「动态规划 + çº¿æ¡åƒç´ é¿è®©ã€ã€‚图表先画折线和标记,再把所有系列颜色像素(容差 45)做成掩膜,对每个候选标签位置统计其覆盖的线条像素数,`×700` é‡ç½šâ€”—数字不再压线。
  - ä¸ºæ¯æ¡ç³»åˆ—建立 Chamfer è·ç¦»åœºï¼ˆåˆ°è¯¥è‰²æœ€è¿‘像素的距离)。代价新增两项:`max(0, è‡ªå·±é‚£æ¡çº¿çš„距离-3)×40`(把标签拉回本线旁,防止飘远)与颜色亲和项 `max(0, è‡ªå·±çº¿è· - åˆ«çš„线距 + 4)×200`(要求本线是最近的线,避免贴到别的系列)。
  - æ—§çš„「离别的同日点位更近」惩罚由 `×5000` é™åˆ° `×400`,避免它压倒亲和项;`ITEM_LABEL_GAP` 12→10;候选含近/远的上、下、左、右及四角。
  - ä¿ç•™åŒæ—¥æ ‡ç­¾ä¸Žæ•°æ®ç‚¹ä¸€è‡´çš„上下顺序,避免标签与折线上下反转。
- **调参过程提示**:v27(只按点位距离排序)→ æ ‡ç­¾åŽ‹çº¿ï¼›v28(只加覆盖像素惩罚)→ æ ‡ç­¾ç¦»æœ¬çº¿ã€è´´åˆ°åˆ«çš„色线旁;v31(覆盖惩罚 + æœ¬çº¿è·ç¦» + é¢œè‰²äº²å’Œï¼‰â†’ ä¸¤è€…兼顾。验证标签是否“贴错线”不能只看 OCR åŒ…围框到异色的距离:相邻的异色**标签**会被误判为异色**线**,必须肉眼核对整图。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚真实库只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v31.docx`;Word COM 8 é¡µ 15 å›¾ï¼ˆ1+2×7)。RapidOCR å¤æ£€ 15 å¼ å›¾æ•°å€¼æ ‡ç­¾åŒ…围框重叠数为 0。肉眼核对图 1(跨区域人员流动量)、图 2(累计)、图 4(自驾)、图 6(水路)、图 9(高速)、图 11(车流总量):数值标签均离开折线、位于各自色线旁、颜色对应正确;图 11 çš„ 630.99/601.04/571.31 åˆ†åˆ«ç´§è´´è“/红/绿折线。
- **产物与状态**:`C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-数值标签避开折线).docx`。未导入、覆盖或重导任何数据。待用户验收,验收前不提交、不推送;现有 `deploy/traffic-audit-deploy-20261004.zip` ä»æ˜¯ v24 è¡Œä¸ºï¼Œéœ€éªŒæ”¶åŽé‡å»ºã€‚
### 10-04 æ™šï¼šèµ°åŠ¿å›¾ 3 é¡¹åæ ‡è½´è°ƒæ•´ï¼ˆv24,用户已验收)
- **用户修改项**:① åæ ‡è½´å•位(万人次、万辆次、公里/小时、拥挤度、日期)改成和刻度一样的颜色;② ä¸¤ä¸ªå †å å›¾çš„坐标轴改成与其他图一样的黑色;③ â€œä¸‡äººæ¬¡/占比”继续放在堆叠图右上角。
- **实现**:`HolidayTrendChartService` æŠŠ `drawUnitLabel`、`drawUnitLabelTopRight`、`drawDateLabel` çš„æ–‡å­—色由黑色改为刻度灰 `(102,102,102)`;横向堆叠图在数据区底边补画 `2.2px` çº¯é»‘基线,覆盖 JFreeChart é»˜è®¤åç°çš„ 1px è½´çº¿å’ŒæŠ—锯齿边缘,不添加箭头。折线图黑色坐标轴逻辑未改,堆叠图单位仍绘制在右上角。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚真实库只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v24.docx`;Word COM å®žæµ‹ 8 é¡µã€15 å¼ å›¾ã€‚像素检查:折线图左上单位、日期及堆叠图右上单位均为 `(102,102,102)`;图 12/13 åº•轴在 y=529 ä¸ºçº¯é»‘连续线;普通折线图底轴仍为纯黑。
- **产物与状态**:`C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-最新).docx` å½“时被 Word/WPS å ç”¨ï¼Œå·²å¦å­˜ä¸º `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-最新-坐标轴调整).docx`;原“最新”文件未覆盖。未导入、覆盖或重导任何数据。2026-10-04 ç”¨æˆ·å·²éªŒæ”¶é€šè¿‡ï¼Œéšæœ¬æ¬¡æäº¤æŽ¨é€ã€‚
### 10-04 æ™šï¼šèµ°åŠ¿å›¾ 4 é¡¹ä¿®æ”¹ï¼ˆv23,待用户验收)
- **用户修改项**:① æŠ˜çº¿æ•°å€¼æ ‡ç­¾ä¸ŽæŠ˜çº¿ä¸Šä¸‹é¡ºåºåäº†ï¼ˆç´¯è®¡æµåŠ¨é‡å›¾ï¼šè“çº¿åœ¨ä¸Šã€æ ‡ç­¾å´åœ¨ä¸‹ï¼›ç¬¬ä¸€å¼ å›¾é¦–æ—¥æœ€é«˜æ˜¯è“ç‚¹ã€æœ€è¿‘çš„æ ‡ç­¾å´æ˜¯æ£•è‰²ï¼‰ï¼›â‘¡ å…¬è·¯å‡ºè¡Œç»“构图例把自驾放前面(棕色),与每日客流构成一致;③ æ¨ªåæ ‡åº”为万人次/占比(数值轴放到底部)、去掉纵轴线与箭头;④ æ¯æ—¥å®¢æµæž„成恢复 5 æ¡ï¼ˆè‡ªé©¾/道路/铁路/民航/水路),占比小的数字标在色块右边、用颜色区分。
- **实现**:
  - `HolidayTrendChartService.drawXYItemLabels` é‡å†™ï¼šå…ˆæŠŠåŒä¸€æ—¥æœŸå„系列按点位 y ä»Žä¸Šåˆ°ä¸‹æŽ’序,标签再按该顺序从上往下摆(下界为前一个标签底部 +3),**从而不会出现标签与折线上下反转**;避让权重改为「标签重叠 Ã— 1000 + æ•°æ®ç‚¹é‡å  Ã— 1」,并删除原来会把标签一路推到图表下方的非白像素惩罚项(`nonWhiteRatio`)。
  - æ¨ªå‘堆叠图:`plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT)` æŠŠæ•°å€¼è½´æ”¾åˆ°åº•部(万人次 / å æ¯” å°±æ˜¯æ¨ªåæ ‡ï¼‰ï¼›`domainAxis.setAxisLineVisible(false)` + `setTickMarksVisible(false)` åŽ»æŽ‰çºµè½´çº¿å’Œåˆ»åº¦ï¼›`render` å¯¹æ¨ªå‘堆叠图不再画坐标轴箭头;单位改到右上角(`drawUnitLabelTopRight`)。
  - å †å å›¾å—内放不下的小数字,改为在色块右侧用同色文字标注(同一行按色块位置排成一列,列 x å–该行最右色块端 +8,并限制在预留 74px å³è¾¹è·å†…);放得下仍用白字写在块内。
  - `HolidayTrendService.roadStructureSeries` é¡ºåºæ”¹ä¸º è‡ªé©¾å‡ºè¡Œé‡ï¼ˆæ£•)、道路客运量(蓝);`dailyShareSeries` æ¢å¤ è‡ªé©¾/道路/铁路/民航/æ°´è·¯ 5 æ¡ï¼Œåˆ é™¤ä¸Šä¸€è½®ä¸´æ—¶åˆå¹¶ç”¨çš„ `sumNullableLists`。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ï¼›v23 Word COM å®žæµ‹ 8 é¡µï¼ˆ1+2×7,15 å¼ å›¾ï¼‰ã€‚RapidOCR å¤æ£€ 12 å¼ æŠ˜çº¿å›¾æ— æ ‡ç­¾é‡å ï¼›é€æ—¥æ¯”对标签颜色顺序与折线点位顺序一致(累计图 10月4日 ç‚¹ä½ 2025 åœ¨ä¸Š/2024 åœ¨ä¸‹ï¼Œæ ‡ç­¾åŒæ · 2025 åœ¨ä¸Šã€2024 åœ¨ä¸‹ï¼›ä¿®æ”¹å‰ v20 æ°å¥½ç›¸åï¼‰ï¼›é¦–图 10月1日最高点(蓝色 2025)的数值标签就在该点位正上方。图 12 å›¾ä¾‹ä¸ºã€Œè‡ªé©¾å‡ºè¡Œé‡Â·é“路客运量」,数值轴在底部 0–12,000,单位万人次在右上角,无箭头、无纵轴线;452/228 ä»¥è“å­—标在蓝块右侧,648 ç™½å­—在块内。图 13 å›¾ä¾‹æ¢å¤ 5 é¡¹ï¼Œ87.8% ç­‰å¤§å æ¯”白字在块内,道路/民航/水路等小占比以同色数字标在右侧一列。
- **数据操作与状态**:未导入、覆盖或重导任何数据。最终文件已覆盖到 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-最新).docx`;待用户验收,验收前不提交、不推送。
### 10-04 å‚æ™šï¼šèµ°åŠ¿å›¾ 5 é¡¹å¾®è°ƒï¼ˆv20,待用户验收)
- **用户修改项**:① çºµè½´å•位仍与纵轴重叠,继续上移;② ç¬¬ 1 é¡µé™¤æ ‡é¢˜å¤–全空白,需放 1 å¼ å›¾åˆ°ç¬¬ 1 é¡µï¼›â‘¢ æ¨ªçºµåæ ‡åˆ»åº¦ä¸Žå›¾ä¾‹åé»‘,稍微调淡;④ é¥¼å›¾æ ‡ç­¾é¢œè‰²æ”¹ä¸ºä¸Žæ‰‡åŒºä¸€è‡´ï¼›â‘¤ ä¸¤ä¸ªå †å å›¾æ•ˆæžœä»ä¸å¥½ï¼Œå—做大、数字写进块里。
- **实现**:`HolidayTrendChartService` ä¸ºåæ ‡ç±»å›¾è¡¨é¢„ç•™ `UNIT_LABEL_BAND=58px` çš„顶部空白(`reserveUnitLabelBand` è°ƒ `plot.setInsets`),单位标签基线改为 `dataArea.getY()-22`,彻底脱离轴线与最高刻度;刻度/图例文字色由 (70,70,70) è°ƒæ·¡ä¸º (102,102,102),坐标轴线与箭头仍为黑色;饼图标签(含引线框内文字)通过 `TextFragment` åå°„改为对应扇区色;堆叠图维持横向,`categoryMargin 0.08->0.05`、`itemMargin 0.05` è®©è‰²å—变厚,块内放得下就用 17px ç™½å­—写在块内,放不下才用同色引线拉到块外;折线数据标签左侧最小留白 `dataArea.minX+16`,避免紧贴纵轴刻度。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚真实库只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v20.docx`(571 KB,15 å¼ å›¾ï¼‰ï¼›Word COM å®žæµ‹ 8 é¡µï¼Œç¬¬ 1 é¡µæ ‡é¢˜+1 å¼ å›¾ï¼Œç¬¬ 2-8 é¡µå„ 2 å¼ å›¾ï¼Œæ— ç©ºç™½é¡µã€‚RapidOCR å¤æ£€ 12 å¼ æŠ˜çº¿å›¾æ–‡å­—包围框重叠数为 0;单位“万人次/占比”与最高刻度、轴线已分离(图 1 å•位 y=17..44、顶部刻度 y=53..77);堆叠图 12 çš„ 452/8,595、648/10,410、228/3,962 ä¸Žå †å å›¾ 13 çš„ 87.8%/7.4% å‡åœ¨å—内,个别过窄的 4.8% ç”¨åŒè‰²å¼•线拉到块外;饼图标签颜色与扇区一致(铁路绿、民航紫、道路蓝、水路青)。
- **口径变化提醒**:图 13「每日客流构成变化」(附录一)由原先 5 æ¡ï¼ˆè‡ªé©¾/道路/铁路/民航/水路)合并为 3 æ¡ï¼ˆè‡ªé©¾/道路/其他方式=铁路+民航+水路),目的是让色块足够宽、数字能写进块内;正文 5 ç§è¿è¾“方式走势图未受影响。
- **数据操作与状态**:未导入、覆盖或重导任何数据。最终文件已覆盖到 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-最新).docx`;待用户验收,验收前不提交、不推送。
### 10-04 ä¸‹åˆï¼ˆ3):走势图 8 é¡¹ä¿®æ”¹ï¼ˆå¾…用户验收)
- **用户修改项**:纵轴单位上移避开箭头;删除附录二同比走势;同日期的三年折线点恢复垂直对齐;图表标题去掉“2026年”;“观测站”改为“普通国省道观测站”;堆叠图小占比改为柱外引线标注;文档改为每页 2 å›¾ï¼›å›¾ä¾‹â€œé“路营业性客运量”改为“道路客运量”、“非营业性自驾折算客流”改为“自驾出行量”。
- **实现**:`HolidayTrendChartService` æ¢å¤æ‰€æœ‰å¹´ä»½åŒä¸€ x åæ ‡ï¼Œçºµè½´å•位绘制在数据区上方,堆叠图小占比使用同色引线拉到柱外;折线数值标签改为对其他数值标签设置极高避让优先级,白描边保留。`HolidayTrendService` åˆ é™¤é™„录二同比 2 å›¾ï¼ŒåŽŸé™„å½•ä¸‰é¡ºå»¶ä¸ºé™„å½•äºŒï¼Œå¹¶æ›´æ–°æ ‡é¢˜å’Œå›¾ä¾‹æ–‡å­—ï¼Œå½“å‰å…± 15 å¼ å›¾ã€‚`HolidayTrendWordService` å°é¢åŽå¼ºåˆ¶åˆ†é¡µï¼Œå–消章节标题后的多余空段,再按每 2 å›¾è‡ªåŠ¨åˆ†é¡µã€‚
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ã€‚真实库只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v17.docx`;Word COM å®žæµ‹ 9 é¡µï¼Œå°é¢å•独第 1 é¡µï¼Œç¬¬ 2–8 é¡µå‡ä¸º 2 å¼ å›¾ï¼Œæœ€åŽ 1 å¼ å›¾å•独第 9 é¡µï¼Œæ— ç©ºç™½é¡µã€‚RapidOCR å¤æ£€ 12 å¼ æŠ˜çº¿å›¾çš„æ•°å€¼æ ‡ç­¾é‡å æ•°ä¸º 0;堆叠图图 12、13 çš„小占比数字均可见;图 3“水路 0.23%”仍在右侧;图例已识别为“道路客运量”“自驾出行量”,图表标题中不再出现“2026年”。
- **数据操作与状态**:未导入、覆盖或重导任何数据。最终验收文件已复制到 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版-最新).docx`(原“修改版”文件当时被 WPS å ç”¨ï¼Œæœªè¦†ç›–);待用户验收,验收前不提交、不推送。
### 10-04 ä¸‹åˆï¼ˆ2):走势图标签、折线间距与横坐标准确性修正(待用户验收)
- **用户反馈**:饼图“水路”引线难辨认需固定到右侧;堆叠图要显示数值;图例改黑色放大;横轴“日期”上移;纵轴单位放到箭头上方;坐标轴和刻度改黑色并放大;折线加粗、尽量拉开;修复横轴错位一天;折线数值标签要与折线同色,并检查标签重叠。
- **修复**:`HolidayTrendChartService` é¥¼å›¾å­ç±»æ”¹ä¸ºåœ¨ JFreeChart å·²åˆ†å¥½çš„左右标签集合中把“水路”从左集合移到右集合;折线宽度由 4px è°ƒè‡³ 6px,多序列按 Â±0.08 åšè½»å¾®æ¨ªå‘错开;关闭 JFreeChart é»˜è®¤æŠ˜çº¿æ ‡ç­¾ï¼Œæ”¹ä¸ºé€ç‚¹è‡ªå®šä¹‰æŽ’版:按上/下/远上/远下/å·¦/右候选位置避让已放标签和数据点,并对单位区域留空;标签使用对应折线颜色并加白色描边。横轴错位根因是原 `XYSeries.add(i + 1, ...)` ä¸Ž `SymbolAxis` å®žé™…整数位置 `0..7` ä¸ä¸€è‡´ï¼Œå·²æ”¹ä¸º `add(i, ...)`。
- **验证**:`mvn test -f traffic-audit-server\pom.xml` é€šè¿‡ï¼›çœŸå®žåº“只读生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v14.docx`。RapidOCR å¤æ£€ 14 å¼ æŠ˜çº¿å›¾ï¼Œæ•°å€¼æ ‡ç­¾åŒ…围框重叠数为 0,未再识别出两个数值粘连;三年折线每图均识别出 2026 å¹´ 3 ä¸ªã€2025 å¹´ 8 ä¸ªã€2024 å¹´ 7 ä¸ªï¼Œå…± 18 ä¸ªæ•°å€¼æ ‡ç­¾ï¼Œé¢œè‰²è®¡æ•°ä¸ºçº¢ 3、蓝 8、绿 7。饼图“水路 0.23%”位于右侧(OCR ä¸­å¿ƒ x≈801.5),其他小占比标签在左侧;堆叠图数值可见。10 æœˆ 1 æ—¥è½´åˆ»åº¦ x≈123.5,首日数据点已落在第一格。
- **数据操作与状态**:未导入、覆盖或重导任何数据。新版已复制到 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版).docx`;待用户验收,验收前不提交、不推送。
### 10-04 ä¸­åˆï¼šèµ°åŠ¿å›¾å¤–è§‚æŒ‰æ¨¡æ¿é‡åˆ¶ï¼ˆå¾…ç”¨æˆ·éªŒæ”¶ï¼‰
- **用户反馈的 7 é¡¹é—®é¢˜**:章节标题重复、图比模板大、字小线细、网格和浅灰背景、坐标轴无箭头且单位位置不符、纵轴范围不合适、饼图小占比标签应拉到圆外。
- **修复**:`HolidayTrendWordService` å…‹éš†ç« èŠ‚æ—¶ç§»é™¤æ¨¡æ¿è‡ªåŠ¨ç¼–å·ï¼›æ¯å¼ å›¾æŒ‰æ¨¡æ¿å¯¹åº”å°ºå¯¸è®¾ç½® `wp:extent` å’Œå†…部 `a:ext`,并移除模板旧 `srcRect`。`HolidayTrendChartService` æ”¹ä¸ºçº¯ç™½èƒŒæ™¯ã€æ— ç½‘æ ¼/无交替灰色带、黑色坐标轴带箭头、左上方水平单位、右下角日期;折线字号按 Word çº¸å¼ å®žé™…显示尺寸放大、加粗线宽和点径;纵轴不强制从 0 å¼€å§‹å¹¶æŒ‰æ•°æ®ä¸Šä¸‹ç•™ç™½ï¼›ç™¾åˆ†æ¯”堆积图固定 0%-100%;饼图关闭 `simpleLabels`,使用外部标签和引线。
- **验证**:`mvn test` å…¨éƒ¨é€šè¿‡ï¼Œæ–°å¢žå›¾è¡¨æµ…灰背景与 Word ç¼–号/尺寸回归断言;前端 `npm run build` é€šè¿‡ï¼ˆä»…原有 bundle ä½“积警告)。真实库生成最终稿 `C:\Users\jcxiong\Downloads\2026年十一假期全省综合运输客流量走势图(修改版).docx`,Word COM å®žæµ‹ 9 é¡µã€17 å¼ å›¾ï¼›ç¨‹åºæ£€æŸ¥å…¨éƒ¨ `gray243=0` ä¸” 6 ä¸ªç« èŠ‚æ®µ `numPr=false`,图片尺寸与模板 7 ç§ç›®æ ‡æ¡†ä¸€è‡´ã€‚最终版 PNG æŒ‰æ¨¡æ¿æ˜¾ç¤ºæ¡†å®½é«˜æ¯”输出(不再被 Word æ‹‰ä¼¸å˜å½¢ï¼‰ï¼Œåˆ»åº¦/图例 18px、数据标签 20px、单位 22px,线宽 4px。
- **数据操作**:全程只读,未导入、覆盖或重导任何数据。状态:待用户验收,验收后再提交/推送。
### 10-04 ä¸‹åˆï¼šæ–°å¢žèŠ‚å‡æ—¥ç»¼åˆè¿è¾“å®¢æµé‡èµ°åŠ¿å›¾åˆç‰ˆï¼ˆå¤–è§‚å·²æŒ‰åé¦ˆé‡åˆ¶ï¼‰
- **需求口径(用户 2026-10-04 ç¡®è®¤ï¼‰**:走势图分 12 å¼ æ­£æ–‡ + é™„录;领导原话为“各种运输方式客流量走势图,高速和国省道车流量走势图”。最终正文分为三部分:`一、总体情况`(当日总客流、累计总客流、运输方式占比);`二、各种运输方式客流量走势`(自驾、铁路、水路、民航、道路,共 5 ç§æ–¹å¼ï¼‰ï¼›`三、车流量走势`(高速、普通国省道、车流总量、公路出行结构)。附录分为每日客流构成变化、同比走势、观测站路网运行。自驾作为运输方式之一,使用非营业性自驾折算客流,单位万人次;高速/普通国省道/车流总量单位万辆次。
- **实现**:新增 `HolidayTrendService`、`HolidayTrendChartService`、`HolidayTrendWordService` å’Œ `ObservationDailyOperation`,使用 JFreeChart ç”Ÿæˆ 17 å¼  PNG;Word ä»ä»¥ `docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx` ä¸ºæ¨¡æ¿ï¼Œé‡å»ºæ­£æ–‡ã€ä¿ç•™æ¨¡æ¿é¡µçœ‰é¡µè„šå’Œå›¾ç‰‡å°ºå¯¸ï¼Œæ›¿æ¢/追加媒体并重建图片关系。新增后端接口 `GET /api/holiday/trend?year=2026&holidayType=NATIONAL_DAY`;前端节假日统计页新增“下载走势图”按钮。
- **数据与口径**:2024/2025/2026 ä¸‰å¹´ï¼Œæ¨ªè½´å›ºå®š 8 å¤©ï¼›2024、2026 æ— ç¬¬ 8 å¤©æ•°æ®æ—¶ç•™ç©ºï¼Œ2025 æ˜¾ç¤ºç¬¬ 8 å¤©ã€‚累计曲线只连接实际已有日期,缺日不延续最后累计值。观测站附录按 2026-10-01 è‡³ 10-08 æŸ¥è¯¢æ¯æ—¥å¹³å‡æœºåŠ¨è½¦é€Ÿåº¦å’Œå¹³å‡æ‹¥æŒ¤åº¦ï¼›2026 å½“前覆盖 10-01/02/03。
- **验证**:`mvn test` å…¨é€šè¿‡ï¼›å‰ç«¯ `npm run build` æˆåŠŸï¼ˆä»… bundle ä½“积原有警告)。真实库数据生成 `_tmp_holiday_verify/holiday_trend_2026-10-04_v3.docx`,Word ç»Ÿè®¡ 12 é¡µã€17 å¼ å›¾ï¼›ä¸‹è½½æŽ¥å£è¿”回约 399 KB DOCX。未执行任何数据导入、覆盖或重导。
- **未跟踪项**:走势图模板和 `_tmp_holiday_verify/` ä»ä¸ºæœªè·Ÿè¸ªæ–‡ä»¶ï¼›æœ¬åœ°å¯è¿è¡Œï¼Œè‹¥åŽç»­æäº¤/部署需把模板纳入部署包或仓库。状态:外观问题已由本日中午段修复,最终稿待用户验收。
### 10-04 ä¸Šåˆï¼š10 æœˆ 3 æ—¥æ•°æ®å¯¼å…¥å¹¶ç”Ÿæˆå¿«æŠ¥
- **源文件**:`D:\03_公司\02_项目\27_湖北省道路运输事业发展中心\10_业务\99_专项统计\01_重要节假日\2026\十一\10月3日\2026年十一数据10月3日.xlsx`(2026-10-04 10:11 æ›´æ–°ï¼‰ï¼›è§‚测站原始数据为同目录 `观测站分日调查数据2026.10.3.xls`。
- **导入过程(修正)**:用户先于 10:13:34 å¯¼å…¥ä¸»æ•°æ®ï¼›åŠ©æ‰‹éšåŽåœ¨ 10:17:41 åˆç”¨åŒä¸€æ–‡ä»¶é‡å¤å¯¼å…¥äº†ä¸€æ¬¡ã€‚接口返回 `passengerRows=14, vehicleRows=6, skippedRows=24, successRows=20, failRows=0`,未检测到 10 æœˆ 1、2 æ—¥åŽ†å²æ•°æ®å˜åŒ–ã€‚å”¯ä¸€é”®å‘½ä¸­åŽä»…æ›´æ–° `updated_at`,没有新增重复行。
- **10 æœˆ 3 æ—¥å…¥åº“值**:铁路 72.4、民航 5.7571、道路 73.12、水路 5.5668 ä¸‡äººæ¬¡ï¼›é«˜é€Ÿ 193.3634、普通国省道 323.7837 ä¸‡è¾†æ¬¡ï¼›åˆè®¡äººå‘˜æµåŠ¨é‡ 1397.99694 ä¸‡äººæ¬¡ã€‚
- **观测站数据(修正)**:用户先导入;助手重复导入时按同日覆盖重插,当前 2026-10-03 ä¸º 691 è¡Œ / 691 ä¸ªå”¯ä¸€é”®ï¼Œæ— é‡å¤ï¼›ä¸­å°å®¢æµé‡åˆè®¡ 3,237,837 è¾†æ¬¡ï¼ˆ323.7837 ä¸‡è¾†æ¬¡ï¼‰ï¼Œä¸Žæ™®é€šå›½çœé“入库值一致。
- **快报**:已生成 `C:\Users\jcxiong\Downloads\湖北省国庆假期综合运输客流快报(10月3日).docx`。正文:当日 1398.00 ä¸‡äººæ¬¡ï¼ŒçŽ¯æ¯”ä¸‹é™ 3.3%,同比 2025 ä¸‹é™ 10.1%,同比 2024 ä¸‹é™ 5.0%;累计 4486.63 ä¸‡äººæ¬¡ï¼ŒåŒæ¯” 2025 ä¸‹é™ 6.3%,同比 2024 ä¸‹é™ 2.7%。
- **人工稿比对(10-04 æ›´æ–°ï¼‰**:手工稿与系统修正版提取文本逐行一致;系统原版仅有两处累计数差异,已随本次代码修正消除。今日走势图功能暂停。
### 10-04 ä¸Šåˆï¼ˆ2):快报累计数按 Excel äº¿å•位显示精度后再换算
- **问题**:系统原版与手工稿仅两处差异:非营业性小客车累计 `3961.76` åº”为 `3961.77`;公路合计累计 `4189.31` åº”为 `4189.32`。
- **根因**:源表 `人员流动量汇总!X5/AA5`(总计对应 `U5`)存的是亿单位,单元格格式为 `0.0000000_`。人先看到 7 ä½å°æ•°çš„æ˜¾ç¤ºå€¼ï¼Œå†æ¢ç®—成万人次并保留 2 ä½ï¼›æ—§ä»£ç ç›´æŽ¥æ‹¿åŽŸå§‹ç´¯è®¡å€¼æ¢ç®—åŽå– 2 ä½ï¼Œæ¼æŽ‰äº† Excel æ˜¾ç¤ºè¿™ä¸€æ­¥ã€‚
- **修复**:`HolidayBulletinService` æ–°å¢ž `displayedCumulativeWan(...)`,先按源单元格 7 ä½å°æ•°å–整,再乘 10000,最后按快报 2 ä½è¾“出;应用于总计、非营业性小客车、公路合计三处累计数。
- **验证**:新增单测覆盖 `3961.76472 -> 3961.77`、`4189.31472 -> 4189.32`、`4486.62652 -> 4486.63`;`mvn test` å…¨é€šè¿‡ã€‚重启后基于现有库重新生成 10 æœˆ 3 æ—¥å¿«æŠ¥ï¼Œä¸Žæ‰‹å·¥ç¨¿æå–文本逐行比对结果为 `NO DIFFERENCES`。本次未执行任何数据导入。
- **产物**:`_tmp_holiday_verify/bulletin_cumulative_display_2026-10-03.docx`。
- **状态**:待用户验收/提交;走势图功能仍暂停。
### 10-04 ä¸Šåˆï¼ˆ3):快报其余 Excel æ˜¾ç¤ºç²¾åº¦è·¯å¾„审计
- **审计范围**:逐项映射快报正文和表格的所有数值来源,核对源工作簿的实际单元格格式、Excel æ˜¾ç¤ºå€¼å’Œä»£ç ä¼ å…¥çš„æ˜¾ç¤ºç²¾åº¦ã€‚
- **结论**:除已修复的累计值 `U/X/AA` å¤–,没有发现其他未处理的快报取值路径。源表直接参与快报的 `0.00%` å•元格共 11 å¤„:`人员流动量汇总` D/P/J、V/W/Y/Z/AB/AC,道路 H(同比2024年),水路 D(当日环比);代码均先按源格式取 2 ä½ï¼Œå†æŒ‰å¿«æŠ¥ 1 ä½å¤„理。
- **补修边界**:道路 H åŽŸæŒ‰ 1 ä½æºæ ¼å¼å¤„理,10 æœˆ 3 æ—¥ç¢°å·§ä»ä¸º `6.6%`;改为先按 `0.00%` å¾—到 `6.62%` åŽå†å– 1 ä½ï¼Œè¾¹ç•Œå€¼ä¸ä¼šå·® 0.1 ä¸ªç™¾åˆ†ç‚¹ã€‚
- **未纳入直接快报路径的格式**:`普通国省道` E/N è™½ä¸º `0.00%`,快报使用的是 `人员流动量汇总` æ±‡æ€»åŽçš„ P/R/T,不使用该两列;高速公路/普通国省道内部 4 ä½å°æ•°äººå‘˜æµåŠ¨é‡åŒç†åªä½œä¸ºæºå…¬å¼ä¸­é—´åˆ—ï¼Œä¸ç›´æŽ¥è¾“å‡ºã€‚
- **验证**:`mvn test` å…¨é€šè¿‡ï¼›é‡å¯åŽç«¯åŽé‡æ–°ç”Ÿæˆ 10 æœˆ 3 æ—¥å¿«æŠ¥ï¼Œä¸Žæ‰‹å·¥ç¨¿é€å—、逐单元格比较无任何差异。10 æœˆ 2 æ—¥ä¸Žæ‰‹å·¥ç¨¿çš„内容和表格文本一致,仅有 Word run åˆ†æ®µæ ¼å¼å·®å¼‚。`_tmp_holiday_verify/bulletin_after_road_format_2026-10-01/02/03.docx` ä¸ºæœ¬è½®æ ¸å¯¹äº§ç‰©ã€‚
- **数据操作**:只读核对,未导入、覆盖或重导任何数据。
- **状态**:待用户验收/提交。
### 10-04:快报百分比按 Excel å•元格显示精度后再取 1 ä½
- **用户确认的业务口径**:人工核对以 `2026年十一数据.xlsx` å•元格实际显示值为准。比如总计累计同比 2025 åº•层精确值为 `-4.448602%`,Excel å•元格格式为 `0.00%`,人看到并用于核对的是 `-4.45%`,再取快报 1 ä½å°æ•°å°±æ˜¯ `-4.5%`。此前“直接按底层值取一位得到 -4.4%”的结论已作废。
- **实现**:`HolidayBulletinService` æ–°å¢ž `bulletinPercent(...)`,严格按百分比来源单元格格式先取显示精度,再取快报 1 ä½å°æ•°ï¼›æ­£æ–‡æŽªè¾žä¸Žè¡¨æ ¼ç™¾åˆ†æ¯”共用同一逻辑。当前源表逐列口径:多数源单元格为 `0.0%`;`人员流动量汇总` çš„当日环比 D/P/J、累计同比 V/W/Y/Z/AB/AC,以及水路当日环比 D ä¸º `0.00%`。
- **领导分析报告核对规则**:领导分析报告不套用快报的 1 ä½å°æ•°ï¼Œä¸€å¾‹æŒ‰è¯¥æŠ¥å‘Šè‡ªèº«æ˜¾ç¤ºçš„小数位核对,确保与人工在文档里看到的值一致。
- **验证**:新增/更新单元测试覆盖 `-4.448602%` çš„ `-4.4%`(1 ä½æºæ ¼å¼ï¼‰ä¸Ž `-4.5%`(2 ä½æºæ ¼å¼ï¼‰ï¼Œä»¥åŠ `-0.045% -> -0.05% -> -0.1%` è¾¹ç•Œï¼›åŽç«¯ `mvn test` å…¨éƒ¨é€šè¿‡ã€‚重启 8090 åŽç”¨çœŸå®žåº“数据生成 2026-10-02 å¿«æŠ¥ï¼Œæ­£æ–‡å’Œè¡¨æ ¼ä¸Žå·²äººå·¥æ ¸å¯¹çš„æ¨¡æ¿é€é¡¹ä¸€è‡´ï¼Œæ€»è®¡ç´¯è®¡åŒæ¯” 2025/2024 ä¸º `-4.5% / -1.6%`。
- **本地验证产物**:`_tmp_holiday_verify/bulletin_excel_display_rounding_2026-10-02.docx`;功能测试报告与工作日结按本地留档规则写入 `docs/功能测试报告/`、`docs/工作日结/`,不入库。
- **状态**:代码和口径说明随本次提交推送;服务保持运行,访问 `http://localhost:8080`。
## æœ€æ–°ï¼ˆ2026-10-03)
### ã€æ–°å¯¹è¯å…ˆè¯»ã€‘当前状态与下一步(2026-10-03 æ·±å¤œå†™å…¥ï¼‰
- **代码状态(已推送)**:`HEAD = origin/main = f329f50`,内容为 `feat(observation): æ–°å¢žè§‚测站分日调查数据导入与明细查看`,用户已「验收通过」。工作区除本 HANDOFF.md çš„交接更新(尚未提交,等用户确认)外,无其他已跟踪文件改动;仅剩未跟踪的 `_tmp_doc_compare/`、`_tmp_holiday_verify/` å’Œ `docs/专项统计/重要节假日/观测站分日调查数据 (2026å¹´10月1日).xls`(过程产物与源数据,按约定保留、不入库、不删除)。`docs/专项统计/重要节假日/2026年十一数据.xlsx` å·²ç”±ç”¨æˆ·æ”¹å›žåŽŸåï¼Œæ¨¡æ¿ä¸‹è½½æŽ¥å£å®žæµ‹ HTTP 200 / 36335 å­—节。
- **服务状态**:后端 8090、前端 8080 å‡åœ¨è¿è¡Œï¼Œè®¿é—® `http://localhost:8080`;登录 admin/admin123。数据库 `127.0.0.1:3305/traffic_audit`(root/root)。
- **库内数据**:节假日 2026-09-30(铁路/民航前一日)、2026-10-01、2026-10-02 å·²å¯¼å…¥ï¼›**2026-10-03 å°šæœªå¯¼å…¥**。观测站 `observation_station_flow` çŽ°æœ‰ 2026-10-01(692 ç«™ï¼‰ä¸Ž 2026-10-02(689 ç«™ï¼‰ã€‚
- **下一步(2026-10-04 æ—©ä¸Šï¼‰**:10 æœˆ 3 æ—¥æ•°æ®å‡ºæ¥åŽï¼ŒæŒ‰æ—¢æœ‰å£å¾„导入 2026-10-03 å¹¶ç”Ÿæˆå½“天「湖北省国庆假期综合运输客流快报」。领导要求 8:30 å‰å‡ºç¬¬ä¸€ç‰ˆï¼Œè€Œæ°‘航数据可能晚于 8:30 æ‰æœ‰ï¼šæ°‘航未到就先出缺民航版本(累计同比只与往年已覆盖日期比较),民航到齐后重新导入并重新生成。完整口径与已验证结论见本文件 10-03 å„段。
- **无自动化**:曾建的心跳提醒(id `10-03`)已按用户要求删除,用户 2026-10-04 æ˜Žç¡®ã€Œåˆ°æ—¶æˆ‘会手工操作」。明天早上由用户手工导入 10-03 æ•°æ®å¹¶ç”Ÿæˆå¿«æŠ¥ï¼ŒåŠ©æ‰‹åªåœ¨å…¶æå‡ºæ—¶é…åˆæ ¸å¯¹/排查。不要在未获用户明确要求时自行创建定时任务或自动写库。
- **数据发现(供参考,无需改代码)**:2026-10-02 æ¯” 10-01 å°‘ 3 ä¸ªè§‚测站——`G348L149420501` åœŸé—¨ï¼ˆå®œæ˜Œå¸‚/市辖区)、`S464L105422828` å¤§å…¸æ²³ï¼ˆæ©æ–½å·ž/鹤峰县)、`S483L105420801` å±ˆå®¶å²­ï¼ˆè†é—¨å¸‚/市辖区)。已核对两份源 Excel,确属源数据当天未上报,不是导入漏行。
- **长期待办**:见下方第七节「数值精度统一改造」(既有 16 å¼ è¡¨ã€228 ä¸ª double åˆ—改 `DECIMAL(30,15)` + Java `BigDecimal`)。
### 10-03 æ™šï¼šé‡è¦èŠ‚å‡æ—¥æ–°æ¨¡æ¿ï¼ˆ2019/前一日)适配
- **模板变化**:`2026年十一数据.xlsx` çš„道路表增加 2019 å¹´åŠåŒæ¯”2019;铁路、民航增加“前一日”行;快报模板增加同比2024 åˆ—。旧模板已放在 `docs/专项统计/重要节假日/备份/`。
- **数据库初始化**:启动时直接写入 2019 é“路数据,以及 2024/2025/2026 å¹´é“è·¯ã€æ°‘航前一日数据;`holiday_calendar.day_index=0` è¡¨ç¤ºâ€œå‰ä¸€æ—¥â€ï¼Œæ—§åº“启动会自动补齐已有年份的前一日配置行,且不覆盖管理员已维护的日期。
- **正常导入口径**:只读取所选年份的当年列(2026 å¹´é“è·¯/民航/道路/水路为 C åˆ—、高速为 C åˆ—、普通国省道为 D åˆ—);同时只额外读取当前年份铁路/民航的“前一日”行。2019/2024/2025 åŽ†å²åˆ—ä¸å‚ä¸Žå¯¼å…¥ã€‚
- **快报口径**:同比只使用 2025、2024;假期第 1 æ—¥çš„铁路、民航环比使用“前一日”数据,公路、水路、总计及其他口径第 1 æ—¥ä»æ˜¾ç¤ºâ€œâ€””;第 2 æ—¥èµ·æ­£å¸¸è®¡ç®—环比。
- **迁移脚本**:`docs/sql_holiday_previous_day_migration.sql`,部署包同步为 `deploy-templates/db/sql_holiday_previous_day_migration.sql`,脚本可重复执行。
- **2019 æ˜¾ç¤ºè¡¥é½ï¼ˆ10-03 æ™šè¿½åŠ ï¼‰**:`holiday_calendar` å¢žåŠ  2019-10-01 è‡³ 2019-10-08 é…ç½®ï¼›ç³»ç»Ÿé…ç½®-节假日管理和专项统计-节假日均增加 2019 å¹´ä»½ï¼ŒåŽ†å²åº“æ‰§è¡Œå¹‚ç­‰è¿ç§»åŽå³å¯æŸ¥çœ‹ 2019 å¹´ 8 å¤©é“路数据。
- **验证(本机 3305 åº“ + çœŸå®žæ¨¡æ¿ï¼‰**:接口导入结果 `passengerRows=10, vehicleRows=4, successRows=14, failRows=0`;数据库抽查 2026-09-30 é“è·¯=82.1、民航=6.6994,2026-10-01 é“è·¯=113.8、民航=6.7482、道路=78.82、水路=1.311,2026-10-02 é“è·¯=82.0、民航=6.3914、道路=75.61、水路=3.3373;2026-10-01 å¿«æŠ¥é“è·¯çŽ¯æ¯”=+38.6%、民航环比=+0.7%,总行环比为“—”;2026-10-02 é“è·¯çŽ¯æ¯”=-27.9%、民航环比=-5.3%。
- **运行状态**:后端 8090(重启后已加载新代码)、前端 8080 å‡åœ¨è¿è¡Œï¼›è®¿é—® `http://localhost:8080`。
### 10-03 æ·±å¤œï¼šå¿«æŠ¥è¡¨æ ¼ç™¾åˆ†æ¯”与模板样式修复(已验收)
- **问题**:生成快报表格使用“增长/下降”文字,数字拆行并多出 1 é¡µï¼›å¤æ ¸å‘现模板路径正确,根因是 `HolidayBulletinService#setCellText` æ›¿æ¢å•元格时只保留段落属性,未保留模板 run çš„字体/字号属性。
- **口径**:表格环比/同比改为带符号一位小数(正数不加 `+`,负数保留 `-`),零或一位小数后为零显示 `0%`,缺失/基数为空或为零显示 `—`;正文继续使用“增长/下降”。
- **修复**:单元格替换时继承模板 `rPr`;新增 `HolidayBulletinServiceTest` è¦†ç›–正数、负数、零、四舍五入为零、缺失和零基数。
- **验证**:Maven ç¼–译和测试通过;重新生成 2026-10-01 å¿«æŠ¥ï¼Œæ­£æ–‡ä»ä¸ºâ€œå¢žé•¿2.3%”且不含“增长了”;Word å¯¼å‡ºç”± 2 é¡µæ¢å¤ä¸º 1 é¡µï¼Œæ•°å­—不再拆行。最终样报见本地 `_tmp_holiday_verify/bulletin_final_signed_styled_2026-10-01.docx`。
- **状态**:2026-10-03 ç”¨æˆ·éªŒæ”¶é€šè¿‡ï¼›æœ¬æ¬¡æäº¤åªåŒ…含代码、回归测试和本交接记录,不包含 `_tmp_*` è¿‡ç¨‹äº§ç‰©åŠç”¨æˆ·æœªæäº¤çš„æ•°æ®æ–‡ä»¶é‡å‘½åã€‚
### 10-03 æ·±å¤œï¼ˆ2):缺当日民航数据时的累计同比对比期间修正
- **快报民航累计同比对比期间修正(2026-10-03,缺当日民航数据场景)**:当某运输方式当年累计覆盖的日期短于所选日期(典型为 10 æœˆ 2 æ—¥æ°‘航数据未出、民航累计只有 10-01),累计同比改为「只和往年已经覆盖到的日期比较」——先找当年最后一个有该指标数据的日期,再取往年同日累计。修复后缺失民航的 10 æœˆ 2 æ—¥å¿«æŠ¥æ°‘航累计同比由 `-50.5%/-49.9%` å˜ä¸º `-10.0%/-0.9%`(与手工稿 -10.03%/-0.87% çš„ 1 ä½å°æ•°å€¼ä¸€è‡´ï¼‰ã€‚实现见 `HolidayBulletinService.comparableCumulative`(仅作用于民航行),其余行与总人员流动量口径不变。
- **「累计同比 -4.65% vs å¿«æŠ¥ -4.6%」结论(2026-10-03,无需改代码)**:Excel æ˜¾ç¤ºçš„ -4.65% æ˜¯**两位小数**结果;底层精确比值是 `-4.64633%`(2026 ç´¯è®¡ 3082.23818、2025 ç´¯è®¡ 3232.42742),**一次四舍五入到 1 ä½å°æ•°å°±æ˜¯ -4.6%**。把已经取到两位小数的 -4.65 å†å–一位才得到 -4.7,属二次取整,不采用。同理 2026 å¯¹ 2024 ä¸º `-1.7710%` â†’ `-1.8%`。快报百分比统一 1 ä½å°æ•°å£å¾„不变。
  - **2026-10-04 ä¿®æ­£**:按 Excel å•元格显示精度口径,`-4.65%` å†å–一位应为 `-4.7%`;以文件顶部最新段为准。
- **「暂缺民航」提示**:用户确认由人工在生成的快报稿上修改,系统不生成该提示行,**不做代码改动**。
- **验证**:`HolidayBulletinServiceTest` 3 ä¸ªç”¨ä¾‹é€šè¿‡ï¼ˆæ–°å¢ž 2 ä¸ªè¦†ç›–民航累计对比期间与无数据返回 null);端到端用库内真实 2026-10-02 æ•°æ®ï¼ˆé“è·¯/道路/æ°´è·¯/车流齐全、缺民航)重新生成快报,民航累计同比 `-10.0%/-0.9%`、民航累计 `6.75`、总计累计 `3082.24`、总计累计同比 `-4.6%/-1.8%`,与手工稿口径一致。
- **Git**:本次改动待用户「验收通过」后再提交/推送。
### 10-03 æ·±å¤œï¼ˆ3):快报累计正文单位由「亿人次」改「万人次」(待验收)
- **口径(用户/领导确认)**:假期累计数值目前偏小(0.31 äº¿ï¼‰ï¼Œæ­£æ–‡å¯è¯»æ€§å·®ï¼Œæ”¹ä¸ºä¸Žå½“日一致的**万人次**;例如 `假期以来,全省全社会跨区域人员流动量累计3088.63万人次`,不再输出「0.31亿人次」。表格与其余口径不变。
- **实现**:`HolidayBulletinService` æŠ½å‡º `cumulativeParagraph(...)`,直接按万人次(2 ä½å°æ•°ï¼‰è¾“出累计值;同比仍为「增长/下降 + ä¸€ä½å°æ•°ã€ï¼Œ**保持一次取整不变**(该处 2026-10-04 å·²ä¿®æ­£ï¼šæºå•元格为 `0.00%`,Excel æ˜¾ç¤º -4.45%,再取一位为 -4.5%;以文件顶部最新段为准)。
- **验证**:`HolidayBulletinServiceTest` 4 ä¸ªç”¨ä¾‹é€šè¿‡ï¼ˆæ–°å¢ž `cumulativeParagraphUsesWanUnitAndSingleRounding` å›ºåŒ–「万人次 + ä¸€æ¬¡å–整」);重启后端后重新生成 2026-10-02 å¿«æŠ¥ï¼Œæ­£æ–‡ä¸º `累计3088.63万人次,比2025年同期下降4.4%,比2024年同期下降1.6%`,与手工稿仅差既定的取整口径;2026-10-01 å›žå½’正常(`累计1643.17万人次`)。
- **状态**:待用户「验收通过」后提交/推送。
### 10-03 æ™šï¼ˆ4):新增「观测站分日调查数据」原始数据导入(待验收)
- **背景(用户 2026-10-03 æå‡ºï¼‰**:普通国省道车流量的来源之一是观测站分日调查数据(模板 `docs/专项统计/重要节假日/观测站分日调查数据 (2026å¹´10月1日).xls`,其中字段「中小客流量」即普通国省道车流量来源)。**系统内不做关联**,节假日统计仍走原有导入;本功能只做原始数据留存,为后续分析挖掘积累数据基础。
- **数据表**:新增 `observation_station_flow`,DDL è§ `docs/sql_observation_station.sql`,部署包同步为 `deploy-templates/db/sql_observation_station.sql`。保留模板全部 26 åˆ—(观测站编号/名称/观测里程/地市/区县/行驶方向 + å„车型流量 + å®¢è½¦/货车/汽车/机动车流量 + å„类当量 + æœºåŠ¨è½¦é€Ÿåº¦ + æ‹¥æŒ¤åº¦ + æ¥æºæ–‡ä»¶åï¼‰ã€‚唯一键 `(obs_date, station_code, direction)`;**数值列一律 `DECIMAL(30,15)`**,Java å®žä½“字段一律 `BigDecimal`,与节假日表同口径,无 float/double。
- **导入实现**:新增包 `com.trafficaudit.observation`(entity / mapper / dto / service / controller)。用 POI `WorkbookFactory` åŒæ—¶æ”¯æŒ `.xls` å’Œ `.xlsx`;**表头按列名匹配**(列顺序变化不影响导入),扫描前 10 è¡Œå®šä½è¡¨å¤´ï¼›æ—¥æœŸå…¼å®¹ Excel æ—¥æœŸæ ¼å¼ä¸Ž `yyyy-M-d`/`yyyy/M/d`/`yyyyMMdd`/中文年月日;同一日期**重复导入即整体覆盖**(先删该日期再插入),幂等。
- **接口**:`POST /api/observation/import`、`GET /api/observation/dates`(按日期汇总站数、中小客流量合计、最近导入时间)、`GET /api/observation/rows?date=&limit=`、`POST /api/observation/delete?date=`。
- **前端**:`traffic-audit-web/src/views/HolidayStat.vue`(专项统计 - èŠ‚å‡æ—¥ï¼‰æ–°å¢žã€Œè§‚æµ‹ç«™åˆ†æ—¥è°ƒæŸ¥æ•°æ®ã€å¡ç‰‡ï¼šé€‰æ‹©æ–‡ä»¶ â†’ å¯¼å…¥ â†’ æŒ‰æ—¥æœŸæ±‡æ€»è¡¨ â†’ æŸ¥çœ‹æ˜Žç»†ï¼ˆå¼¹çª—)→ åˆ é™¤ã€‚
- **实测(真实模板,本机 3305)**:导入 692 è¡Œ / 692 ç«™ / 0 è·³è¿‡ / 0 å¤±è´¥ï¼›ä¸‰é¡¹åˆè®¡ä¸Žæºæ–‡ä»¶é€é¡¹ä¸€è‡´ï¼ˆä¸­å°å®¢æµé‡ 3,523,873、机动车流量 5,487,965、机动车当量 7,140,575);**692 è¡Œ Ã— 25 å­—段全量逐行比对 0 å¤„不一致**;中文(站名/地市/区县)落库无乱码;重复导入仍为 692 è¡Œï¼ˆè¦†ç›–而非累加);上传非 Excel è¿”回明确错误。旁证:692 ç«™ä¸­å°å®¢æµé‡åˆè®¡ 3,523,873 è¾† = 352.3873 ä¸‡è¾†ï¼Œä¸Ž 2026-10-01 èŠ‚å‡æ—¥ã€Œæ™®é€šå›½çœé“ã€å…¥åº“å€¼å®Œå…¨ä¸€è‡´ï¼Œå°è¯äº†æ•°æ®æ¥æºå…³ç³»ã€‚
- **顺带修复**:`pack-deploy.ps1` æŠŠ db è„šæœ¬å¤åˆ¶è¿›åŒ…å†… docs çš„æ¸…单里漏了 `sql_holiday_previous_day_migration.sql`,本次一并补上 `sql_observation_station.sql`;`deploy-templates/README-部署说明.txt` çš„ db æ¸…单同步更新。
- **10-03 æ·±å¤œè¿½åŠ ï¼ˆè§‚æµ‹ç«™ç•Œé¢å¾®è°ƒï¼‰**:①「查看」明细弹窗按模板表头展示原始数据全部字段(观测日期/站号/站名/里程/地市/区县/方向 + å…¨éƒ¨è½¦åž‹æµé‡ã€å®¢è´§æ±½è½¦æœºåŠ¨è½¦æµé‡ã€å„ç±»å½“é‡ã€é€Ÿåº¦ã€æ‹¥æŒ¤åº¦ï¼Œå¦é™„æ¥æºæ–‡ä»¶åï¼Œå…± 27 åˆ—,横向滚动);②汇总表「中小客流量合计」单位由辆次改为**万辆次**、保留 4 ä½å°æ•°ï¼ˆ`formatWanVehicles`,如 3,523,873 â†’ 352.3873)。前端 dev server å·²çƒ­æ›´æ–°ç¼–译通过。
- **10-03 æ·±å¤œè¿½åŠ ï¼ˆè§‚æµ‹ç«™åˆ—è¡¨/明细修正)**:①**明细行序**原来按 `stationCode` æŽ’序,与 Excel è¡Œåºå¯¹ä¸ä¸Šï¼Œæ”¹ä¸ºæŒ‰å¯¼å…¥é¡ºåºï¼ˆ`id ASC`,即 Excel è¡Œåºï¼‰ï¼Œä¾¿äºŽé€è¡Œæ¯”对;②明细**列顺序**与模板 26 åˆ—完全一致(末尾附来源文件名);③**观测里程/机动车速度/拥挤度**改为按原始小数显示(`formatPlain`,去尾零,如 50.92 / 43.882 / 0.777 / 58),不再固定 2 ä½ï¼›â‘£**17 ä¸ªæµé‡ä¸Žå½“量列**改为整数显示(`formatInteger`,如 6413),与 Excel ä¸€è‡´ï¼›â‘¤æ±‡æ€»åˆ—表「观测日期」改为**正序**(mapper `ORDER BY obs_date ASC`)。已用 10-01/10-02 çœŸå®žæ•°æ®éªŒè¯è¡Œåºä¸Ž Excel å‰ 5 è¡Œä¸€è‡´ï¼ˆä¸­å¸®/万店/沼山站/邾城站/白云)。
- **状态**:待用户「验收通过」后提交/推送。
---
### è¡¥å……(10-03 ä¸‹åˆï¼‰ï¼šæœåŠ¡å™¨éƒ¨ç½²æŠ¥ â€œAccess denied ... (using password: NO)” â€”— æ ¹å› ä¸Žä¿®å¤
- **现象**:把 `deploy/traffic-audit-release-20261002/` éƒ¨ç½²åˆ° `61.183.254.94` åŽï¼ŒåŽç«¯å¯åŠ¨æŠ¥ `java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)`。
- **根因**:jar å†…置配置里库口令是占位符 `${TRAFFIC_DB_PASSWORD:}`(默认空串),而 10-02 é‚£ä¸ªåŒ…**只含 jar + web + dist**,没有外置 `application.yml`、没有 `docs/`、`db/`、启动脚本。部署机没有任何外部配置覆盖,Spring å°±æ‹¿ç©ºå¯†ç è¿žæœ¬æœº MySQL â†’ `using password: NO`(注意:这是“末传密码”,不是密码错)。
- **服务器侧一步修复(无需重打包)**:在 jar åŒçº§æ”¾ `application.yml`,把 `spring.datasource.url/username/password` æ”¹æˆçŽ°åœºçœŸå®žå€¼åŽé‡å¯å³å¯ï¼›ä¹Ÿå¯ç”¨çŽ¯å¢ƒå˜é‡ `TRAFFIC_DB_PASSWORD` ä»£æ›¿ã€‚启动脚本已加预检提醒(缺 `application.yml` / password ä»æ˜¯å ä½æ–‡å­—都会 WARN)。修复后若报“未找到数据目录 docs”则说明包内缺 `backend/docs`,从新包或源机器拷。
- **打包工具已修(原 â€œæ‰“包工具待修” ä¸¤é¡¹å‡å·²è½å®žï¼‰**:`pack-deploy.ps1` é‡å†™ä¸ºäº”步:① ç”¨æ–°å¢žæ¨¡æ¿ `deploy-templates/jar-application.yml`(**全占位符**)临时替换源码配置后 `mvn clean package`,无论成败都还原真实配置;② è§£åŽ‹ jar åŽ»å¯†è‡ªæ£€ï¼›â‘¢ å‰ç«¯æž„建;④ ç»„装**完整包**:`backend/{jar, application.yml, start-backend.cmd/.sh, docs/, web/}` + `frontend/dist` + `db/*.sql` + README(`backend/web` ä»Ž dist è‡ªåŠ¨åŒæ­¥ï¼Œé€æ–‡ä»¶ä¸€è‡´ï¼‰ï¼›â‘¤ æ•´åŒ…明文密钥扫描。新增 `-SkipBackend` å¯å¤ç”¨çŽ°æœ‰ jar å¿«é€Ÿé‡ç»„包。
- **模板同步修正**:`deploy-templates/backend/application.yml` çš„ `server.port` ç”± 8080 æ”¹å›ž 8090(原值会导致后端起在 8080)、补 `static-locations: file:./web/` ä¸Ž password é™·é˜±æ³¨é‡Šï¼›`README-部署说明.txt` å¸¸è§é—®é¢˜æ–°å¢žè¯¥æŠ¥é”™å¤„置;`docs/打包部署说明.md` æŽ’错表新增该行;`application.yml.example` çŽ¯å¢ƒå˜é‡åç»Ÿä¸€ä¸º `TRAFFIC_DB_USER/TRAFFIC_DB_PASSWORD/TRAFFIC_JWT_SECRET`(原来误用 `MYSQL_PASSWORD`/`JWT_SECRET`,与 `start-dev.ps1` ä¸ä¸€è‡´ï¼‰ã€‚
- **新包(本地,不入库)**:`deploy/traffic-audit-deploy-20261003/`(388 æ–‡ä»¶ / 93.07 MB,zip 76.37 MB),已验证:jar å†…置配置全占位符(去密自检通过)、`backend/web` ä¸Ž `frontend/dist` é€æ–‡ä»¶ä¸€è‡´ï¼ˆå„ 44 ä¸ªï¼‰ã€`backend/docs/生成汇总大表` æ¯ç‰ˆå­˜åœ¨ã€`功能测试报告/工作日结/问题汇总/_cache` å·²æŽ’除。
- **踩坑(重要)**:本机 PowerShell 5.1 æŠŠæ—  BOM çš„ `.ps1` å½“ ANSI è¯»ï¼Œä¸­æ–‡æ³¨é‡Šä¼šå˜æˆä¹±ç å¹¶å¯¼è‡´è„šæœ¬è§£æžå¤±è´¥ã€‚`pack-deploy.ps1` å¿…须保存为 **UTF-8 with BOM**(仓库其他 `*.ps1` ä¹Ÿéƒ½æ˜¯ BOM);排查中曾实际踩到。
- **运行状态(写交接时)**:后端 8090、前端 8080 å‡æœªç›‘听;新对话如继续开发或测试,先执行 `powershell -NoProfile -ExecutionPolicy Bypass -File start-dev.ps1`。数据库仍为 `127.0.0.1:3305/traffic_audit`,MySQL æœåŠ¡æœªåœã€‚
- **发布构建(已完成)**:应“发布编译”要求,已完成后端 `mvn clean package -DskipTests` å’Œå‰ç«¯ `npm run build`。jar å†…已做去密自检,只保留环境变量占位符;构建后本机真实 `src/main/resources/application.yml` å·²åŽŸæ ·æ¢å¤ã€‚
- **发布产物(本地,未入库)**:
  - åŽç«¯ jar:`traffic-audit-server/target/traffic-audit-server-1.0.0-SNAPSHOT.jar`(约 54.6 MB)
  - å‰ç«¯ï¼š`traffic-audit-web/dist`(44 ä¸ªæ–‡ä»¶ã€çº¦ 3.55 MB)
  - å‘布目录:`deploy/traffic-audit-release-20261002/`(含 `backend/`、`frontend/dist/`)
  - å‘布压缩包:`deploy/traffic-audit-release-20261002.zip`(约 51.11 MB)
- **发布包结构说明**:`frontend/dist` æ˜¯æ ‡å‡†å‰ç«¯å‘布目录;`backend/web` æ˜¯åŒä¸€ä»½ dist çš„副本,用于后端 8090 å•端口模式(当前静态资源路径为 `file:./web/,classpath:/static/`)。两处已逐文件比对,文件列表与 SHA-256 å®Œå…¨ä¸€è‡´ã€‚
- **安全状态**:本机真实配置已保存在被忽略的 `traffic-audit-server/config/application.yml`;发布 jar ä¸å«çœŸå®žæ•°æ®åº“口令、JWT secret æˆ– DeepSeek key。发布包不包含部署机的真实外部配置,部署时需要继续使用现场 `application.yml` æˆ–按模板填写。
- **打包工具待修(重要)**:`pack-deploy.ps1` ç›®å‰ç›´æŽ¥æ¶ˆè´¹ `src/main/resources/application.yml`,若该文件被本机真实配置覆盖,会在去密自检阶段中止;且它只把 dist æ”¾åˆ° `frontend/dist`,没有同步到 `backend/web`,默认包无法直接用于 8090 å•端口模式。本次使用“临时占位符配置 â†’ æž„建自检 â†’ æ¢å¤çœŸå®žé…ç½®â€çš„æ–¹å¼å®Œæˆã€‚下次发布前建议改为从 `application.yml.example` ç”Ÿæˆä¸´æ—¶èµ„源,并在组装阶段同时复制 `backend/web`。
- **Git/工作区(本段更新前)**:`HEAD = origin/main = 37f5209`;当前无已跟踪文件改动。未跟踪项有 `_tmp_holiday_verify/`(历史验证产物)和 `docs.zip`(约 31.6 MB,用户本地产物);二者均保留,新对话不要误删或误提交。
- **下一步**:如需部署,直接使用上面发布目录或 zip;如需继续修复发布工具,先实现“占位符模板自动切换 + `backend/web` è‡ªåŠ¨ç»„è£… + jar åŽ»å¯†è‡ªæ£€â€åŽå†é‡æ–°äº§å‡ºå‘å¸ƒåŒ…ã€‚
### Windows å¼€æœºè‡ªå¯ï¼ˆ10-03 è¿½åŠ ï¼Œç”¨æˆ·é€‰â€œæ–¹æ¡ˆ B”:注册成 Windows æœåŠ¡ï¼‰
- **先回答过的问题**:`start-backend.cmd` **不能**直接做开机自启(末尾有 `pause`、无日志重定向、依赖 PATH)。给出 A(计划任务)/B(Windows æœåŠ¡ï¼‰/C ä¸‰æ–¹æ¡ˆåŽï¼Œç”¨æˆ·é€‰ **B**。
- **新增模板(已入库)**:
  - `deploy-templates/backend/trafficAudit.xml`:WinSW æœåŠ¡é…ç½®â€”â€”id `TrafficAuditBackend`、`startmode=Automatic` + `delayedAutoStart=true`(等 MySQL å…ˆå°±ç»ªï¼‰ã€`onfailure restart 10s/30s/60s` + `resetfailure 1 hour`、`workingdirectory=%BASE%`、日志 `logs\trafficAudit.out.log|err.log`(10MB Ã— 10 æ»šåŠ¨ï¼‰ã€‚
  - `deploy-templates/backend/install-service.ps1`:管理员安装/查看/重启/删除(`-Status/-Restart/-Remove/-NoStart/-JavaPath`);自动定位 java(JAVA_HOME â†’ PATH â†’ JavaSoft æ³¨å†Œè¡¨ â†’ Program Files)并把绝对路径写进 XML çš„ `executable` å…ƒç´ ï¼›é‡å¤æ‰§è¡Œå¹‚等;非管理员直接拒绝。
  - `start-backend-service.cmd` + `install-autostart.ps1`(计划任务方式)保留为**备选**;注意 `New-ScheduledTask*` éœ€ Win8/Server 2012+,且**不可与服务方式同时启用**(会抢 8090)。
  - `pack-deploy.ps1`:新增 WinSW èŽ·å–ï¼ˆç¼“å­˜ `deploy-templates/tools/winsw.exe` å¹¶æ ¡ SHA-256,缺则从 GitHub ä¸‹è½½ v2.12.0 x64)与 `-SkipServiceWrapper`;包内输出 `backend\trafficAudit.exe`(改名以匹配 XML æ–‡ä»¶åï¼‰ã€‚`.gitignore` æ–°å¢ž `deploy-templates/tools/`(17.4MB äºŒè¿›åˆ¶ä¸å…¥åº“)。
- **WinSW = Windows Service Wrapper**:把普通程序包成 Windows æœåŠ¡ã€‚java.exe è‡ªå·±ä¸æ˜¯æœåŠ¡ç¨‹åºï¼Œç›´æŽ¥ `sc create` ä¼šæŠ¥â€œæœåŠ¡æ²¡æœ‰åŠæ—¶å“åº”å¯åŠ¨æˆ–æŽ§åˆ¶è¯·æ±‚â€ï¼›WinSW å®žçŽ°äº†æœåŠ¡æŽ§åˆ¶åè®®å†æ‹‰èµ· `java -jar`,并管重启与日志。
- **实测验证(本机,未真正安装服务)**:`trafficAudit.exe status` èƒ½è¯»æ‰“包 XML å¹¶è¾“出 `NonExistent`(解析通过);故意写坏 XML â†’ `FATAL - The configuration file cound not be loaded`(证明该校验路径真实有效);`install-service.ps1 -Status` æ­£å¸¸è¾“出“未安装”,无参数 / `-Remove` åœ¨éžç®¡ç†å‘˜ä¸‹æŠ›â€œå¿…须管理员”且不产生注册项。
- **踩坑(重要)**:XML æ”¹å†™æœ€åˆç”¨æ­£åˆ™ `(?s)<executable>.*?</executable>`,会**先命中注释里的 `<executable>` å­—æ ·**,把注释头与 service å¼€å¤´æ•´æ®µåžæŽ‰ â†’ XML éžæ³•。已改为 DOM(`SelectSingleNode('/service/executable')`)并把注释里的裸标签改掉。另:新增打包代码曾多一个 `}` å¯¼è‡´è¯­æ³•错,已用 `Parser::ParseFile` AST å¤æ ¸ï¼Œä¸¤ä¸ª ps1 çŽ°åœ¨è¯­æ³• OK ä¸”均为 UTF-8 with BOM。
- **兼容性(用户问“Win7/Win10/Win11/Server æ˜¯ä¸æ˜¯éƒ½å¯ä»¥â€ï¼‰**:服务机制完全一样(自动启动/延迟启动/失败重启都是系统能力),差别只在包装器依赖什么运行时。实测二进制:`WinSW-x64.exe` 2.12.0 æ˜¯**自包含 .NET 6 å•文件**(含 `singlefilehost`/`System.Private.CoreLib`/`Microsoft.NETCore.App 6.0.13`/`net6.0`),**不需要装 .NET Framework**;老系统(Win7 SP1/2008R2/2012R2)若报 `api-ms-win-*.dll` ç¼ºå¤±ï¼Œå¯æ¢å®˜æ–¹ `WinSW.NET461.exe`(0.6MB,需 .NET 4.6.1+,改名成 `trafficAudit.exe` å³å¯ï¼ŒXML ä¸åŠ¨ï¼‰ï¼›32 ä½ç³»ç»Ÿæ¢ `WinSW-x86.exe`。
- **安全提示**:WinSW å®˜æ–¹äº§ç‰©**未做 Authenticode ç­¾å**(本机 `Get-AuthenticodeSignature` = NotSigned),因此以固定 SHA-256 ä½œå®Œæ•´æ€§æ ¡éªŒï¼š`05B82D46AD331CC16BDC00DE5C6332C1EF818DF8CEEFCD49C726553209B3A0DA`(与 GitHub å‘布资产 18,243,033 å­—节一致)。
- **未验证项(需在目标机做一次)**:本机当前不是管理员,**没真正 install è¿‡æœåŠ¡**;开机自启、失败重启、日志滚动要在服务器上按 README 3.1 æ‰§è¡Œä¸€æ¬¡ç¡®è®¤ã€‚
- **新包(本地,不入库)**:`deploy/traffic-audit-deploy-20261003-3/`(393 æ–‡ä»¶ / 110.49 MB,zip 83.77 MB)。已验证:服务三件套在 `backend\`、包内 `trafficAudit.exe` SHA-256 ä¸Žå›ºå®šå€¼ä¸€è‡´ã€`backend\web` ä¸Ž `frontend/dist` å„ 44 æ–‡ä»¶ã€jar åŽ»å¯†è‡ªæ£€é€šè¿‡ã€æ— æ˜Žæ–‡ key。**旧的 `traffic-audit-deploy-20261003/` ä¸Ž `-2/` å·²è¢«å–代**,部署请用 `-3`(旧包待用户确认后再删)。
- **运行状态(写本条时)**:开发用后端 8090(PID 43040)、前端 8080(PID 43152)均在跑;数据库 `127.0.0.1:3305/traffic_audit`。
- **补充可操作性(用户反馈“backend ä¸‹æ–‡ä»¶å¤ªå¤šï¼Œä¸çŸ¥é“怎么操作”)**:
  - æ–°å¢žåŒå‡»å…¥å£ `deploy-templates/backend/install-service.cmd`:自动 UAC ææƒ â†’ è°ƒ `install-service.ps1`;若找不到 `trafficAudit.exe` åˆ™æç¤ºâ€œå¿…须整个 backend ä¸€èµ·æ‹·â€ã€‚
  - `install-service.ps1` è£…完会自己探测端口(TCP æŽ¢æµ‹ï¼Œå…¼å®¹ PowerShell 2.0),打印 OK + æœ¬æœº/对外网址;起不来时直接打印 `logs\*.log` æœ€åŽ 15 è¡Œï¼›è‹¥ `application.yml` å£ä»¤ä»åƒå ä½ç¬¦ï¼Œæœ€åŽå†æ‰“一次醒目提示。
  - `README-部署说明.txt` é¡¶éƒ¨æ–°å¢žã€æœåŠ¡å™¨ä¸Šåªè¦åšè¿™ 3 æ­¥ã€‘+“哪些要拷/哪些不用拷”;`docs/打包部署说明.md` çš„ 5.4 æ”¹ä¸ºâ€œå³é”® `install-service.cmd` â†’以管理员运行”。
  - ä¸‰ä¸ª `.cmd` æ¨¡æ¿ç»Ÿä¸€ä¸º CRLF(新增的两个原来是 LF,批处理在 Windows ä¸Šåº”用 CRLF)。
  - å®žæµ‹ï¼šå¼ºåˆ¶èµ°â€œå·²ææƒâ€åˆ†æ”¯æ—¶ `cmd â†’ install-service.ps1 -Status` é“¾è·¯è·‘通、`%*` ä¼ å‚正常;`Test-LocalPort` å¯¹ 8090 è¿”回 True、对 8099 è¿”回 False。
  - æ–°åŒ…:`deploy/traffic-audit-deploy-20261003-4/`(394 æ–‡ä»¶ / 110.50 MB,zip 83.78 MB)——**当前唯一可用包**。
- **发布包清理(10-03 æ™šï¼Œç”¨æˆ·ç¡®è®¤â€œåªç•™ -4”)**:已删除 `traffic-audit-release-20261002/`(+zip)、`traffic-audit-deploy-20261003/`(+zip)、`-2/`(+zip)、`-3/`(+zip) åŠç©ºçš„ `server-fix/`,`deploy\` çŽ°åœ¨åªæœ‰ `traffic-audit-deploy-20261003-4/` å’Œå®ƒçš„ zip(释放约 671MB)。
  > å› æ­¤æœ¬æ–‡ä»¶ä¸Šé¢æåˆ°çš„ `deploy/traffic-audit-deploy-20261003/`(388 æ–‡ä»¶ï¼‰ä¸Ž `deploy/traffic-audit-release-20261002/` å‡ä¸º**历史记录**,磁盘上已不存在,别再去找。
- **紧急修复(10-03 æ™šï¼‰ï¼š`install-service.ps1` å¯†ç è§£æžå´©æºƒï¼ŒæœåŠ¡æ ¹æœ¬æ²¡è£…ä¸Š**
  - çŽ°è±¡ï¼šç”¨æˆ·åœ¨æœåŠ¡å™¨ï¼ˆ`D:\trafficAudit`)双击 `install-service.cmd`,报 `无法将“Trim”的参数“trimChars”转换为类型“System.Char”`,位置 `install-service.ps1:210`。
  - æ ¹å› ï¼šé‚£è¡Œæ˜¯ `.Trim(' ''', '"')`,其中 `' '''` è¢« PowerShell è§£æžæˆä¸€ä¸ª**长度 2 çš„字符串**(空格 + å•引号),`Trim(char[])` æ— æ³•把 2 å­—符串转成单个 char â†’ æ•´è¡ŒæŠ›å¼‚常(`MethodArgumentConversionInvalidCastArgument`)。
  - ä¿®å¤ï¼šæŠŠè¿™æ®µæŠ½æˆçº¯å‡½æ•° `Test-AppConfigPassword`,改写为 `Trim()` â†’ `Trim("'")` â†’ `Trim('"')`;并加脚本级 `trap`,任何异常只打印一行说明 + è¡Œå·ï¼Œä¸å†ç”©çº¢å­—堆栈。
  - éªŒè¯ï¼š8 ä¸ªæ ·æœ¬ï¼ˆç¼ºæ–‡ä»¶ / ç©ºå€¼ / ä¸­æ–‡å ä½ç¬¦ / è£¸å€¼ / åŒå¼•号 / å•引号 / å«ç©ºæ ¼ç‰¹æ®Šå­—符 / çœŸå®žæ¨¡æ¿æ–‡ä»¶ï¼‰å…¨éƒ¨ç¬¦åˆé¢„期;打包内脚本语法解析通过、`-Status` å®žè·‘正常、非管理员时 trap è¾“出干净错误并 exit 1。
  - æ•™è®­ï¼šè¿™æ¡åˆ†æ”¯ä¹‹å‰æ²¡è¢«ä»»ä½•测试覆盖(`-Status` æå‰ exit、非管理员在 `Assert-Admin` å°±é€€å‡ºï¼‰ï¼Œæ‰€ä»¥è§£æžç±»é€»è¾‘要抽成纯函数并用样本测。
  - åŒ…名:修复版重打包时脚本复用了已释放的基础名,最终已删掉旧坏包并改名,`deploy\` ä»ç„¶æ˜¯**唯一**的 `traffic-audit-deploy-20261003-4\`(含修复后的 install-service.ps1)与同名 zip。
  - ç”¨æˆ·ä¾§æœ€å°ä¿®å¤ï¼šåªéœ€ç”¨æ–°åŒ…里的 `backend\install-service.ps1` è¦†ç›–服务器 `D:\trafficAudit\install-service.ps1`,再重跑 `install-service.cmd`(不必重拷 110MB)。
- **服务器部署成功(10-03 æ™šï¼Œç”¨æˆ·ç¡®è®¤ï¼‰**:服务器(部署目录 `D:\trafficAudit`,把 backend é‡Œçš„内容直接铺在该目录下)用修复后的 `install-service.cmd` å®‰è£…服务 `TrafficAuditBackend` æˆåŠŸã€‚
  - **已验证(用户确认)**:① åŠžå…¬ç”µè„‘æµè§ˆå™¨èƒ½æ‰“å¼€æœåŠ¡å™¨é¡µé¢ï¼ˆè¯´æ˜Žç½‘ç»œ/防火墙这一层通,无需额外放行 8090);② **重启服务器后仍能从办公电脑打开页面 â†’ å¼€æœºè‡ªå¯å®žæµ‹ç”Ÿæ•ˆ**(服务不依赖任何账号登录)。
  - **仍待做**:业务功能验收(登录 / æ•°æ®å¯¼å…¥ / æŠ¥è¡¨å¯¼å‡º / Word å¿«æŠ¥ï¼‰ï¼›ç¡®è®¤ `application.yml` çš„ jwt.secret å·²æ¢æˆæ­£å¼éšæœºå€¼ã€é¢„ç½® admin/admin123 å·²æ”¹å¯†ã€‚
  - é˜²ç«å¢™æ”¾è¡Œï¼ˆè€ç³»ç»Ÿä¹Ÿèƒ½ç”¨ï¼‰ï¼š`netsh advfirewall firewall add rule name="TrafficAudit 8090" dir=in action=allow protocol=TCP localport=8090`
  - è£…完后常用:`install-service.ps1 -Status / -Restart / -Remove`;日志在 `D:\trafficAudit\logs\trafficAudit.out.log`(错误看 `trafficAudit.err.log`)。
## æœ€æ–°ï¼ˆ2026-10-02)
- **新模块(用户已验收,本次提交/推送)**:新增“专项统计-重要节假日”模块,含节假日配置、营业性客流、非营业性车流、客运系数 4 å¼ è¡¨ï¼›ç³»ç»Ÿé…ç½®èœå•ä»… admin å¯ç»´æŠ¤ï¼Œä¸“项统计-节假日支持导入、导出、模板下载和 Word å¿«æŠ¥ä¸‹è½½ã€‚
- **数据初始化**:从 `docs/专项统计/重要节假日/2026年十一数据.xlsx` åˆå§‹åŒ– 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµå’Œ 2024-2026 å¹´å®¢è¿ç³»æ•°ï¼›æºè¡¨ 90 ä¸ªæ•°æ®å•元格入库逐项比对差异 0。
- **日常导入口径**:只读取所选年份的当年列(2026 å¹´ä¸ºå„客流工作表 C åˆ—、普通国省道 D åˆ—);历史年份不要求日常重导。
- **历史数据保护**:导入时先检查更早历史日期是否被修改。有变化则先不写库,返回差异清单,前端弹窗让用户选择“允许导入/不允许”;确认后才覆盖。接口参数 `allowHistoricalOverwrite=true`。
- **快报**:默认下载系统日期前一天的《湖北省国庆假期综合运输客流快报(10月X日).docx》,用户可自选日期;环比/同比按增长、下降、持平输出。
- **Excel æµ®ç‚¹ç²¾åº¦ï¼ˆ2026-10-02 è¿½åŠ ï¼‰**:参考 `docs/Excel浮点数存储.txt`,新增 `HolidayNumberUtils`(`NumberToTextConverter.toText` + `BigDecimal`,`MathContext(15, HALF_UP)`);节假日模块由 `Double` å…¨é¢æ”¹ `BigDecimal`;三张表数值列改 `DECIMAL(30,15)`;库内 6 æ¡å®¢è¿ç³»æ•°ç”± 2.3999999999999995 æ ¡æ­£ä¸º 2.4。迁移脚本 `docs/sql_holiday_decimal_migration.sql`。
- **菜单顺序**:“专项统计”已移到“报表生成”之后,当前为 æŠ¥è¡¨ç”Ÿæˆ -> ä¸“项统计 -> è§„则管理 -> ç”¨æˆ·ç®¡ç† -> ç³»ç»Ÿé…ç½®ã€‚
- **节假日删除复活修复(2026-10-02 è¿½åŠ ï¼‰**:原初始化逻辑每次启动补齐缺失的第 N æ—¥ï¼Œå¯¼è‡´ admin åˆ é™¤ 2026-10-08 åŽè¢«é‡æ–°æ’入;现改为仅 `holiday_calendar` æ•´è¡¨ä¸ºç©ºæ—¶é¦–次初始化。重启后 2026 å¹´åä¸€ä¿æŒ 10-01 è‡³ 10-07 å…± 7 å¤©ã€‚
- **普通国省道环比基期(2026-10-02 è¿½åŠ ï¼Œ2026-10-06 æ–¹æ¡ˆ D å·²ä¿®æ­£ï¼‰**:C åˆ—语义最终确定为“下一日环比分母的调整值”;填写后只影响下一日的环比分母,不影响 C æ‰€åœ¨æ—¥åŠç´¯è®¡ã€èµ°åŠ¿å›¾ã€‚`holiday_vehicle_flow.comparison_base` å·²å…¥åº“,导入导出与历史变更确认均已接入。
- **快报首日口径(2026-10-02 è¿½åŠ ï¼‰**:假期第1天没有前一日环比基数,正文不输出“环比增长/下降/持平”短语,表格当日环比列为“—”;第2天起仍按实际前一日输出环比。已用 2025-10-01/02 å¿«æŠ¥å›žå½’验证。
- **快报百分比精度(2026-10-02 è¿½åŠ ï¼‰**:Word å¿«æŠ¥ä¸­åŒæ¯”/环比百分比统一保留1位小数(如6.79%显示6.8%、11.46%显示11.5%);人次、车流等业务数值仍保留2位小数。
- **验证结果**:后端编译、前端构建、权限/CRUD/负向导入、历史变更两阶段导入、Excel å¯¼å‡ºã€Word å¿«æŠ¥ã€å‰ç«¯ admin/非 admin çƒŸæµ‹å‡é€šè¿‡ï¼›èœå•顺序、客运系数 2.4 å±•示(系统配置页 + èŠ‚å‡æ—¥ç»Ÿè®¡é¡µï¼‰ã€`decimal(30,15)` åˆ—型、历史变更两阶段导入回归均已实测;功能测试报告与工作日结按 2026-09-27 æäº¤è§„范仅本地留存(不入库):`docs/功能测试报告/功能测试报告_重要节假日专项统计_2026-10-02.md`、`docs/工作日结/工作日结_2026-10-02.md`。
- **运行状态**:后端 8090、前端 8080 å·²å¯åŠ¨ï¼›æ•°æ®åº“ `127.0.0.1:3305/traffic_audit`。测试产生的 2026 ä¸´æ—¶æ•°æ®å’Œå¯¼å…¥æ‰¹æ¬¡å·²æ¸…理。
- **关机收尾(2026-10-02)**:已停掉本次开发用后端 8090、前端 8080 åŠå„自进程树,端口均已释放;MySQL æœåŠ¡ä¿ç•™ã€‚å½“å‰å¯å®‰å…¨å…³æœºã€‚
- **Git**:用户已于 2026-10-02 æ˜Žç¡®éªŒæ”¶é€šè¿‡ï¼›æœ¬æ¬¡æŒ‰è¦æ±‚合并远端 `origin/main` åŽæäº¤å¹¶æŽ¨é€ï¼Œä¸è¦†ç›–远端同事提交。
- **部署模板同步**:节假日 4 å¼ è¡¨å·²åŒæ­¥åˆ° `deploy-templates/db/init.sql`;`sql_holiday.sql` åŠä¸¤ä¸ªæ—§åº“迁移脚本随部署包发布,避免新包缺表。
## æœ€æ–°ï¼ˆ2026-09-28)
- **用户用 WPS ç¼–辑母版**:改《班线包车》边框、《道路运输周转量》字号,并删除《中口径明细》`AW` åˆ—(手工定稿同删)。已验证生成端正常:中口径明细 2024 åŠæ›´æ—©å—整体左移(跨页/同页公式自动改引用,`#REF!` 2→0)、中口径排名块 J..R â†’ A..I å‡æ­£ç¡®å¸¦å…¥äº§ç‰©ã€‚
@@ -392,6 +764,11 @@
- **2026-08-28 å¤æ ¸ã€Œç‚¹æ•°æ®å¯¼å…¥æµè§ˆå™¨å´©æºƒã€ï¼ˆæ‰“包测试环境,部分用户)**:重点排查 `GET /api/import/batches`——后端 limit ä¸Šé™ 200;全表 81 æ¡ã€error_detail æœ€é•¿ 2.6KB、全量响应约 17KB(前端只取 50 æ¡ï¼‰ï¼›å‰ç«¯è¡¨æ ¼ä»… 8 åˆ—短字段(errorDetail ä¸æ¸²æŸ“)、无轮询/定时器。**结论:接口不是崩溃根因**(若接口有问题,所有用户都会崩,而非"部分用户")。现象符合客户端环境差异(第三方扩展注入/旧显卡驱动/低配内存,与 08-27 è¿…雷扩展+Intel æ—§é©±åŠ¨ç»“è®ºä¸€è‡´ï¼‰ã€‚å»ºè®®å¯¹å´©æºƒç”¨æˆ·ä¾æ¬¡éªŒè¯ï¼šæ— ç—•æ¨¡å¼â†’æ¢ Edge→禁扩展,并看 chrome://crashes。
- ã€æ–°éœ€æ±‚·仅记录未实现·2026-08-28】增加**经济指标试算功能**:领导非常关注经济指标,**货运是大头**;常驻现场的所有人都要会计算货运相关数据(货运量/周转量等)。领导经常要求把某个数据「压一压」,今后考虑在软件里增加试算功能——可按需要调整/测算指标数值(如模拟下调某企业/某口径的货运量后看汇总与趋势影响),辅助现场人员快速试算与应对。
- ã€å¾…办·2026-10-03】**数值精度统一改造**:把既有业务表的数值列由 `double` æ”¹ä¸º `DECIMAL(30,15)`,Java å®žä½“字段由 `Double/Float` æ”¹ä¸º `BigDecimal`,与节假日表、观测站表统一口径(用户 2026-10-03 æ˜Žç¡®è¦æ±‚,择机实施)。
  - çŽ°çŠ¶ç›˜ç‚¹ï¼ˆtraffic_audit,2026-10-03 æŸ¥è¯¢ï¼‰ï¼š**16 å¼ è¡¨ã€å…± 228 ä¸ª double åˆ—**——`freight_turnover_import` 48、`city_bus_monthly` 34、`h2032_enterprise_monthly` 33、`city_taxi_monthly` 29、`wyc_order_monthly` 22、`h2031_enterprise_monthly` 18、`h204_vehicle_quarterly` 12、`scale_split_transport` 9、`investment_monthly` 5、`investment_system` 5、`wyc_total_monthly` 4、`h204_auth_vehicle` 2、`investment_project` 2、`passenger_individual_monthly` 2、`transport_auth_vehicle` 2、`vehicle_track_mileage` 1。
  - å·²æ˜¯ decimal å£å¾„、无需改动:`holiday_calendar` / `holiday_passenger_coefficient` / `holiday_passenger_flow` / `holiday_vehicle_flow` / `observation_station_flow`。
  - æ”¹é€ èŒƒå›´ä¸åªæ˜¯æ”¹åˆ—类型:实体类字段类型、导入解析(不要经 double ä¸­è½¬ï¼‰ã€è®¡ç®—与取整逻辑、报表导出格式化,以及存量数据的迁移脚本,都要一起改;改完要做逐表数值抽查。
## å…«ã€æ¯æ—¥äº¤æŽ¥çº¦å®š
1. æ–°å¯¹è¯ç¬¬ä¸€æ­¥ï¼šè¯» `AGENTS.md` + `HANDOFF.md`;涉及 AI åˆ†æž/审核时参考 `docs/` ä¸Žæ•°æ®åº“表结构。
2. æ¯å¤©ç»“束时:更新 `HANDOFF.md`(进展、坑、新需求、待办),再关对话。
deploy-templates/README-²¿Êð˵Ã÷.txt
@@ -1,12 +1,33 @@
TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp})
TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp},版本 v{version})
========================================================
【服务器上只要做这 3 æ­¥ã€‘(其它文件都不用动)
  ç¬¬ 1 æ­¥  æ”¹é…ç½®ï¼šç”¨è®°äº‹æœ¬æ‰“å¼€ backend\application.yml,把数据库地址/账号/密码
           ï¼ˆspring.datasource ä¸‹é¢ä¸‰é¡¹ï¼‰å’Œ jwt.secret æ”¹æˆè¿™å°æœåŠ¡å™¨çš„çœŸå®žå€¼ã€‚
  ç¬¬ 2 æ­¥  è£…服务:进入 backend æ–‡ä»¶å¤¹ï¼Œå³é”® install-service.cmd â†’「以管理员身份运行」
           ï¼ˆç›´æŽ¥åŒå‡»ä¹Ÿè¡Œï¼Œå®ƒä¼šè‡ªå·±å¼¹ç®¡ç†å‘˜ç¡®è®¤ï¼‰ã€‚脚本会注册开机自启服务并启动它。
  ç¬¬ 3 æ­¥  çœ‹ç»“果:脚本最后会打印 OK å’Œç½‘址;浏览器打开 http://<服务器IP>:8090
           ï¼ˆè´¦å· admin / admin123),能看到登录页就算成功。
  å“ªäº›è¦æ‹·ã€å“ªäº›ä¸ç”¨æ‹·ï¼š
    backend\   å¿…须整个目录一起拷(jar、application.yml、docs、web、服务文件都在里面),
               ä¸è¦åªæŒ‘几个文件拷。
    db\        åªæœ‰éœ€è¦åˆå§‹åŒ–/还原数据库时才用,平时可不拷。
    frontend\  ä¸ç”¨æ‹·ï¼ˆå‰ç«¯å·²å†…嵌在 jar é‡Œï¼Œ8090 ç«¯å£ç›´æŽ¥è®¿é—®ï¼‰ã€‚
    æœ¬æ–‡ä»¶      å¯æ‹·å¯ä¸æ‹·ï¼Œå°±æ˜¯è¿™ä»½è¯´æ˜Žä¹¦ã€‚
【目录结构】
  backend/
    traffic-audit-server-1.0.0-SNAPSHOT.jar  åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**)
    traffic-audit-server.jar                 åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**,固定文件名)
    docs/                                    æŠ¥è¡¨æ¨¡æ¿ä¸Žè¿è¡Œæ•°æ®ç›®å½•(必需,勿删)
    application.yml                          å¤–部配置(改这里即可,无需重新打包)
    start-backend.cmd / start-backend.sh     å¯åŠ¨è„šæœ¬
    start-backend.cmd / start-backend.sh     å‰å°å¯åŠ¨è„šæœ¬ï¼ˆäººå·¥è°ƒè¯•ç”¨ï¼Œå…³äº†çª—å£å°±åœï¼‰
    trafficAudit.exe                         WinSW æœåŠ¡åŒ…è£…å™¨ï¼ˆæŠŠ java å˜æˆ Windows æœåŠ¡ï¼Œå‹¿åˆ å‹¿æ”¹åï¼‰
    trafficAudit.xml                         WinSW æœåŠ¡é…ç½®ï¼ˆæœåŠ¡å/启动方式/日志/失败重启策略)
    install-service.cmd                      åŒå‡»å…¥å£ï¼šæ³¨å†Œå¼€æœºè‡ªå¯æœåŠ¡ï¼ˆä¼šè‡ªåŠ¨è¯·æ±‚ç®¡ç†å‘˜æƒé™ï¼‰
    install-service.ps1                      ä¸Šé¢é‚£ä¸ª cmd å®žé™…调用的脚本(注册/查看/重启/删除)
    start-backend-service.cmd                éžäº¤äº’启动器(无 pause、写 app.log,配合计划任务用)
    install-autostart.ps1                    å¤‡é€‰ï¼šæ³¨å†Œâ€œè®¡åˆ’任务”方式的开机自启
  frontend/
    dist/                                    å‰ç«¯æž„建产物(备用:想用 nginx æ‰˜ç®¡æ—¶ç”¨ï¼‰
    nginx.conf.example                       nginx é…ç½®ç¤ºä¾‹
@@ -15,6 +36,12 @@
    sql_city_bus.sql                         åŸŽå¸‚客运-公交月度表(必须执行)
    sql_city_taxi.sql                        åŸŽå¸‚客运-出租月度表/出租授权表(必须执行)
    sql_wyc.sql                              ç½‘约车全省总量表/订单分市州表(必须执行)
    sql_holiday.sql                          ä¸“项统计-重要节假日表(旧库缺表时执行)
    sql_holiday_decimal_migration.sql        èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_vehicle_adjustment_migration.sql å¾€å¹´å›½çœé“数据调整表(旧库升级)
    sql_observation_station.sql               è§‚测站分日调查数据表(新功能,旧库缺表时执行)
  README-部署说明.txt
【部署机环境要求】
@@ -23,12 +50,38 @@
  **不需要 Redis**(代码未使用;旧版说明里的 Redis æ˜¯é—留配置,可忽略)
  **不需要 nginx**(前端已内嵌进 jar,访问 8090 å³å¯ï¼›nginx ä»…为备选方案)
【操作系统兼容性】
  å¼€æœºè‡ªå¯ç”¨çš„æ˜¯ Windows æœåŠ¡ï¼Œè€Œâ€œè‡ªåŠ¨å¯åŠ¨ / å»¶è¿Ÿå¯åЍ / å¤±è´¥è‡ªåŠ¨é‡å¯â€æ˜¯ç³»ç»Ÿè‡ªå¸¦èƒ½åŠ›ï¼Œ
  ä»Ž Win7 SP1 åˆ° Win11、Server 2008 R2 åˆ° Server 2022/2025 æœºåˆ¶å®Œå…¨ä¸€æ ·ã€‚差别只在服务包装器
  trafficAudit.exe ä¾èµ–什么运行时:
  - æœ¬åŒ…内的 trafficAudit.exe æ˜¯ WinSW 2.12.0 çš„ x64 è‡ªåŒ…含构建(自带 .NET 6 è¿è¡Œæ—¶ï¼Œå·²æ ¡éªŒ SHA-256),
    **不需要另外安装 .NET Framework**;推荐用于 64 ä½ Win10 / Win11 / Server 2016 åŠä»¥ä¸Šã€‚
  - Win7 SP1 / Server 2008 R2 / Server 2012(R2):自包含版通常也能跑(Win7 SP1 éœ€å…ˆæ‰“ KB2533623);
    è‹¥æŠ¥ç¼ºå°‘ api-ms-win-*.dll ä¹‹ç±»ï¼Œæ¢æˆå®˜æ–¹ thin æž„建:把 WinSW.NET461.exe ä¸‹è½½åŽæ”¹åæˆ
    trafficAudit.exe è¦†ç›–(配置 trafficAudit.xml ä¸ç”¨æ”¹ï¼‰ï¼Œå®ƒéœ€ .NET Framework 4.6.1+:
      https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe
  - 32 ä½ç³»ç»Ÿï¼šæŠŠåŒ…装器换成 WinSW-x86.exe(同一下载页)。
  - Win7 / Server 2012 æœ¬èº«å·²è¿‡å¾®è½¯æ”¯æŒæœŸï¼Œæ–°æœåŠ¡å™¨å»ºè®®ç›´æŽ¥ä¸Š Server 2016 åŠä»¥ä¸Šã€‚
  - è„šæœ¬è¦æ±‚ï¼šinstall-service.ps1 åœ¨ Windows PowerShell 2.0+ ä¸‹å¯ç”¨ï¼ˆWin7 è‡ªå¸¦ 2.0,已做兼容);
    3.2 çš„计划任务脚本用到了 New-ScheduledTask* å‘½ä»¤ï¼Œéœ€è¦ Win8 / Server 2012 åŠä»¥ä¸Šã€‚
【从本版起:后端 jar ä½¿ç”¨å›ºå®šæ–‡ä»¶åã€‘
  backend\traffic-audit-server.jar
  - æ–°æœåŠ¡å™¨ï¼šæŒ‰ä¸‹é¢çš„æ™®é€šéƒ¨ç½²æ­¥éª¤æ‰§è¡Œå³å¯ã€‚
  - å·²å®‰è£…旧版服务的服务器(首次升级到本版):
      1) åœæ­¢æœåŠ¡ï¼šStop-Service TrafficAuditBackend
      2) å¤‡ä»½å¹¶ç¼–辑 backend\trafficAudit.xml,把 <arguments> é‡Œçš„æ—§ jar åæ”¹æˆ traffic-audit-server.jar
      3) æ›¿æ¢ backend\traffic-audit-server.jar,再执行 Start-Service TrafficAuditBackend
  - ä»¥åŽæ¯æ¬¡å‡çº§ï¼šåªæ›¿æ¢ backend\traffic-audit-server.jar,再重启服务;不再需要修改 trafficAudit.xml。
  - é¡µé¢ç™»å½•页和右上角显示的版本号与本发布包名称中的版本号一致。
【部署步骤】
  1. åˆå§‹åŒ–数据库(4 ä¸ªè„šæœ¬éƒ½è¦æ‰§è¡Œï¼Œé¡ºåºå¦‚ä¸‹ï¼‰
       mysql --default-character-set=utf8mb4 -u root -p < db/init.sql
       mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_city_bus.sql
       mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_city_taxi.sql
       mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_wyc.sql
     æ³¨ï¼šå…¨æ–°åº“çš„ init.sql å·²å«èŠ‚å‡æ—¥åŠç›¸å…³è¡¨ï¼›è‹¥æ˜¯åœ¨æ—©æœŸç‰ˆæœ¬åº“ä¸Šè¡¥èŠ‚å‡æ—¥æ¨¡å—ï¼Œå†æ‰§è¡Œ db/sql_holiday.sql,并按需执行迁移脚本(数值精度、环比基期、前一日配置、往年国省道数据调整、观测站分日调查数据)。迁移脚本均可重复执行。
     é¢„置账号:admin / admin123(首次登录后请改密码)
  2. æ”¹é…ç½® backend/application.yml
@@ -37,10 +90,47 @@
     - deepseek.api-key ï¼šéœ€è¦ AI åˆ†æžæ—¶å¡«ï¼Œä¸éœ€è¦å¯ç•™ç©º
     æ³¨ï¼šapplication.yml ä¸Ž jar åŒçº§å³ç”Ÿæ•ˆï¼Œä¸ç”¨é‡æ–°æ‰“包。
  3. å¯åŠ¨åŽç«¯ï¼ˆåœ¨ backend ç›®å½•下)
  3. å¯åŠ¨åŽç«¯ï¼ˆåœ¨ backend ç›®å½•下;这步只是手工试跑,关窗口就停,要开机自启请看 3.1)
     Windows:双击 start-backend.cmd
     Linux:  sh start-backend.sh
     æˆåŠŸæ ‡å¿—ï¼šå‡ºçŽ° "Started TrafficAuditApplication" ä¸Ž "Tomcat started on port(s): 8090"
  3.1 Windows å¼€æœºè‡ªå¯ï¼ˆæŽ¨èï¼šæ³¨å†Œæˆ Windows æœåŠ¡ï¼Œä¸€æ¬¡é…ç½®é•¿æœŸç”Ÿæ•ˆï¼‰
     å‰æï¼šbackend ç›®å½•下的 trafficAudit.exe / trafficAudit.xml / install-service.cmd / install-service.ps1
           éƒ½å·²éšæœ¬éƒ¨ç½²åŒ…提供,不需要另外下载 WinSW,也不需要给 java è£…别的东西。
     æœ€çœäº‹ï¼šåœ¨ backend ç›®å½•右键 install-service.cmd â†’「以管理员身份运行」
             ï¼ˆç›´æŽ¥åŒå‡»ä¹Ÿè¡Œï¼Œå®ƒä¼šè‡ªå·±å¼¹ UAC ç®¡ç†å‘˜ç¡®è®¤ï¼‰ã€‚
     ç­‰ä»·çš„æ‰‹å·¥æ–¹å¼ï¼ˆåœ¨ backend ç›®å½•下,用【管理员】PowerShell):
       powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
     æ•ˆæžœï¼šæ³¨å†Œ Windows æœåŠ¡ TrafficAuditBackend,开机自动启动(延迟启动,先等 MySQL å°±ç»ªï¼‰ï¼Œ
           ä¸éœ€è¦ä»»ä½•用户登录;java è¿›ç¨‹å¼‚常退出由服务管理器自动重启(10 ç§’ / 30 ç§’ / 60 ç§’递增重试,
           1 å°æ—¶åŽé‡è¯•计数清零)。服务以 LocalSystem èº«ä»½è¿è¡Œã€‚
     è„šæœ¬ä¼šè‡ªåŠ¨å®šä½ java.exe(JAVA_HOME â†’ PATH â†’ JavaSoft æ³¨å†Œè¡¨ â†’ Program Files),
     å¹¶æŠŠç»å¯¹è·¯å¾„写进 trafficAudit.xml(SYSTEM æœåŠ¡çš„ PATH ä¸Žç™»å½•用户不同,写绝对路径更稳)。
     å¸¸ç”¨å‚数:
       -Status                           æŸ¥çœ‹æœåŠ¡çŠ¶æ€ï¼ˆä¸éœ€è¦ç®¡ç†å‘˜ï¼‰
       -Restart                          é‡å¯æœåŠ¡        -Remove  åœæ­¢å¹¶åˆ é™¤æœåŠ¡
       -NoStart                          åªæ³¨å†Œä¸å¯åЍ    -JavaPath "D:\jdk8\bin\java.exe"  æ‰‹åŠ¨æŒ‡å®š JDK
     æ—¥å¿—:backend\logs\trafficAudit.out.log(运行输出)与 trafficAudit.err.log(错误输出),
           å•个文件 10MB,保留 10 ä¸ªæ»šåŠ¨æ–‡ä»¶ã€‚
     éªŒè¯ï¼šè„šæœ¬è£…å®Œä¼šè‡ªåŠ¨æŽ¢æµ‹ç«¯å£ï¼Œæ‰“å° OK + æœ¬æœº/对外网址;起不来时直接打印 logs\*.log æœ€åŽ 15 è¡Œï¼Œ
           æŒ‰æç¤ºæ”¹ application.yml åŽæ‰§è¡Œ install-service.ps1 -Restart å³å¯ã€‚也可手工用浏览器打开
           http://<部署机IP>:8090 ç¡®è®¤ã€‚
     æ³¨æ„ï¼š
       - å¼€æœºè‡ªå¯é æœåŠ¡çš„â€œè‡ªåŠ¨â€å¯åŠ¨ç±»åž‹ï¼›å¦‚æžœæŠŠå®ƒæ”¹æˆâ€œæ‰‹åŠ¨â€ï¼Œå°±ä¸ä¼šå†éšæœºå™¨å¯åŠ¨ã€‚
       - é‡å¤æ‰§è¡Œ install-service.ps1 æ˜¯å®‰å…¨çš„:会先停服务再重新注册并启动。
       - åŒä¸€å°æœºä¸è¦åŒæ—¶å¯ç”¨ä¸‹é¢çš„计划任务方式,两个进程会抢 8090 ç«¯å£ã€‚
       - å»ºè®®æŠŠéƒ¨ç½²åŒ…放在 D:\trafficAudit è¿™ç±»ç®€å•路径;放在某些用户目录下时 LocalSystem
         å¯èƒ½æ²¡æœ‰å†™æƒé™ï¼Œæ—¥å¿—会报权限错误。
  3.2 å¤‡é€‰ï¼šè®¡åˆ’任务方式(不想把后端装成服务时用,与 3.1 äºŒé€‰ä¸€ï¼‰
     åœ¨ backend ç›®å½•下,用【管理员】PowerShell æ‰§è¡Œï¼š
       powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1
     æ•ˆæžœï¼šæ³¨å†Œè®¡åˆ’任务 TrafficAuditBackend,开机延迟 60 ç§’后以 SYSTEM èº«ä»½è¿è¡Œ start-backend-service.cmd,
           ä¸éœ€è¦ç”¨æˆ·ç™»å½•,失败自动重试 3 æ¬¡ï¼Œè¿è¡Œæ—¥å¿—写在 backend\app.log。
     å¸¸ç”¨å‚数:-DelaySeconds 90(改延迟秒数)  -StartNow(注册后立即启动一次)
               -Status(查看状态)             -Remove(删除开机自启)
     æ³¨ï¼šå¯åŠ¨å™¨ä¼˜å…ˆç”¨ JAVA_HOME\bin\java.exe,找不到时回退 PATH;检测到 8090 å·²åœ¨ç›‘听则直接退出。
  4. è®¿é—®ç³»ç»Ÿ
     æµè§ˆå™¨æ‰“å¼€ http://<部署机IP>:8090  ï¼ˆå‰ç«¯å·²å†…嵌,页面与接口同源)
@@ -54,6 +144,10 @@
  æ³¨æ„ï¼šå…¨é‡ dump ä¼šå¸¦ä¸Šæœ¬æœºçš„账号密码,覆盖 init.sql é¢„置的 admin è´¦å·ã€‚
【常见问题】
  0. å¯åŠ¨æŠ¥ java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO):
     â€œusing password: NO” = åŽç«¯æ‹¿åˆ°çš„æ•°æ®åº“密码是空串(不是密码错)。
     æ£€æŸ¥ backend/application.yml æ˜¯å¦ä¸Ž jar åŒçº§ä¸” spring.datasource.password å·²å¡«çœŸå®žå¯†ç ï¼›
     è‹¥æœªæ”¹ application.yml,也可用环境变量 TRAFFIC_DB_PASSWORD ä»£æ›¿ã€‚改完重启即可,无需重新打包。
  1. å¯åŠ¨æŠ¥ã€Œæœªæ‰¾åˆ°æ•°æ®ç›®å½• docs」/ å¯¼å‡ºæŠ¥ã€Œæœªæ‰¾åˆ°æ¨¡æ¿æ–‡ä»¶ã€ï¼š
     å¿…须在 backend ç›®å½•下启动(jar ä¸Ž docs åŒçº§ï¼‰ï¼Œä¸è¦åªæ‹· jar。
  2. æŠ¥è¡¨å¯¼å‡ºæŠ¥ã€Œæœªæ‰¾åˆ°æ±‡æ€»è¡¨æ¯ç‰ˆã€ŠYYYYå¹´M月道路运输量汇总表.xlsx》」:
@@ -61,7 +155,15 @@
  3. 8090 ç«¯å£è¢«å ç”¨ï¼šæ”¹ application.yml çš„ server.port åŽé‡å¯ã€‚
  4. é¡µé¢æ‰“开是空白:确认访问的是 http://<IP>:8090 æ ¹è·¯å¾„,且浏览器强制刷新(Ctrl+F5)。
  5. ä¸Šä¼ å¤§æ–‡ä»¶å¤±è´¥ï¼šåŽç«¯é™åˆ¶ 100MB,已配置;如前面还挂了 nginx,需同步放大 client_max_body_size。
  6. æœåŠ¡å·²æ³¨å†Œä½† 8090 æ‰“不开:看 backend\logs\trafficAudit.err.log çš„æŠ¥é”™å†…容;
     æœåŠ¡æ–¹å¼ä¸‹ç”¨çš„æ˜¯ backend\application.yml,password å¿…须是真实 MySQL å¯†ç ï¼ˆå ä½æ–‡å­—会导致 using password: NO)。
  7. æœåŠ¡æŠ¥â€œæ‹’ç»è®¿é—®â€æˆ–å†™ä¸äº†æ—¥å¿—ï¼šéƒ¨ç½²ç›®å½•æƒé™ä¸è¶³ã€‚
     æŠŠéƒ¨ç½²åŒ…放到 D:\trafficAudit è¿™ç±»ç®€å•路径,或给 LocalSystem æŽˆäºˆè¯¥ç›®å½•的读写权限。
  8. æƒ³ä¸´æ—¶å–消开机自启但保留服务:Set-Service TrafficAuditBackend -StartupType Manual
【停止服务】
  Windows:关闭 start-backend.cmd çª—口
  Linux:  Ctrl+C,或 kill å¯¹åº” java è¿›ç¨‹ï¼ˆps -ef | grep java)
  æœåŠ¡æ–¹å¼ï¼š Stop-Service TrafficAuditBackend        ï¼ˆé‡æ–°å¯åŠ¨ï¼šStart-Service TrafficAuditBackend)
             å½»åº•删除开机自启:install-service.ps1 -Remove
  å‰å°å¯åŠ¨ï¼š å…³é—­ start-backend.cmd çª—口
  è®¡åˆ’任务: Stop-ScheduledTask -TaskName TrafficAuditBackend
  Linux:    Ctrl+C,或 kill å¯¹åº” java è¿›ç¨‹ï¼ˆps -ef | grep java)
deploy-templates/backend/application.yml
@@ -3,15 +3,19 @@
# éƒ¨ç½²åˆ°æ–°æœºå™¨åŽï¼Œè¯·**至少**修改下面标注 TODO çš„三处数据库配置!
# ============================================================
server:
  port: 8080
  port: 8090            # å•端口模式:后端 8090 åŒæ—¶æ‰˜ç®¡å‰ç«¯é¡µé¢ï¼ˆä¸è¦æ”¹æˆ 8080)
app:
  captcha:
    required: false        # ç”Ÿäº§çŽ¯å¢ƒå¦‚éœ€å›¾å½¢éªŒè¯ç ï¼Œæ”¹ä¸º true
spring:
  web:
    resources:
      # å•端口模式:优先读取 jar åŒçº§ web\ ç›®å½•(打包时由前端 dist è‡ªåŠ¨ç”Ÿæˆï¼‰
      static-locations: file:./web/,classpath:/static/
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/traffic_audit?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
    username: root                     # TODO æ”¹æˆä½ çš„ MySQL è´¦å·
    password: è¯·æ”¹æˆä½ çš„æ•°æ®åº“密码      # TODO æ”¹æˆä½ çš„ MySQL å¯†ç 
    password: è¯·æ”¹æˆä½ çš„æ•°æ®åº“密码      # TODO æ”¹æˆä½ çš„ MySQL å¯†ç ï¼›ç•™ç©ºä¼šæŠ¥ Access denied ... (using password: NO)
    driver-class-name: com.mysql.cj.jdbc.Driver
  servlet:
    multipart:
deploy-templates/backend/install-autostart.ps1
New file
@@ -0,0 +1,109 @@
<#
  Register / remove / inspect a Windows Scheduled Task that starts the TrafficAudit
  backend at machine boot, with no user session required.
  Run from an ELEVATED PowerShell in the backend folder (jar + application.yml +
  this script + start-backend-service.cmd):
    powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1
    powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -DelaySeconds 90
    powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -StartNow
    powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -Status
    powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -Remove
  What it does:
    - trigger  : AtStartup, delayed by -DelaySeconds (default 60s, so MySQL is up first)
    - principal: SYSTEM, highest privileges, "run whether user is logged on or not"
    - settings : no execution time limit, start when available, restart 3x / 1 min on failure
    - action   : cmd.exe /c start-backend-service.cmd (logs to app.log next to the jar)
  Notes:
    - Re-running without -Remove is idempotent (-Force overwrites the task).
    - Start/stop manually with: Start-ScheduledTask / Stop-ScheduledTask -TaskName <name>
    - Keep this file ASCII-only: Windows PowerShell 5.1 reads a BOM-less .ps1 as ANSI.
#>
param(
  [string]$TaskName = 'TrafficAuditBackend',
  [int]$DelaySeconds = 60,
  [switch]$Remove,
  [switch]$Status,
  [switch]$StartNow
)
$ErrorActionPreference = 'Stop'
$root     = Split-Path -Parent $MyInvocation.MyCommand.Path
$launcher = Join-Path $root 'start-backend-service.cmd'
$logFile  = Join-Path $root 'app.log'
function Assert-Admin {
  $identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
  $principal = New-Object Security.Principal.WindowsPrincipal($identity)
  if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw 'This script must run from an elevated (Run as Administrator) PowerShell.'
  }
}
if ($Status) {
  $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
  if (-not $task) { Write-Host "Scheduled task '$TaskName' is not registered."; exit 1 }
  $task | Select-Object TaskName, TaskPath, State | Format-List
  Get-ScheduledTaskInfo -TaskName $TaskName |
    Select-Object LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns | Format-List
  exit 0
}
if ($Remove) {
  Assert-Admin
  if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
    Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
    Write-Host "Removed scheduled task '$TaskName'."
  } else {
    Write-Host "Scheduled task '$TaskName' was not registered; nothing to remove."
  }
  exit 0
}
Assert-Admin
if (-not (Test-Path $launcher)) { throw "Missing launcher: $launcher" }
if (-not (Test-Path (Join-Path $root 'traffic-audit-server.jar'))) {
  Write-Host "WARN: jar not found in $root - the task will be registered, but startup will fail until the jar is there."
}
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c "' + $launcher + '"') -WorkingDirectory $root
$trigger = New-ScheduledTaskTrigger -AtStartup
if ($DelaySeconds -gt 0) { $trigger.Delay = 'PT' + $DelaySeconds + 'S' }
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
  -AllowStartIfOnBatteries `
  -DontStopIfGoingOnBatteries `
  -StartWhenAvailable `
  -MultipleInstances IgnoreNew `
  -RestartCount 3 `
  -RestartInterval (New-TimeSpan -Minutes 1) `
  -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask `
  -TaskName    $TaskName `
  -Action      $action `
  -Trigger     $trigger `
  -Principal   $principal `
  -Settings    $settings `
  -Description 'TrafficAudit backend auto-start at machine boot (script-managed).' `
  -Force | Out-Null
Write-Host "Registered scheduled task '$TaskName'."
Write-Host "  Trigger  : At startup, +${DelaySeconds}s"
Write-Host "  Principal: SYSTEM (highest privileges, no logon required)"
Write-Host "  Launcher : $launcher"
Write-Host "  Log      : $logFile"
Write-Host ''
Write-Host "Service address after boot: http://<server-ip>:8090"
Write-Host "Manage it with: -Status | -StartNow | -Remove"
if ($StartNow) {
  Start-ScheduledTask -TaskName $TaskName
  Write-Host "Started task '$TaskName' now."
}
deploy-templates/backend/install-service.cmd
New file
@@ -0,0 +1,49 @@
@echo off
rem ============================================================
rem  Double-click entry point for a fresh Windows server:
rem  installs the TrafficAudit backend as a Windows service
rem  (auto-start at boot) and then verifies it comes up.
rem
rem  It asks for Administrator rights (UAC) and then runs
rem  install-service.ps1 from this folder.
rem
rem  If double-clicking does nothing, right-click this file and
rem  choose "Run as administrator".
rem
rem  ASCII-only on purpose: cmd.exe reads this file with the OEM
rem  code page.
rem ============================================================
setlocal
cd /d "%~dp0"
if not exist "%~dp0trafficAudit.exe" (
  echo [ERROR] trafficAudit.exe was not found next to this script.
  echo         Copy the WHOLE backend folder to the server, do not
  echo         pick single files out of it.
  echo.
  pause
  exit /b 1
)
rem S-1-16-12288 = High Mandatory Level, i.e. this window is already elevated.
whoami /groups | findstr /C:"S-1-16-12288" >nul 2>&1
if errorlevel 1 (
  echo Administrator rights are required. A UAC prompt will appear now.
  powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
  if errorlevel 1 (
    echo.
    echo [ERROR] Automatic elevation failed.
    echo         Right-click install-service.cmd and choose
    echo         "Run as administrator" instead.
    echo.
    pause
  )
  exit /b
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-service.ps1" %*
echo.
echo ------------------------------------------------------------
echo  Finished. Press any key to close this window.
pause >nul
deploy-templates/backend/install-service.ps1
New file
@@ -0,0 +1,330 @@
<#
  Install / update / remove the TrafficAudit backend as a Windows service.
  The service is a WinSW (Windows Service Wrapper) host: trafficAudit.exe reads
  trafficAudit.xml and runs "java -jar traffic-audit-server.jar".
  Because it is a real Windows service it starts at boot with no user logged on,
  is listed in services.msc / Get-Service, and is restarted by the Service
  Control Manager when the java process dies.
  Run from an ELEVATED PowerShell in the backend folder
  (the folder that contains the jar, application.yml, docs, web and trafficAudit.exe):
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Status
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Restart
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Remove
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -NoStart
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -JavaPath 'D:\jdk8\bin\java.exe'
  What it does:
    1. locates java.exe (JAVA_HOME -> PATH -> JavaSoft registry -> Program Files)
    2. writes that absolute path into trafficAudit.xml (<executable>)
    3. installs or reinstalls the service and starts it (unless -NoStart)
  Notes:
    - Re-running is idempotent: an existing service is stopped and reinstalled.
    - Logs: logs\trafficAudit.out.log / logs\trafficAudit.err.log (10 MB x 10 roll).
    - Do NOT enable the scheduled-task autostart (install-autostart.ps1) at the
      same time: two hosts would race for the same jar and port 8090.
    - Keep this file ASCII-only; Windows PowerShell 5.1 reads a BOM-less .ps1 as ANSI.
#>
param(
  [switch]$Remove,
  [switch]$Status,
  [switch]$Restart,
  [switch]$NoStart,
  [string]$JavaPath
)
$ErrorActionPreference = 'Stop'
$root      = Split-Path -Parent $MyInvocation.MyCommand.Path
$wrapper   = Join-Path $root 'trafficAudit.exe'
$config    = Join-Path $root 'trafficAudit.xml'
$appConfig = Join-Path $root 'application.yml'
$serviceId = 'TrafficAuditBackend'
# Any terminating error below lands here instead of dumping a raw stack trace
# at the operator. The line number is kept so a copy of this window is enough
# to diagnose a report.
trap {
  Write-Host ''
  Write-Host '=============================================================='
  Write-Host ' ERROR: the installer stopped before finishing.'
  Write-Host ('        ' + $_.Exception.Message)
  Write-Host ('        at line ' + $_.InvocationInfo.ScriptLineNumber + ' of install-service.ps1')
  Write-Host ' The service may not be installed. Send this window text to the'
  Write-Host ' developer if the reason is not obvious.'
  Write-Host '=============================================================='
  exit 1
}
function Assert-Admin {
  $identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
  $principal = New-Object Security.Principal.WindowsPrincipal($identity)
  if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw 'This script must run from an elevated (Run as Administrator) PowerShell.'
  }
}
function Resolve-JavaPath {
  param([string]$Explicit)
  if ($Explicit) {
    if (-not (Test-Path $Explicit)) { throw "JavaPath does not exist: $Explicit" }
    return (Resolve-Path $Explicit).Path
  }
  if ($env:JAVA_HOME) {
    $candidate = Join-Path $env:JAVA_HOME 'bin\java.exe'
    if (Test-Path $candidate) { return (Resolve-Path $candidate).Path }
  }
  $onPath = Get-Command java.exe -ErrorAction SilentlyContinue
  if ($onPath -and $onPath.Source) { return $onPath.Source }
  # A SYSTEM service inherits the machine PATH, which often lacks the JDK.
  # Fall back to the install locations used by Oracle / OpenJDK installers.
  foreach ($reg in @('HKLM:\SOFTWARE\JavaSoft\JDK', 'HKLM:\SOFTWARE\JavaSoft\Java Development Kit')) {
    if (-not (Test-Path $reg)) { continue }
    foreach ($ver in (Get-ChildItem $reg -ErrorAction SilentlyContinue | Sort-Object Name -Descending)) {
      $home = (Get-ItemProperty -Path $ver.PSPath -Name JavaHome -ErrorAction SilentlyContinue).JavaHome
      if ($home) {
        $candidate = Join-Path $home 'bin\java.exe'
        if (Test-Path $candidate) { return (Resolve-Path $candidate).Path }
      }
    }
  }
  $roots = @($env:ProgramFiles)
  if (${env:ProgramFiles(x86)}) { $roots += ${env:ProgramFiles(x86)} }
  foreach ($base in $roots) {
    foreach ($vendor in @('Java', 'Eclipse Adoptium', 'AdoptOpenJDK', 'Microsoft')) {
      $hit = Get-ChildItem -Path (Join-Path $base ($vendor + '\*\bin\java.exe')) -ErrorAction SilentlyContinue |
             Sort-Object FullName -Descending | Select-Object -First 1
      if ($hit) { return $hit.FullName }
    }
  }
  throw 'java.exe not found. Install a JDK 8 and re-run, or pass -JavaPath "D:\path\to\bin\java.exe".'
}
function Set-WrapperJavaPath {
  param([string]$JavaExe)
  if (-not (Test-Path $config)) { throw "Missing config file: $config" }
  # Edit through the DOM: a regex would also hit the executable mention inside
  # the header comment and would mangle the whole document.
  $doc = New-Object System.Xml.XmlDocument
  $doc.PreserveWhitespace = $true
  $doc.Load($config)
  $node = $doc.SelectSingleNode('/service/executable')
  if (-not $node) { throw "No /service/executable element found in $config" }
  if ($node.InnerText -eq $JavaExe) {
    Write-Host "Config already points at $JavaExe"
    return
  }
  $node.InnerText = $JavaExe
  $settings = New-Object System.Xml.XmlWriterSettings
  $settings.Encoding = New-Object System.Text.UTF8Encoding $false
  $settings.Indent = $true
  $settings.IndentChars = '  '
  $writer = [System.Xml.XmlWriter]::Create($config, $settings)
  try { $doc.Save($writer) } finally { $writer.Close() }
  Write-Host "Updated $config -> $JavaExe"
}
function Get-ServiceState {
  Get-Service -Name $serviceId -ErrorAction SilentlyContinue
}
function Test-LocalPort {
  param([int]$Port, [string]$TargetHost = '127.0.0.1')
  # Probe TCP instead of HTTP: Invoke-WebRequest does not exist on Windows
  # PowerShell 2 (Windows 7), and this works on every PowerShell version.
  $client = New-Object System.Net.Sockets.TcpClient
  try {
    $async = $client.BeginConnect($TargetHost, $Port, $null, $null)
    if ($async.AsyncWaitHandle.WaitOne(1000, $false) -and $client.Connected) { return $true }
    return $false
  } catch {
    return $false
  } finally {
    $client.Close()
  }
}
function Test-AppConfigPassword {
  param([string]$Path)
  # Returns $true when the datasource password looks usable. Prints a WARN and
  # returns $false for the states that break startup with
  # "Access denied ... (using password: NO)": missing file / empty value / placeholder.
  if (-not (Test-Path -LiteralPath $Path)) {
    Write-Host 'WARN: application.yml is missing next to the jar.'
    Write-Host '      The built-in password placeholder resolves to an empty string and startup will fail with'
    Write-Host "      Java SQLException: Access denied for user 'root'@'localhost' (using password: NO)."
    return $false
  }
  $line = Select-String -LiteralPath $Path -Pattern '^\s*password\s*:' | Select-Object -First 1
  if (-not $line) {
    Write-Host "WARN: no 'password:' entry found in application.yml; check the datasource block."
    return $false
  }
  # Trim() takes a single char, so strip whitespace, then one quote kind at a
  # time. Passing a 2-char string here throws
  # "cannot convert value ... to type System.Char" and aborts the script.
  $value = $line.Line -replace '^\s*password\s*:\s*', ''
  $value = $value.Trim()
  $value = $value.Trim("'")
  $value = $value.Trim('"')
  if ($value -eq '') {
    Write-Host "WARN: spring.datasource.password is empty in application.yml; startup may fail with '(using password: NO)'."
    return $false
  }
  if ($value -match '[^\x00-\x7F]') {
    Write-Host 'WARN: the datasource password contains non-ASCII characters.'
    Write-Host '      If it is still the Chinese placeholder text, replace it with the real MySQL password first.'
    return $false
  }
  return $true
}
# ---------- read-only status: no elevation needed ----------
if ($Status) {
  $service = Get-ServiceState
  if (-not $service) {
    Write-Host "Service '$serviceId' is NOT installed."
    exit 1
  }
  $service | Select-Object Name, DisplayName, Status, StartType | Format-List
  # Get-CimInstance needs PowerShell 3+; fall back to WMI for Windows 7 / PowerShell 2.
  $detail = $null
  try {
    $detail = Get-CimInstance Win32_Service -Filter "Name='$serviceId'" -ErrorAction Stop
  } catch {
    $detail = Get-WmiObject Win32_Service -Filter "Name='$serviceId'" -ErrorAction SilentlyContinue
  }
  if ($detail) { $detail | Select-Object ProcessId, StartMode, DelayedAutoStart, PathName | Format-List }
  if (Test-Path $wrapper) { & $wrapper status }
  exit 0
}
if ($Restart) {
  Assert-Admin
  if (-not (Get-ServiceState)) { throw "Service '$serviceId' is not installed; run this script without -Restart first." }
  Write-Host "Restarting service '$serviceId'..."
  Restart-Service -Name $serviceId -Force
  Start-Sleep -Seconds 3
  Get-ServiceState | Select-Object Name, Status | Format-List
  exit 0
}
if ($Remove) {
  Assert-Admin
  if (-not (Test-Path $wrapper)) { throw "Missing wrapper executable: $wrapper" }
  if (Get-ServiceState) {
    Write-Host "Stopping and removing service '$serviceId'..."
    & $wrapper stop | Out-Null
    Start-Sleep -Seconds 3
    & $wrapper uninstall | Out-Null
    Write-Host "Removed service '$serviceId'. Files in $root were left untouched."
  } else {
    Write-Host "Service '$serviceId' is not installed; nothing to remove."
  }
  exit 0
}
# ---------- install / reinstall ----------
Assert-Admin
if (-not (Test-Path $wrapper)) {
  throw "Missing wrapper executable: $wrapper`nRebuild the deploy package with pack-deploy.ps1 (it provides WinSW), or copy WinSW-x64.exe here renamed to trafficAudit.exe."
}
$passwordOk = Test-AppConfigPassword -Path $appConfig
$java = Resolve-JavaPath -Explicit $JavaPath
Set-WrapperJavaPath -JavaExe $java
$existing = Get-ServiceState
if ($existing) {
  Write-Host "Service '$serviceId' already exists (status: $($existing.Status)); reinstalling..."
  if ($existing.Status -ne 'Stopped') {
    & $wrapper stop | Out-Null
    Start-Sleep -Seconds 3
  }
  & $wrapper uninstall | Out-Null
  Start-Sleep -Seconds 2
}
Write-Host "Installing service '$serviceId'..."
& $wrapper install
if ($LASTEXITCODE -ne 0) { throw "WinSW install failed with exit code $LASTEXITCODE" }
if (-not $NoStart) {
  Write-Host "Starting service '$serviceId' (auto-start at boot is enabled)..."
  & $wrapper start
  Start-Sleep -Seconds 5
  $service = Get-ServiceState
  $service | Select-Object Name, DisplayName, Status, StartType | Format-List
  # Read the port from application.yml so the check matches the real config.
  $port = 8090
  if (Test-Path $appConfig) {
    $portLine = Select-String -LiteralPath $appConfig -Pattern '^\s*port\s*:' | Select-Object -First 1
    if ($portLine -and $portLine.Line -match '(\d{2,5})') { $port = [int]$matches[1] }
  }
  $ready = $false
  for ($i = 1; $i -le 30; $i++) {
    if (Test-LocalPort -Port $port) { $ready = $true; break }
    Start-Sleep -Seconds 2
    $state = Get-ServiceState
    if ($state -and $state.Status -ne 'Running') { break }
  }
  $logDir = Join-Path $root 'logs'
  Write-Host ''
  if ($ready) {
    Write-Host "OK  : the backend is listening on port $port."
    Write-Host "      On this server   : http://127.0.0.1:$port"
    Write-Host "      For other people : http://<server-ip>:$port"
  } else {
    Write-Host "WARN: nothing is listening on port $port yet - startup is still running, or it failed."
    Write-Host "      Fix application.yml / MySQL, then run: .\install-service.ps1 -Restart"
    foreach ($name in @('trafficAudit.err.log', 'trafficAudit.out.log')) {
      $logFile = Join-Path $logDir $name
      if (Test-Path -LiteralPath $logFile) {
        Write-Host ''
        Write-Host "---- last 15 lines of $name ----"
        Get-Content -LiteralPath $logFile -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host "  $_" }
      }
    }
  }
  if (-not $passwordOk) {
    Write-Host ''
    Write-Host '=============================================================='
    Write-Host ' IMPORTANT: backend\application.yml still looks unset.'
    Write-Host ' Put the real MySQL url / username / password in it, then run'
    Write-Host '   .\install-service.ps1 -Restart'
    Write-Host '=============================================================='
  }
  Write-Host ''
  Write-Host "Logs  : $logDir\trafficAudit.out.log / trafficAudit.err.log"
  Write-Host "Manage: .\install-service.ps1 -Status | -Restart | -Remove"
  Write-Host "        Stop-Service $serviceId | Start-Service $serviceId"
  Write-Host ''
  Write-Host 'This service starts automatically on every boot (Automatic + delayed start).'
}
deploy-templates/backend/start-backend-service.cmd
New file
@@ -0,0 +1,49 @@
@echo off
rem ============================================================
rem  Non-interactive launcher for Task Scheduler / boot auto-start.
rem  Differences from start-backend.cmd (which is meant for a human):
rem    - no "pause" at the end (nobody can press a key during boot)
rem    - all output goes to app.log next to this file
rem    - resolves java via JAVA_HOME first, then PATH
rem    - exits 0 immediately if port 8090 is already listening
rem  ASCII-only on purpose: cmd.exe reads this file with the OEM
rem  code page, so non-ASCII text here would be garbled.
rem  Registered by install-autostart.ps1; run under the account that
rem  owns the deployment folder (SYSTEM by default).
rem ============================================================
setlocal
cd /d "%~dp0"
set "APP_JAR=traffic-audit-server.jar"
set "LOG=%~dp0app.log"
netstat -ano | findstr /C:":8090" | findstr /I "LISTENING" >nul 2>nul
if not errorlevel 1 (
  echo [%date% %time%] port 8090 is already listening; nothing to do. >> "%LOG%"
  exit /b 0
)
if not exist "%~dp0%APP_JAR%" (
  echo [%date% %time%] ERROR: %APP_JAR% not found in "%CD%". >> "%LOG%"
  exit /b 1
)
set "JAVA_EXE="
if defined JAVA_HOME if exist "%JAVA_HOME%\bin\java.exe" set "JAVA_EXE=%JAVA_HOME%\bin\java.exe"
if not defined JAVA_EXE for %%J in (java.exe) do set "JAVA_EXE=%%~$PATH:J"
if not defined JAVA_EXE (
  echo [%date% %time%] ERROR: java.exe not found. Install JDK 8 or set JAVA_HOME / PATH. >> "%LOG%"
  exit /b 1
)
if not exist "%~dp0application.yml" (
  echo [%date% %time%] WARN: application.yml is missing next to the jar. >> "%LOG%"
  echo [%date% %time%]       The DB password placeholder will resolve to empty and startup will fail with >> "%LOG%"
  echo [%date% %time%]       "Access denied ... (using password: NO)". >> "%LOG%"
)
echo [%date% %time%] starting backend: "%JAVA_EXE%" -jar "%APP_JAR%"  (cwd "%CD%") >> "%LOG%"
"%JAVA_EXE%" -jar "%~dp0%APP_JAR%" >> "%LOG%" 2>&1
set "RC=%ERRORLEVEL%"
echo [%date% %time%] backend exited with code %RC%. >> "%LOG%"
endlocal & exit /b %RC%
deploy-templates/backend/start-backend.cmd
@@ -7,10 +7,20 @@
  pause
  exit /b 1
)
if not exist "%~dp0application.yml" (
  echo [WARN] æœªæ‰¾åˆ° %~dp0application.yml
  echo        æ²¡æœ‰å¤–置配置时,jar å†…置密码占位符会解析为空,
  echo        å¯åŠ¨å°†æŠ¥ java.sql.SQLException: Access denied ... ^(using password: NO^)
)
findstr /C:"请改成你的数据库密码" "%~dp0application.yml" >nul 2>nul
if not errorlevel 1 (
  echo [WARN] application.yml é‡Œ spring.datasource.password è¿˜æ˜¯å ä½æ–‡å­—,
  echo        è¯·å…ˆæ”¹æˆéƒ¨ç½²æœºçœŸå®žçš„ MySQL å¯†ç å†å¯åŠ¨ã€‚
)
echo ============================================
echo  TrafficAudit å¯åŠ¨ä¸­ï¼šhttp://localhost:8090
echo  å‰ç«¯å·²å†…嵌,直接访问 8090 å³å¯ï¼ˆä¸éœ€è¦ nginx)
echo  æ—¥å¿—显示在本窗口,关闭窗口即停止服务
echo ============================================
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
java -jar traffic-audit-server.jar
pause
deploy-templates/backend/start-backend.sh
@@ -1,4 +1,10 @@
#!/bin/sh
cd "$(dirname "$0")" || exit 1
if [ ! -f application.yml ]; then
  echo "[WARN] æœªæ‰¾åˆ° application.yml(jar åŒçº§ï¼‰ï¼šæ•°æ®åº“密码会解析为空,可能报 Access denied ... (using password: NO)"
fi
if grep -q "请改成你的数据库密码" application.yml 2>/dev/null; then
  echo "[WARN] application.yml é‡Œ spring.datasource.password è¿˜æ˜¯å ä½æ–‡å­—,请先改成真实 MySQL å¯†ç "
fi
echo "TrafficAudit å¯åŠ¨ä¸­ï¼šhttp://localhost:8090 ï¼ˆCtrl+C åœæ­¢ï¼‰"
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar 2>&1 | tee app.log
java -jar traffic-audit-server.jar 2>&1 | tee app.log
deploy-templates/backend/trafficAudit.xml
New file
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
  WinSW (Windows Service Wrapper) configuration for the TrafficAudit backend.
  Layout requirement: this file MUST stay in the same folder as the wrapper
  executable named trafficAudit.exe, the application jar, application.yml,
  docs\ and web\. WinSW looks for a config file whose name matches the
  executable (trafficAudit.exe -> trafficAudit.xml).
  %BASE% is expanded by WinSW to the folder that holds this file, so the
  service always runs with that folder as its working directory.
  The executable element below is rewritten with an absolute java.exe path by
  install-service.ps1 (a SYSTEM service does not inherit the interactive
  user PATH, so relying on PATH alone is fragile).
-->
<service>
  <id>TrafficAuditBackend</id>
  <name>TrafficAudit Backend</name>
  <description>TrafficAudit freight statistics audit backend (Spring Boot, HTTP port 8090).</description>
  <executable>java.exe</executable>
  <arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"</arguments>
  <workingdirectory>%BASE%</workingdirectory>
  <!-- Start at boot, but delayed so a local MySQL service is ready first. -->
  <startmode>Automatic</startmode>
  <delayedAutoStart>true</delayedAutoStart>
  <!-- Restart on crash: quickly at first, then back off. Counter resets after 1 hour. -->
  <onfailure action="restart" delay="10 sec"/>
  <onfailure action="restart" delay="30 sec"/>
  <onfailure action="restart" delay="60 sec"/>
  <resetfailure>1 hour</resetfailure>
  <stoptimeout>30 sec</stoptimeout>
  <logpath>%BASE%\logs</logpath>
  <log mode="roll-by-size">
    <sizeThreshold>10240</sizeThreshold>
    <keepFiles>10</keepFiles>
  </log>
  <outfile>trafficAudit.out.log</outfile>
  <errfile>trafficAudit.err.log</errfile>
</service>
deploy-templates/db/init.sql
@@ -399,6 +399,8 @@
    region_code VARCHAR(20) COMMENT '所属地区',
    enterprise_code VARCHAR(50) COMMENT '企业代码',
    enterprise_name VARCHAR(200) COMMENT '企业名称',
    unified_credit_code VARCHAR(50) COMMENT '统一社会信用代码',
    report_unit VARCHAR(200) COMMENT '填报单位',
    vehicle_total INT COMMENT '客运车辆_总计',
    seat_total INT COMMENT '核定载客位_总计',
    vehicle_schedule INT COMMENT '车辆数_客运班车(含定线旅游客运客车)',
@@ -617,3 +619,84 @@
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_type_period (report_type, report_period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报表期审核通过标记';
-- ============================================
-- åä¸€ã€ä¸“项统计-重要节假日
-- ============================================
-- èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
DROP TABLE IF EXISTS holiday_calendar;
CREATE TABLE holiday_calendar (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  holiday_type VARCHAR(32) NOT NULL COMMENT '节假日分类:SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
  holiday_date DATE NOT NULL COMMENT '节假日具体日期',
  day_index INT NOT NULL COMMENT '0=前一日,1..N=假期第N日',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
  UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
  KEY idx_holiday_calendar_date (holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='重要节假日日期配置';
-- èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
DROP TABLE IF EXISTS holiday_passenger_flow;
CREATE TABLE holiday_passenger_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:RAIL/CIVIL_AVIATION/ROAD/WATER',
  passenger_count DECIMAL(30,15) NOT NULL COMMENT '发送旅客数(万人次)',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
  KEY idx_holiday_passenger_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输营业性客流情况';
-- èŠ‚å‡æ—¥ç»¼åˆè¿è¾“éžè¥ä¸šæ€§è½¦æµ
DROP TABLE IF EXISTS holiday_vehicle_flow;
CREATE TABLE holiday_vehicle_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  road_category VARCHAR(32) NOT NULL COMMENT '道路分类:EXPRESSWAY/NATIONAL_PROVINCIAL',
  vehicle_type VARCHAR(32) NOT NULL COMMENT '车型分类:SMALL_PASSENGER/MEDIUM_SMALL_PASSENGER',
  vehicle_count DECIMAL(30,15) NOT NULL COMMENT '车流量(万辆次)',
  comparison_base DECIMAL(30,15) NULL COMMENT '普通国省道环比基期(万辆次,仅当年使用)',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输非营业性车流情况';
-- å¾€å¹´å›½çœé“车流量调整
DROP TABLE IF EXISTS holiday_vehicle_adjustment;
CREATE TABLE holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT '调整后普通国省道车流量(万辆次)',
  reason VARCHAR(500) NOT NULL COMMENT '调整原因',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='往年国省道车流量调整';
-- å®¢è¿ç³»æ•°é…ç½®
DROP TABLE IF EXISTS holiday_passenger_coefficient;
CREATE TABLE holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  date_category VARCHAR(32) NOT NULL COMMENT '日期分类:HOLIDAY/NON_HOLIDAY',
  coefficient DECIMAL(30,15) NOT NULL COMMENT '客运系数',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_coefficient (year, date_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客运系数配置';
deploy-templates/db/sql_holiday.sql
New file
@@ -0,0 +1,67 @@
-- Holiday statistics module. Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS holiday_calendar (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  holiday_type VARCHAR(32) NOT NULL,
  holiday_date DATE NOT NULL,
  -- 0 is the configured previous day; normal holiday days are 1..N.
  day_index INT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
  UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
  KEY idx_holiday_calendar_date (holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  transport_mode VARCHAR(32) NOT NULL,
  passenger_count DECIMAL(30,15) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
  KEY idx_holiday_passenger_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  road_category VARCHAR(32) NOT NULL,
  vehicle_type VARCHAR(32) NOT NULL,
  vehicle_count DECIMAL(30,15) NOT NULL,
  comparison_base DECIMAL(30,15) NULL COMMENT '普通国省道环比基期(万辆次,仅当年使用)',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  adjusted_value DECIMAL(30,15) NOT NULL,
  reason VARCHAR(500) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  date_category VARCHAR(32) NOT NULL,
  coefficient DECIMAL(30,15) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_coefficient (year, date_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
deploy-templates/db/sql_holiday_comparison_base_migration.sql
New file
@@ -0,0 +1,6 @@
-- Add the optional ordinary national/provincial road comparison base.
-- The field is used only for the 2026 holiday data.
ALTER TABLE holiday_vehicle_flow
  ADD COLUMN comparison_base DECIMAL(30,15) NULL
  COMMENT '普通国省道环比基期(万辆次,仅当年使用)'
  AFTER vehicle_count;
deploy-templates/db/sql_holiday_decimal_migration.sql
New file
@@ -0,0 +1,16 @@
-- Convert holiday statistics numeric columns from binary floating point to exact decimal storage.
-- Run once against the traffic_audit database.
ALTER TABLE holiday_passenger_flow
  MODIFY COLUMN passenger_count DECIMAL(30,15) NOT NULL COMMENT '发送旅客数(万人次)';
ALTER TABLE holiday_vehicle_flow
  MODIFY COLUMN vehicle_count DECIMAL(30,15) NOT NULL COMMENT '车流量(万辆次)';
ALTER TABLE holiday_passenger_coefficient
  MODIFY COLUMN coefficient DECIMAL(30,15) NOT NULL COMMENT '客运系数';
-- The seeded coefficient was averaged from repeated 2.4 cells through a Java double.
-- Normalize the migrated value to the exact Excel decimal.
UPDATE holiday_passenger_coefficient
SET coefficient = 2.4
WHERE year IN (2024, 2025, 2026);
deploy-templates/db/sql_holiday_previous_day_migration.sql
New file
@@ -0,0 +1,16 @@
-- Backfill the 2019 National Day calendar and previous-day rows used by rail and civil-aviation ring-ratio calculations.
-- day_index = 0 identifies the configured previous day; 2019 uses normal days 1..8.
INSERT IGNORE INTO holiday_calendar
  (year, holiday_type, holiday_date, day_index, created_at, updated_at)
VALUES
  (2019, 'NATIONAL_DAY', '2019-10-01', 1, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-02', 2, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-03', 3, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-04', 4, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-05', 5, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-06', 6, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-07', 7, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-08', 8, NOW(), NOW()),
  (2024, 'NATIONAL_DAY', '2024-09-30', 0, NOW(), NOW()),
  (2025, 'NATIONAL_DAY', '2025-09-30', 0, NOW(), NOW()),
  (2026, 'NATIONAL_DAY', '2026-09-30', 0, NOW(), NOW());
deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql
New file
@@ -0,0 +1,14 @@
-- Add the historical national/provincial road adjustment table.
-- Run against the traffic_audit database on upgrade.
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT 'year',
  flow_date DATE NOT NULL COMMENT 'holiday date',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT 'adjusted national/provincial vehicle flow (10k vehicle trips)',
  reason VARCHAR(500) NOT NULL COMMENT 'adjustment reason',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='historical national/provincial vehicle flow adjustments';
deploy-templates/db/sql_observation_station.sql
New file
@@ -0,0 +1,39 @@
-- Observation station daily survey data (raw retention for future analysis).
-- Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS observation_station_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  obs_date DATE NOT NULL COMMENT 'Observation date',
  station_code VARCHAR(64) NOT NULL COMMENT 'Station code',
  station_name VARCHAR(128) DEFAULT NULL COMMENT 'Station name',
  mileage DECIMAL(30,15) DEFAULT NULL COMMENT 'Observed mileage (km)',
  city_name VARCHAR(64) DEFAULT NULL COMMENT 'City name',
  county_name VARCHAR(64) DEFAULT NULL COMMENT 'County name',
  direction VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Travel direction',
  small_medium_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Small/medium passenger cars',
  large_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Large passenger cars',
  small_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Small trucks',
  medium_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Medium trucks',
  large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Large trucks',
  extra_large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Extra large trucks',
  container_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Container trucks',
  motorcycle DECIMAL(30,15) DEFAULT NULL COMMENT 'Motorcycles',
  tractor DECIMAL(30,15) DEFAULT NULL COMMENT 'Tractors',
  passenger_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total passenger vehicles',
  truck_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total trucks',
  car_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total automobiles',
  motor_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total motor vehicles',
  passenger_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Passenger car equivalent',
  truck_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Truck equivalent',
  car_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Automobile equivalent',
  motor_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle equivalent',
  vehicle_speed DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle speed',
  congestion_degree DECIMAL(30,15) DEFAULT NULL COMMENT 'Congestion degree',
  source_file VARCHAR(255) DEFAULT NULL COMMENT 'Source file name',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_observation_station (obs_date, station_code, direction),
  KEY idx_observation_station_date (obs_date),
  KEY idx_observation_station_city (city_name),
  KEY idx_observation_station_code (station_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
deploy-templates/jar-application.yml
New file
@@ -0,0 +1,94 @@
# ============================================================
# æ‰“包用「进 jar çš„默认配置」——只允许占位符,禁止真实密钥/口令!
#
# pack-deploy.ps1 æž„建前会用本文件临时替换
#   traffic-audit-server/src/main/resources/application.yml
# æž„建完成(含去密自检)后自动还原开发机的真实配置。
#
# éƒ¨ç½²æœºçœŸå®žå€¼ç”±ä¸‹é¢ä¸¤ç§æ–¹å¼è¦†ç›–,优先级 çŽ¯å¢ƒå˜é‡ > jar åŒçº§ application.yml > æœ¬æ–‡ä»¶ï¼š
#   1) jar åŒçº§ï¼ˆå¯åŠ¨å·¥ä½œç›®å½•ï¼‰æ”¾ application.yml,见 deploy-templates/backend/application.yml
#   2) çŽ¯å¢ƒå˜é‡ TRAFFIC_DB_URL / TRAFFIC_DB_USER / TRAFFIC_DB_PASSWORD /
#      TRAFFIC_JWT_SECRET / DEEPSEEK_API_KEY
#
# æ³¨æ„ï¼špassword é»˜è®¤å€¼ä¸ºç©ºä¸²ï¼Œè‹¥éƒ¨ç½²æœºæ—¢æ²¡æœ‰å¤–ç½® application.yml ä¹Ÿæ²¡è®¾
# TRAFFIC_DB_PASSWORD,启动会报 java.sql.SQLException: Access denied ... (using password: NO)
# ============================================================
server:
  port: 8090
  tomcat:
    basedir: .
    threads:
      max: 500
    accesslog:
      enabled: true
      directory: logs
      prefix: access
      suffix: .log
      pattern: '%h %l %u %t "%r" %s %b %D'
spring:
  web:
    resources:
      static-locations: file:./web/,classpath:/static/
  datasource:
    url: ${TRAFFIC_DB_URL:jdbc:mysql://127.0.0.1:3306/traffic_audit?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
    username: ${TRAFFIC_DB_USER:root}
    password: ${TRAFFIC_DB_PASSWORD:}
    driver-class-name: com.mysql.cj.jdbc.Driver
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai
mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  type-aliases-package: com.trafficaudit
  configuration:
    map-underscore-to-camel-case: true
    # æ˜¾å¼æŒ‡å®š Slf4j:MyBatis-Plus é»˜è®¤ StdOutImpl ä¼šæŠŠæ•´è¡Œæ•°æ®ï¼ˆå«ä¼ä¸šå/电话)打进日志
    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
  global-config:
    db-config:
      id-type: auto
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0
jwt:
  secret: ${TRAFFIC_JWT_SECRET:8f2c1d64a7b34e09c5d81f6a2b0e7c93}
  expiration: 86400000
docs:
  template-root: docs
city-passenger:
  template-dir: docs/城市客运
passenger:
  template-dir: docs/公路旅客
freight:
  template-dir: docs/货运
energy:
  template-dir: docs/能耗
investment:
  template-dir: docs/投资/模板
summary:
  template-dir: docs/生成汇总大表
deepseek:
  api-key: ${DEEPSEEK_API_KEY:}
  api-url: https://api.deepseek.com/v1/chat/completions
  model: deepseek-chat
app:
  cors:
    allowed-origins: http://localhost:*,http://127.0.0.1:*,http://192.168.*:*,http://10.*:*,http://172.*:*
  captcha:
    required: false
docs/Excel¸¡µãÊý´æ´¢.txt
New file
@@ -0,0 +1,152 @@
excel的数值计算非常精确,其内部是怎么存储数值的呢,是用浮点数吗
用户想知道Excel内部如何存储数值,是否使用浮点数。我需要提供准确的解释。为了全面获取信息,我将同时进行多项搜索,覆盖Excel数值存储机制、浮点数精度问题、与IEEE 754标准的关系以及可能的优化技术。
搜索到 16 ä¸ªç½‘页
搜索结果显示了一些相关页面。为了全面获取信息,我们需要同时打开这些页面。
浏览 1 ä¸ªé¡µé¢
Floating Point Errors in Excel
这些资料提供了关于Excel数值存储的详细信息。微软的文档明确指出Excel遵循IEEE 754标准,使用双精度浮点数,有效数字为15位。Stack Overflow的讨论也确认了这一点。ExcelJet的文章解释了浮点数误差的原因。我的回答将涵盖以下几个方面:Excel使用IEEE 754双精度浮点数(64位),有效精度为15位,存储范围,以及浮点数误差及其原因。我还会提及一些特殊情况,比如整数存储和日期时间存储。为了更全面地覆盖这些方面,我需要同时进行多项搜索。
搜索到 12 ä¸ªç½‘页
现在可以组织答案了。答案将涵盖:Excel使用IEEE 754双精度浮点数(64位),有效精度为15位,存储范围,以及浮点数误差及其原因。还会提及整数和日期时间的存储方式。回答时会引用微软文档、Stack Overflow等来源。
是的,Excel çš„æ•°å€¼è®¡ç®—确实基于浮点数,具体来说,它遵循 IEEE 754 æ ‡å‡†çš„ 64 ä½åŒç²¾åº¦æµ®ç‚¹æ•°æ ¼å¼æ¥å­˜å‚¨å’Œè®¡ç®—数值。
核心机制:64位双精度浮点数
Excel å†…部将所有工作表单元格中的数字(包括整数、日期和时间)都统一存储为 8字节(64位)的双精度浮点数。
这种存储格式的 64 ä¸ªæ¯”特位被划分为三个部分:
符号位 (1位):表示正负。
指数位 (11位):决定数值的范围。
尾数位 (52位):决定数值的精度。
精度限制:15位有效数字
尽管双精度浮点数能提供约 15 åˆ° 16 ä½çš„十进制有效数字,但 Excel ä¸ºäº†ç¡®ä¿è®¡ç®—结果的可靠性,将有效精度限制在 15 ä½ã€‚
这意味着,如果一个数字的有效位数超过 15 ä½ï¼Œä»Žç¬¬ 16 ä½å¼€å§‹å°†è¢«æ›¿æ¢ä¸º 0。例如,输入 123456789012345678,Excel å®žé™…存储的将是 123456789012345000。
数值范围
基于此标准,Excel å¯ä»¥å¤„理的数值范围如下:
最大正数:1.79769313486232E+308
最小正数:2.2250738585072E-308
超出此范围的数值会被视为“无穷大”并显示为错误值。
为什么会有浮点数误差?
你可能会遇到 Excel è®¡ç®— 0.1 + 0.2 å¾—到 0.30000000000000004 çš„æƒ…况,这并非 Bug,而是二进制浮点数的固有特性。
其根本原因在于,像 0.1 è¿™æ ·çš„十进制小数无法用二进制浮点数精确表示,就像十进制无法精确表示 1/3 ä¸€æ ·ã€‚Excel å­˜å‚¨çš„æ˜¯ä¸€ä¸ªæžå…¶æŽ¥è¿‘ 0.1 çš„近似值,当多个这样的近似值进行运算时,微小的误差会累积,最终导致结果出现极小的偏差。
其他数据类型的存储
整数:也按双精度浮点数存储,因此 Excel èƒ½ç²¾ç¡®è¡¨ç¤ºçš„æ•´æ•°ä¸Šé™æ˜¯ 2^53(约 9,007,199,254,740,992)。超过此范围的整数可能会丢失精度。
日期和时间:同样是浮点数。整数部分代表日期(自 1900 å¹´ 1 æœˆ 1 æ—¥ä»¥æ¥çš„天数),小数部分代表时间(一天中的比例)。
总结来说,Excel çš„æ•°å€¼è®¡ç®—在底层就是标准的 IEEE 754 åŒç²¾åº¦æµ®ç‚¹æ•°ï¼Œå…¶â€œç²¾ç¡®â€æ˜¯ç›¸å¯¹çš„,受限于 15 ä½æœ‰æ•ˆæ•°å­—的精度和二进制浮点数本身的表示限制。
那么用Java处理excel的数据时,为了保持与excel的计算结果一致,数据库和Java中如何存储和计算呢
用户想知道如何在Java中处理Excel数据,以保持与Excel计算结果一致,涉及数据库和Java中的存储与计算。我需要提供关于数据类型选择、计算策略以及可能遇到的精度问题方面的建议。为了全面获取信息,我将同时进行多项搜索,覆盖Java处理Excel精度、数据库存储策略、BigDecimal使用、Apache POI处理方式以及IEEE 754兼容性等关键方面。
搜索到 36 ä¸ªç½‘页
搜索结果显示了一些相关资源。为了全面获取信息,我们需要同时打开这些结果。
浏览 4 ä¸ªé¡µé¢
NumberComparer (POI API Documentation)
EasyExcel读入数字类型数据时出现小数位增长现象 - EasyExcel读入数字类型数据时出现小数位增长现象
【完美解决POI读取Excel文档数值类型缺失精度问题】
MySQL :: MySQL 9.0 åƒè€ƒæ‰‹å†Š :: 13.1.3 å®šé»žé¡žåž‹ï¼ˆç²¾ç¢ºå€¼ï¼‰- DECIMAL、NUMERIC
这些资料提供了关于精度问题、Apache POI的NumberToTextConverter、Excel的15位精度限制以及数据库DECIMAL类型的信息。我的回答将涵盖Java中的存储与计算、数据库中的存储、以及如何保持结果一致。回答会引用这些资料。现在开始组织回答。
要在Java中处理Excel数据并保持结果一致,核心思路是将Excel的15位有效数字精度作为基准,在Java和数据库中采用更高精度或精确类型进行存储,并统一计算与舍入规则。
💾 ç¬¬ä¸€æ­¥ï¼šåœ¨Java中如何存储与读取
Java的 double ç±»åž‹åŒæ ·éµå¾ª IEEE 754 æ ‡å‡†ï¼Œä½†å…¶æœ‰æ•ˆæ•°å­—可达15-17位,而Excel严格限制为15位,这是差异的根源。
读取时:推荐用 BigDecimal æˆ– String
直接用 double æŽ¥æ”¶ Excel æ•°å€¼ä¼šå¼•入额外的二进制浮点误差。例如,Excel ä¸­çš„ 9699.1 è¯»æˆ double å¯èƒ½å˜æˆ 9699.0999999999999996。
推荐方案:
读取为 BigDecimal:在实体类中,将金额、数量等数值字段声明为 BigDecimal ç±»åž‹ï¼Œè€Œéž double æˆ– float。
使用 POI çš„ NumberToTextConverter:Apache POI æä¾›äº† NumberToTextConverter.toText(double) å·¥å…·ç±»ï¼Œå¯ä»¥å°† Excel çš„原始数值精确地转换为字符串,再由字符串构造 BigDecimal,避免中间精度损失。
java
import org.apache.poi.ss.util.NumberToTextConverter;
import java.math.BigDecimal;
if (cell.getCellType() == CellType.NUMERIC) {
    String exactText = NumberToTextConverter.toText(cell.getNumericCellValue());
    BigDecimal value = new BigDecimal(exactText);
    // æ­¤æ—¶ value æ˜¯ Excel ä¸­å­˜å‚¨çš„精确值(15位有效数字)
}
🗄️ ç¬¬äºŒæ­¥ï¼šåœ¨æ•°æ®åº“中如何存储
数据库字段切忌使用 FLOAT æˆ– DOUBLE,因为它们同样存在二进制浮点误差,会导致与Excel结果的偏差。
推荐方案:使用 DECIMAL ç±»åž‹
DECIMAL(或 NUMERIC)是定点精确类型,它按整数位和小数位存储,能精确表示十进制小数。
数据库    æŽ¨èç±»åž‹    è¯´æ˜Ž
MySQL    DECIMAL(p, s)    p ä¸ºæ€»ç²¾åº¦ï¼Œs ä¸ºå°æ•°ä½ã€‚Excel æœ‰æ•ˆä½15,建议 p è‡³å°‘为 15,再根据小数位需求设定。例如 DECIMAL(18, 6)。
PostgreSQL    NUMERIC(p, s)    åŒä¸Šï¼Œæ ‡å‡†SQL类型。
SQL Server    DECIMAL(p, s)    åŒä¸Šã€‚
Oracle    NUMBER(p, s)    NUMBER ç±»åž‹å¯å­˜å‚¨å®šç‚¹æ•°ï¼Œæ•ˆæžœç­‰åŒã€‚
JDBC æ˜ å°„:使用 JDBC è¯»å†™æ—¶ï¼ŒDECIMAL å­—段会直接映射为 Java çš„ BigDecimal,保证了精度在传递过程中不丢失。
🧮 ç¬¬ä¸‰æ­¥ï¼šJava中的计算与舍入规则
即使存储精确,计算逻辑也必须模拟 Excel çš„行为。
1. å§‹ç»ˆä½¿ç”¨ BigDecimal è¿›è¡Œè®¡ç®—
BigDecimal çš„加减乘除(add, subtract, multiply, divide)都是精确计算,不会引入二进制误差。切勿将 BigDecimal è½¬å›ž double åŽå†è®¡ç®—。
2. æ¨¡æ‹Ÿ Excel çš„15位有效数字限制
Excel åœ¨æ¯æ¬¡å°†ä¸­é—´ç»“果写入单元格时,都会将结果舍入到15位有效数字。在 Java ä¸­ï¼Œéœ€è¦æ¨¡æ‹Ÿè¿™ä¸€è¡Œä¸ºã€‚
java
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
// åˆ›å»ºä¸€ä¸ªç²¾åº¦ä¸º15位有效数字,舍入模式为“四舍五入”的上下文
MathContext EXCEL_MC = new MathContext(15, RoundingMode.HALF_UP);
public BigDecimal excelRound(BigDecimal value) {
    // åº”用Excel的舍入规则
    return value.round(EXCEL_MC);
}
// ç¤ºä¾‹ï¼šåœ¨æ¯ä¸€æ­¥å…³é”®è®¡ç®—后调用
BigDecimal intermediate = a.add(b);
intermediate = excelRound(intermediate); // æ¨¡æ‹Ÿå†™å…¥å•元格
BigDecimal result = intermediate.multiply(c);
result = excelRound(result);
注意:Excel çš„舍入模式近似于“四舍五入”,但存在微妙的“银行家舍入”行为。RoundingMode.HALF_UP æ˜¯æœ€æŽ¥è¿‘的近似,但在极少数边缘情况下仍有差异。对于财务系统,建议在最终结果上按业务规则(如 RoundingMode.HALF_UP)显式保留固定小数位。
3. æ¯”较运算需特殊处理
Excel åœ¨æ¯”较数字时有额外的容差逻辑。例如,(0.06-0.01)=0.05 åœ¨ Excel ä¸­ä¸º TRUE,但在 Java çš„ double æ¯”较中为 false。Apache POI æä¾›äº† NumberComparer.compare(double a, double b) æ¥æ¨¡æ‹Ÿè¿™ç§æ¯”较行为。对于 BigDecimal,建议先按15位有效数字舍入后再用 compareTo() æ¯”较。
💎 æ€»ç»“
要在 Java ç”Ÿæ€ä¸­å¤çް Excel çš„计算结果,关键在于:
入口精确:用 NumberToTextConverter + BigDecimal è¯»å–,避免 double æ±¡æŸ“。
存储精确:数据库使用 DECIMAL ç±»åž‹ï¼ŒJava å­—段使用 BigDecimal。
计算精确:全程使用 BigDecimal è¿ç®—。
舍入对齐:在每次可能产生中间结果的地方,用 MathContext(15, RoundingMode.HALF_UP) æ¨¡æ‹Ÿ Excel çš„15位有效数字截断。
比较容差:使用模拟 Excel è¡Œä¸ºçš„æ¯”较逻辑,而非直接比较。
这样就能最大程度地保证 Java ä¸Ž Excel çš„计算结果一致。
docs/database.md
@@ -487,3 +487,68 @@
| /api/report/export/investFiveYearLogistics | "十五五"物流项目进展 | å›ºå®šåå•过滤,市州小节+项目行 |
| /api/report/export/investBillion | äº¿å…ƒæŠ•资项目 | æŠ•资总表 + è§„上项目表两sheet,亿元名单过滤 |
| /api/report/export/investCounty | ç»æµŽå¼ºåŽ¿æŠ•èµ„ç»Ÿè®¡ | ç»æµŽå¼ºåŽ¿åå•è¿‡æ»¤ |
## ä¹ã€ä¸“项统计-重要节假日模块
> æ•°æ®æ¥æºä¸Žæ¨¡æ¿ï¼š`docs/专项统计/重要节假日/2026年十一数据.xlsx`;快报模板:`docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx`。首次启动会从工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµæ•°æ®ã€2024-2026 å¹´é“è·¯/民航前一日数据、2019 å¹´é“路数据,并初始化 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
### holiday_calendar èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| holiday_type | varchar(32) | æ˜¥èŠ‚ SPRING_FESTIVAL / äº”一 LABOR_DAY / åä¸€ NATIONAL_DAY |
| holiday_date | date | èŠ‚å‡æ—¥å…·ä½“æ—¥æœŸ |
| day_index | int | 0=前一日,1..N=假期第 N æ—¥ |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, holiday_type, day_index)`、`(year, holiday_type, holiday_date)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护;`day_index=0` ç”¨äºŽé“è·¯ã€æ°‘航的前一日环比基数。
### holiday_passenger_flow èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| flow_date | date | æ—¥æœŸ |
| transport_mode | varchar(32) | é“è·¯ RAIL / æ°‘航 CIVIL_AVIATION / é“è·¯ ROAD / æ°´è·¯ WATER |
| passenger_count | decimal(30,15) | å‘送旅客数(万人次) |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, flow_date, transport_mode)`。
### holiday_vehicle_flow èŠ‚å‡æ—¥ç»¼åˆè¿è¾“éžè¥ä¸šæ€§è½¦æµ
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| flow_date | date | æ—¥æœŸ |
| road_category | varchar(32) | é«˜é€Ÿå…¬è·¯ EXPRESSWAY / æ™®é€šå›½çœé“ NATIONAL_PROVINCIAL |
| vehicle_type | varchar(32) | å°å®¢è½¦ SMALL_PASSENGER / ä¸­å°å®¢è½¦ MEDIUM_SMALL_PASSENGER |
  | vehicle_count | decimal(30,15) | è½¦æµé‡ï¼ˆä¸‡è¾†æ¬¡ï¼‰ |
  | comparison_base | decimal(30,15) | æ™®é€šå›½çœé“环比基期(万辆次,人工备用值,仅 2026 å¹´å¯ç”¨ã€ä¸å‚与计算) |
  | created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, flow_date, road_category, vehicle_type)`。
### holiday_passenger_coefficient å®¢è¿ç³»æ•°é…ç½®
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | bigint | PK |
| year | int | å¹´ä»½ |
| date_category | varchar(32) | èŠ‚å‡æ—¥ HOLIDAY / éžèŠ‚å‡æ—¥ NON_HOLIDAY |
| coefficient | decimal(30,15) | å®¢è¿ç³»æ•° |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, date_category)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护。
### è®¡ç®—与导入导出口径
  - éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ =(高速车流 + æ™®é€šå›½çœé“车流)נ当年节假日客运系数。
  - æ™®é€šå›½çœé“环比固定按“当日 / å‰ä¸€æ—¥ - 1”计算;2024/2025/2026 å„年一致。`comparison_base`(Excel æ™®é€šå›½çœé“表 C åˆ—“环比基期”)自 2026-10-05 èµ·ä»…作人工备用:导入导出原样保存/回写,不参与环比、折算或累计;导出时 C åˆ—照常写出,E åˆ—“环比”公式始终引用上一行同年 D åˆ—。
  - å…¬è·¯äººå‘˜æµåŠ¨é‡ = é“路营业性客运量 + éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ã€‚
- æ€»äººå‘˜æµåŠ¨é‡ = å…¬è·¯ + é“è·¯ + æ°‘航 + æ°´è·¯ã€‚
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` ä¸­ `day_index>=1` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;同时读取当前年份铁路、民航的“前一日”行,写入 `day_index=0` å¯¹åº”的日期。
- æ—¥å¸¸å¯¼å…¥åªè¯»å–所选年份的当年列(道路表的 2019/2024/2025 åˆ—均不读取);2019 å¹´é“路数据和 2024/2025/2026 å¹´å‰ä¸€æ—¥é“è·¯ã€æ°‘航数据由启动初始化直接写入数据库。2019 å¹´ 10 æœˆ 1-8 æ—¥çš„ `holiday_calendar` é…ç½®ç”¨äºŽåœ¨ç³»ç»Ÿé…ç½®å’ŒèŠ‚å‡æ—¥ç»Ÿè®¡é¡µæŸ¥çœ‹è¯¥åŽ†å²åŸºçº¿ã€‚
- å¯¼å…¥å‰æ£€æŸ¥æ¯”本次最新导入日更早的历史日期是否发生变化;如有变化先不写库,返回差异清单,用户确认后才允许覆盖。
- è®°å½•“前一日”的铁路、民航数据后,假期第 1 æ—¥çš„铁路、民航环比按该数据计算;公路、水路、总人员流动量在第 1 æ—¥ä»ä¸è®¡ç®—环比。
- å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºåº•稿,回填库内数据与客运系数;快报按日期生成 Word,同比固定使用 2025、2024 ä¸¤å¹´ï¼Œä¸ä½¿ç”¨ 2019 æ•°æ®ã€‚
docs/init.sql
@@ -619,3 +619,84 @@
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  KEY idx_type_period (report_type, report_period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报表期审核通过标记';
-- ============================================
-- åä¸€ã€ä¸“项统计-重要节假日
-- ============================================
-- èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
DROP TABLE IF EXISTS holiday_calendar;
CREATE TABLE holiday_calendar (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  holiday_type VARCHAR(32) NOT NULL COMMENT '节假日分类:SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
  holiday_date DATE NOT NULL COMMENT '节假日具体日期',
  day_index INT NOT NULL COMMENT '0=前一日,1..N=假期第N日',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
  UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
  KEY idx_holiday_calendar_date (holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='重要节假日日期配置';
-- èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
DROP TABLE IF EXISTS holiday_passenger_flow;
CREATE TABLE holiday_passenger_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:RAIL/CIVIL_AVIATION/ROAD/WATER',
  passenger_count DECIMAL(30,15) NOT NULL COMMENT '发送旅客数(万人次)',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
  KEY idx_holiday_passenger_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输营业性客流情况';
-- èŠ‚å‡æ—¥ç»¼åˆè¿è¾“éžè¥ä¸šæ€§è½¦æµ
DROP TABLE IF EXISTS holiday_vehicle_flow;
CREATE TABLE holiday_vehicle_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  road_category VARCHAR(32) NOT NULL COMMENT '道路分类:EXPRESSWAY/NATIONAL_PROVINCIAL',
  vehicle_type VARCHAR(32) NOT NULL COMMENT '车型分类:SMALL_PASSENGER/MEDIUM_SMALL_PASSENGER',
  vehicle_count DECIMAL(30,15) NOT NULL COMMENT '车流量(万辆次)',
  comparison_base DECIMAL(30,15) NULL COMMENT '普通国省道环比基期(万辆次,仅当年使用)',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日综合运输非营业性车流情况';
-- å¾€å¹´å›½çœé“车流量调整
DROP TABLE IF EXISTS holiday_vehicle_adjustment;
CREATE TABLE holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  flow_date DATE NOT NULL COMMENT '日期',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT '调整后普通国省道车流量(万辆次)',
  reason VARCHAR(500) NOT NULL COMMENT '调整原因',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='往年国省道车流量调整';
-- å®¢è¿ç³»æ•°é…ç½®
DROP TABLE IF EXISTS holiday_passenger_coefficient;
CREATE TABLE holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT '年份',
  date_category VARCHAR(32) NOT NULL COMMENT '日期分类:HOLIDAY/NON_HOLIDAY',
  coefficient DECIMAL(30,15) NOT NULL COMMENT '客运系数',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_coefficient (year, date_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客运系数配置';
docs/sql_holiday.sql
New file
@@ -0,0 +1,66 @@
-- Holiday statistics module. Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS holiday_calendar (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  holiday_type VARCHAR(32) NOT NULL,
  holiday_date DATE NOT NULL,
  day_index INT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
  UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
  KEY idx_holiday_calendar_date (holiday_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  transport_mode VARCHAR(32) NOT NULL,
  passenger_count DECIMAL(30,15) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
  KEY idx_holiday_passenger_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  road_category VARCHAR(32) NOT NULL,
  vehicle_type VARCHAR(32) NOT NULL,
  vehicle_count DECIMAL(30,15) NOT NULL,
  comparison_base DECIMAL(30,15) NULL COMMENT '普通国省道环比基期(万辆次,仅当年使用)',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
  KEY idx_holiday_vehicle_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  flow_date DATE NOT NULL,
  adjusted_value DECIMAL(30,15) NOT NULL,
  reason VARCHAR(500) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS holiday_passenger_coefficient (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL,
  date_category VARCHAR(32) NOT NULL,
  coefficient DECIMAL(30,15) NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_coefficient (year, date_category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
docs/sql_holiday_comparison_base_migration.sql
New file
@@ -0,0 +1,6 @@
-- Add the optional ordinary national/provincial road comparison base.
-- The field is used only for the 2026 holiday data.
ALTER TABLE holiday_vehicle_flow
  ADD COLUMN comparison_base DECIMAL(30,15) NULL
  COMMENT '普通国省道环比基期(万辆次,仅当年使用)'
  AFTER vehicle_count;
docs/sql_holiday_decimal_migration.sql
New file
@@ -0,0 +1,16 @@
-- Convert holiday statistics numeric columns from binary floating point to exact decimal storage.
-- Run once against the traffic_audit database.
ALTER TABLE holiday_passenger_flow
  MODIFY COLUMN passenger_count DECIMAL(30,15) NOT NULL COMMENT '发送旅客数(万人次)';
ALTER TABLE holiday_vehicle_flow
  MODIFY COLUMN vehicle_count DECIMAL(30,15) NOT NULL COMMENT '车流量(万辆次)';
ALTER TABLE holiday_passenger_coefficient
  MODIFY COLUMN coefficient DECIMAL(30,15) NOT NULL COMMENT '客运系数';
-- The seeded coefficient was averaged from repeated 2.4 cells through a Java double.
-- Normalize the migrated value to the exact Excel decimal.
UPDATE holiday_passenger_coefficient
SET coefficient = 2.4
WHERE year IN (2024, 2025, 2026);
docs/sql_holiday_previous_day_migration.sql
New file
@@ -0,0 +1,16 @@
-- Backfill the 2019 National Day calendar and previous-day rows used by rail and civil-aviation ring-ratio calculations.
-- day_index = 0 identifies the configured previous day; 2019 uses normal days 1..8.
INSERT IGNORE INTO holiday_calendar
  (year, holiday_type, holiday_date, day_index, created_at, updated_at)
VALUES
  (2019, 'NATIONAL_DAY', '2019-10-01', 1, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-02', 2, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-03', 3, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-04', 4, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-05', 5, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-06', 6, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-07', 7, NOW(), NOW()),
  (2019, 'NATIONAL_DAY', '2019-10-08', 8, NOW(), NOW()),
  (2024, 'NATIONAL_DAY', '2024-09-30', 0, NOW(), NOW()),
  (2025, 'NATIONAL_DAY', '2025-09-30', 0, NOW(), NOW()),
  (2026, 'NATIONAL_DAY', '2026-09-30', 0, NOW(), NOW());
docs/sql_holiday_vehicle_adjustment_migration.sql
New file
@@ -0,0 +1,14 @@
-- Add the historical national/provincial road adjustment table.
-- Run against the traffic_audit database on upgrade.
CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  year INT NOT NULL COMMENT 'year',
  flow_date DATE NOT NULL COMMENT 'holiday date',
  adjusted_value DECIMAL(30,15) NOT NULL COMMENT 'adjusted national/provincial vehicle flow (10k vehicle trips)',
  reason VARCHAR(500) NOT NULL COMMENT 'adjustment reason',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
  KEY idx_holiday_vehicle_adjustment_date (flow_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='historical national/provincial vehicle flow adjustments';
docs/sql_observation_station.sql
New file
@@ -0,0 +1,39 @@
-- Observation station daily survey data (raw retention for future analysis).
-- Run against the traffic_audit database.
CREATE TABLE IF NOT EXISTS observation_station_flow (
  id BIGINT NOT NULL AUTO_INCREMENT,
  obs_date DATE NOT NULL COMMENT 'Observation date',
  station_code VARCHAR(64) NOT NULL COMMENT 'Station code',
  station_name VARCHAR(128) DEFAULT NULL COMMENT 'Station name',
  mileage DECIMAL(30,15) DEFAULT NULL COMMENT 'Observed mileage (km)',
  city_name VARCHAR(64) DEFAULT NULL COMMENT 'City name',
  county_name VARCHAR(64) DEFAULT NULL COMMENT 'County name',
  direction VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Travel direction',
  small_medium_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Small/medium passenger cars',
  large_passenger DECIMAL(30,15) DEFAULT NULL COMMENT 'Large passenger cars',
  small_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Small trucks',
  medium_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Medium trucks',
  large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Large trucks',
  extra_large_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Extra large trucks',
  container_truck DECIMAL(30,15) DEFAULT NULL COMMENT 'Container trucks',
  motorcycle DECIMAL(30,15) DEFAULT NULL COMMENT 'Motorcycles',
  tractor DECIMAL(30,15) DEFAULT NULL COMMENT 'Tractors',
  passenger_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total passenger vehicles',
  truck_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total trucks',
  car_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total automobiles',
  motor_total DECIMAL(30,15) DEFAULT NULL COMMENT 'Total motor vehicles',
  passenger_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Passenger car equivalent',
  truck_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Truck equivalent',
  car_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Automobile equivalent',
  motor_equivalent DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle equivalent',
  vehicle_speed DECIMAL(30,15) DEFAULT NULL COMMENT 'Motor vehicle speed',
  congestion_degree DECIMAL(30,15) DEFAULT NULL COMMENT 'Congestion degree',
  source_file VARCHAR(255) DEFAULT NULL COMMENT 'Source file name',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_observation_station (obs_date, station_code, direction),
  KEY idx_observation_station_date (obs_date),
  KEY idx_observation_station_city (city_name),
  KEY idx_observation_station_code (station_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/2026Äêʮһ¼ÙÆÚȫʡ×ÛºÏÔËÊä¿ÍÁ÷Á¿×ßÊÆÍ¼.docx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/±¸·Ý/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/±¸·Ý/ºþ±±Ê¡¹úÇì¼ÙÆÚ×ÛºÏÔËÊä¿ÍÁ÷¿ì±¨Ä£°å.docx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/ºþ±±Ê¡¹úÇì¼ÙÆÚ×ÛºÏÔËÊä¿ÍÁ÷¿ì±¨Ä£°å.docx
Binary files differ
docs/´ò°ü²¿Êð˵Ã÷.md
@@ -1,6 +1,6 @@
# äº¤é€šç»Ÿè®¡æŠ¥è¡¨å®¡æ ¸ç³»ç»Ÿ â€” æ‰“包与部署说明
> é€‚用:main åˆ†æ”¯ï¼ˆæœ€æ–°æ›´æ–° 2026-09-13)。后端 Spring Boot 2.7(Java 8),前端 Vue 2 + Element UI。
> é€‚用:main åˆ†æ”¯ï¼ˆæœ€æ–°æ›´æ–° 2026-10-05)。后端 Spring Boot 2.7(Java 8),前端 Vue 2 + Element UI。
> æœ¬æ–‡æ¡£é¢å‘开发与部署同事,命令均在 Windows ä¸‹éªŒè¯ã€‚
## ä¸€ã€é¡¹ç›®ç»“æž„
@@ -34,8 +34,8 @@
1. `traffic-audit-web` ä¸‹ `npm run build` â†’ `dist`;
2. æŠŠ `dist` å¤åˆ¶è¿› `traffic-audit-server/src/main/resources/static`,使 **jar è‡ªå¸¦å‰ç«¯**,再 `mvn clean package -DskipTests`(跑完自动清空 static,保持仓库干净);
3. ç»„装 `deploy/traffic-audit-deploy-YYYYMMDD/`;
4. åŽ‹ç¼©ä¸º `traffic-audit-deploy-YYYYMMDD.zip`(约 70 MB)。
3. ç»„装 `deploy/traffic-audit-deploy-YYYYMMDD-v1.0.0/`;
4. åŽ‹ç¼©ä¸º `traffic-audit-deploy-YYYYMMDD-v1.0.0.zip`(约 70 MB)。
手工等价命令(不想用脚本时):
@@ -47,15 +47,51 @@
> æ³¨æ„ï¼šæ‰‹å·¥æ–¹å¼è·‘完记得删掉 `traffic-audit-server/src/main/resources/static` ä¸‹çš„内容,否则会污染源码目录。
### ç‰ˆæœ¬ç®¡ç†ä¸Žå‘布(2026-10-05 èµ·ï¼‰
采用方案 A:后端 jar ä½¿ç”¨å›ºå®šæ–‡ä»¶åï¼Œç‰ˆæœ¬å·ç”± `pom.xml` å•一来源生成,发布包名和 git tag å¸¦ç‰ˆæœ¬ã€‚
- å•一版本源:`traffic-audit-server/pom.xml` çš„ `<version>`,例如 `1.0.0`
- æž„建产物固定为 `traffic-audit-server/target/traffic-audit-server.jar`
- æž„建时自动把版本写进:
  - `BOOT-INF/classes/app-version.properties`
  - `META-INF/MANIFEST.MF` çš„ `Implementation-Version`
- å‘布包命名:`traffic-audit-deploy-YYYYMMDD-v1.0.0(.zip)`
- git tag å‘½åï¼š`v1.0.0`
- é¡µé¢ç‰ˆæœ¬ï¼šåŽç«¯ `GET /api/version`,登录页和页头显示 `V1.0.0`
- `pack-deploy.ps1` ä¼šæ ¡éªŒ `pom.xml`、jar å†… `app-version.properties`、jar æ¸…单三者版本一致,不一致直接中止打包
发新版本流程:
1. å…ˆæ”¹ `traffic-audit-server/pom.xml` çš„ `<version>`,例如 `1.0.1`
2. è¿è¡Œ `pack-deploy.ps1` æ‰“包
3. æäº¤ä»£ç å¹¶æ‰“ git tag:`git tag -a v1.0.1 -m "v1.0.1"`
4. æœåŠ¡å™¨åœæ­¢æœåŠ¡ï¼Œæ›¿æ¢ `traffic-audit-server.jar`,再启动服务
旧服务器首次升级(只需做一次):已有 `trafficAudit.xml` é€šå¸¸ä»å†™æ­»æ—§ jar å `traffic-audit-server-1.0.0-SNAPSHOT.jar`,把 `<arguments>` æ”¹æˆå›ºå®šæ–‡ä»¶åï¼š
```xml
<arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"</arguments>
```
以后每次发布只替换 jar å¹¶é‡å¯æœåŠ¡ï¼Œä¸å†ä¿®æ”¹ `trafficAudit.xml`。
## å››ã€éƒ¨ç½²åŒ…内容
```
traffic-audit-deploy-YYYYMMDD/
traffic-audit-deploy-YYYYMMDD-v1.0.0/
├─ backend/
│  â”œâ”€ traffic-audit-server-1.0.0-SNAPSHOT.jar   åŽç«¯ + å†…嵌前端(约 55 MB)
│  â”œâ”€ traffic-audit-server.jar                   åŽç«¯ + å†…嵌前端(约 55 MB,固定文件名)
│  â”œâ”€ docs/                                     æ¨¡æ¿ä¸Žè¿è¡Œæ•°æ®ï¼ˆå¿…需,勿删)
│  â”œâ”€ application.yml                           å¤–部配置(改这里即可,无需重打包)
│  â””─ start-backend.cmd / start-backend.sh       å¯åŠ¨è„šæœ¬
│  â”œâ”€ start-backend.cmd / start-backend.sh      å¯åŠ¨è„šæœ¬ï¼ˆäººå·¥åŒå‡»/命令行)
│  â”œâ”€ trafficAudit.exe                          WinSW æœåŠ¡åŒ…è£…å™¨ï¼ˆ2.12.0 x64,自包含 .NET 6)
│  â”œâ”€ trafficAudit.xml                          WinSW æœåŠ¡é…ç½®ï¼ˆè‡ªåŠ¨/延迟启动、失败重启、日志滚动)
│  â”œâ”€ install-service.cmd                       åŒå‡»å…¥å£ï¼šæ³¨å†Œå¼€æœºè‡ªå¯æœåŠ¡ï¼ˆè‡ªåŠ¨è¯·æ±‚ç®¡ç†å‘˜æƒé™ï¼‰
│  â”œâ”€ install-service.ps1                       ä¸Šé¢é‚£ä¸ª cmd å®žé™…调用的脚本(注册/查看/重启/删除)
│  â”œâ”€ start-backend-service.cmd                 å¤‡é€‰ï¼šéžäº¤äº’启动器(无 pause、写 app.log)
│  â”œâ”€ install-autostart.ps1                     å¤‡é€‰ï¼šæ³¨å†Œ/查看/删除 Windows å¼€æœºè‡ªå¯è®¡åˆ’任务
│  â””─ web/                                     å‰ç«¯é™æ€æ–‡ä»¶ï¼ˆç”± dist è‡ªåŠ¨åŒæ­¥ï¼Œ8090 å•端口模式必需)
├─ frontend/
│  â”œâ”€ dist/                                     å‰ç«¯äº§ç‰©ï¼ˆä»… nginx æ–¹æ¡ˆéœ€è¦ï¼‰
│  â””─ nginx.conf.example                         nginx é…ç½®ç¤ºä¾‹
@@ -63,7 +99,13 @@
│  â”œâ”€ init.sql                                  å»ºåº“建表 + é¢„置账号(必须执行)
│  â”œâ”€ sql_city_bus.sql                          åŸŽå¸‚客运-公交月度表(必须执行)
│  â”œâ”€ sql_city_taxi.sql                         åŸŽå¸‚客运-出租月度表/授权表(必须执行)
│  â””─ sql_wyc.sql                               ç½‘约车全省总量/订单分市州表(必须执行)
│  â”œâ”€ sql_wyc.sql                               ç½‘约车全省总量/订单分市州表(必须执行)
│  â”œâ”€ sql_holiday.sql                           ä¸“项统计-重要节假日表(旧库补建时执行)
│  â”œâ”€ sql_holiday_decimal_migration.sql         èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_vehicle_adjustment_migration.sql å¾€å¹´å›½çœé“数据调整表(旧库升级)
│  â””─ sql_observation_station.sql               è§‚测站分日调查数据表(旧库补建时执行)
└─ README-部署说明.txt
```
@@ -77,14 +119,15 @@
```bash
cd db
mysql --default-character-set=utf8mb4 -u root -p            < init.sql          # å»ºåº“ + 24 å¼ åŸºç¡€è¡¨ + é¢„置账号
mysql --default-character-set=utf8mb4 -u root -p            < init.sql          # å»ºåº“ + 28 å¼ åŸºç¡€è¡¨ + é¢„置账号
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_city_bus.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_city_taxi.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_wyc.sql
```
> âš ï¸ **`init.sql` æ˜¯ç ´åæ€§çš„**:里面的基础表都带 `DROP TABLE IF EXISTS`。**已有数据的库上千万不要再跑 init.sql**,否则表数据会被清空重建。
> 4 ä¸ªè„šæœ¬å…±è¦†ç›– 29 å¼ è¡¨ï¼ˆinit.sql 24 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日相关表,执行 `sql_holiday.sql`;若已有节假日表但缺精度、环比基期字段、前一日配置、往年国省道调整表或观测站表,按需执行对应的迁移脚本。迁移脚本均可重复执行。
> 4 ä¸ªè„šæœ¬å…±è¦†ç›– 33 å¼ è¡¨ï¼ˆinit.sql 28 å¼ ï¼Œå…¶ä¸­å«èŠ‚å‡æ—¥ 4 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
#### è·¯çº¿ B:把现网/开发机的数据整套搬过去(推荐,不用跑脚本)
@@ -107,9 +150,9 @@
- `--databases` ä¼šæŠŠ `CREATE DATABASE` + `USE` ä¸€èµ·å†™è¿›å¤‡ä»½ï¼Œæ‰€ä»¥ç›®æ ‡æœºå™¨**不用先建库、也不用跑那 4 ä¸ªè„šæœ¬**。
- å¤‡ä»½é‡ŒåŒ…含 `sys_user`,账号密码与源机器完全一致(本机是 `admin / 123456`),不会被重置成 `admin/admin123`。
- âš ï¸ å¤‡ä»½é‡Œæ¯å¼ è¡¨éƒ½å¸¦ `DROP TABLE IF EXISTS`(共 29 å¼ ï¼‰ï¼š**目标库要是已有数据,会被直接覆盖**。请还原到空库,或先自己备份目标库。
- âš ï¸ å¤‡ä»½é‡Œæ¯å¼ è¡¨éƒ½å¸¦ `DROP TABLE IF EXISTS`(以实际备份为准,最新为 33 å¼ ï¼‰ï¼š**目标库要是已有数据,会被直接覆盖**。请还原到空库,或先自己备份目标库。
- æœ¬æ¬¡å·²ç”Ÿæˆå¹¶**验证过**备份:`traffic_audit_full_20260913.sql`(45.5 MB,8.0.29 å®¢æˆ·ç«¯å¯¼å‡ºï¼‰ã€‚
  éªŒè¯æ–¹å¼ï¼šæŠŠå¤‡ä»½åº“名改成临时库导入,逐表比对 `SHOW CREATE TABLE`(表结构)与 `COUNT(*)`(行数)——**29 å¼ è¡¨ç»“构与行数均 0 å·®å¼‚**,抽查 `wyc_order_monthly` 2026-08 æ•°æ®ä¸€è‡´ï¼ŒéªŒè¯åŽå·²åˆ é™¤ä¸´æ—¶åº“。
  éªŒè¯æ–¹å¼ï¼šæŠŠå¤‡ä»½åº“名改成临时库导入,逐表比对 `SHOW CREATE TABLE`(表结构)与 `COUNT(*)`(行数)——**29 å¼ è¡¨ç»“构与行数均 0 å·®å¼‚(新增节假日模块后为 33 å¼ ï¼‰**,抽查 `wyc_order_monthly` 2026-08 æ•°æ®ä¸€è‡´ï¼ŒéªŒè¯åŽå·²åˆ é™¤ä¸´æ—¶åº“。
#### è·¯çº¿ B2:用 Navicat çš„「备份 / è¿˜åŽŸå¤‡ä»½ã€ï¼ˆ.nb3,最贴近日常习惯)
@@ -149,7 +192,7 @@
```powershell
# Windows:双击 start-backend.cmd;或
cd backend ; java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
cd backend ; java -jar traffic-audit-server.jar
```
```bash
@@ -159,7 +202,43 @@
成功标志:日志出现 `Started TrafficAuditApplication` ä¸Ž `Tomcat started on port(s): 8090`。
### 5.4 è®¿é—®
### 5.4 Windows å¼€æœºè‡ªå¯ï¼ˆæŽ¨èï¼šæ³¨å†Œæˆ Windows æœåŠ¡ï¼‰
在 `backend` ç›®å½•里**右键 `install-service.cmd` â†’「以管理员身份运行」**即可(直接双击也行,它会自己弹 UAC)。它实际调用的就是同目录的 `install-service.ps1`,等价命令:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
```
脚本会自动定位 java、注册服务、启动,然后探测端口打印结果;起不来时直接打印 `logs\*.log` çš„æœ€åŽ 15 è¡Œã€‚
| é¡¹ | å€¼ |
|----|----|
| æœåŠ¡å | `TrafficAuditBackend`(services.msc é‡Œæ˜¾ç¤ºä¸º TrafficAudit Backend) |
| å¯åŠ¨ç±»åž‹ | è‡ªåЍ + å»¶è¿Ÿå¯åŠ¨ï¼ˆå…ˆç­‰ MySQL å°±ç»ªï¼‰ï¼Œä¸éœ€è¦ä»»ä½•用户登录 |
| è¿è¡Œèº«ä»½ | LocalSystem |
| å¤±è´¥é‡å¯ | 10s / 30s / 60s é€’增,1 å°æ—¶åŽè®¡æ•°æ¸…零(由服务控制管理器执行) |
| æ—¥å¿— | `backend\logs\trafficAudit.out.log`、`trafficAudit.err.log`(10MB Ã— 10 æ»šåŠ¨ï¼‰ |
常用参数:`-Status`(无需管理员)、`-Restart`、`-Remove`、`-NoStart`、`-JavaPath "D:\jdk8\bin\java.exe"`。
脚本会自动定位 `java.exe`(JAVA_HOME â†’ PATH â†’ JavaSoft æ³¨å†Œè¡¨ â†’ Program Files)并把**绝对路径**写进 `trafficAudit.xml`(SYSTEM æœåŠ¡çš„ PATH ä¸Žç™»å½•用户不同,写绝对路径更稳)。重复执行是幂等的:先停服务再重新注册并启动。
> åŒä¸€å°æœº**不要**同时启用计划任务方式(`install-autostart.ps1`),两个宿主会抢 8090 ç«¯å£ã€‚
#### æ“ä½œç³»ç»Ÿå…¼å®¹æ€§
服务本身(自动启动 / å»¶è¿Ÿå¯åЍ / å¤±è´¥é‡å¯ï¼‰ä»Ž Win7 SP1 åˆ° Win11、Server 2008 R2 åˆ° Server 2022/2025 æœºåˆ¶ä¸€è‡´ï¼Œå·®åˆ«åªåœ¨åŒ…装器依赖的运行时:
| ç›®æ ‡ç³»ç»Ÿ | ç”¨å“ªä¸ªåŒ…装器 | é¢å¤–依赖 |
|----------|--------------|----------|
| Win10 / Win11 / Server 2016 åŠä»¥ä¸Šï¼ˆæŽ¨èï¼‰ | åŒ…内 `trafficAudit.exe`(WinSW 2.12.0 x64 è‡ªåŒ…含) | æ— ï¼ˆè‡ªå¸¦ .NET 6 è¿è¡Œæ—¶ï¼‰ |
| Win7 SP1 / Server 2008 R2 / Server 2012(R2) | å…ˆè¯•包内自包含版(Win7 éœ€ KB2533623);报 `api-ms-win-*.dll` ç¼ºå¤±æ—¶æ¢ `WinSW.NET461.exe` æ”¹åæˆ `trafficAudit.exe` | .NET Framework 4.6.1+ |
| 32 ä½ç³»ç»Ÿ | `WinSW-x86.exe` æ”¹åæˆ `trafficAudit.exe` | æ— ï¼ˆè‡ªå¸¦ .NET 6 è¿è¡Œæ—¶ï¼‰ |
下载页:<https://github.com/winsw/winsw/releases/tag/v2.12.0> â€”— ä»»ä½•构建都用同一份 `trafficAudit.xml`,改的只是包装器文件名。
`install-service.ps1` åœ¨ Windows PowerShell 2.0+ å¯ç”¨ï¼›`install-autostart.ps1` ç”¨åˆ° `New-ScheduledTask*`,需 Win8 / Server 2012 åŠä»¥ä¸Šã€‚
### 5.5 è®¿é—®
浏览器打开 `http://<部署机IP>:8090`(前端已内嵌,页面与接口同源,无需 nginx),账号 `admin / admin123`。
@@ -204,8 +283,13 @@
| å¯¼å‡ºæŠ¥ã€Œæœªæ‰¾åˆ°æ±‡æ€»è¡¨æ¯ç‰ˆã€ŠYYYYå¹´M月…》」 | `docs/生成汇总大表` ç¼ºè¯¥æœˆæ¯ç‰ˆ |
| ç™»å½•接口 500 | `jwt.secret` å¡«äº†éžæ³•字符(如中文);改成 32 ä½åå…­è¿›åˆ¶ä¸² |
| é¡µé¢èƒ½å¼€ä½†æŽ¥å£ 404 | èµ° nginx æ–¹æ¡ˆæ—¶æ²¡é… `/api` åä»£ï¼›æ”¹ç”¨ jar å†…嵌访问 8090 |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认 4 ä¸ª SQL è„šæœ¬å·²æ‰§è¡Œ |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认初始化脚本和对应升级迁移脚本已执行 |
| å¯åŠ¨æŠ¥ `Access denied ... (using password: NO)` | åŽç«¯æ‹¿åˆ°çš„库口令是**空串**:jar å†…ç½® `${TRAFFIC_DB_PASSWORD:}` æœªè¢«å¤–置配置覆盖。把 `backend/application.yml` æ”¾åœ¨ jar åŒçº§å¹¶å¡« `spring.datasource.password`,或设环境变量 `TRAFFIC_DB_PASSWORD`;改完重启,无需重打包 |
| ç«¯å£å ç”¨ | æ”¹ `server.port`;前端开发端口 8080 |
| æœåŠ¡å·²æ³¨å†Œä½† 8090 æ‰“不开 | çœ‹ `backend\logs\trafficAudit.err.log`;服务方式读的是 `backend\application.yml`,`password` å¿…须是真实口令 |
| æœåŠ¡æŠ¥â€œæ‹’ç»è®¿é—®â€/写不了日志 | éƒ¨ç½²ç›®å½•权限不足:换到 `D:\trafficAudit` è¿™ç±»ç®€å•路径,或给 LocalSystem æŽˆå†™æƒé™ |
| ä¸æƒ³å¼€æœºè‡ªå¯ä½†ä¿ç•™æœåŠ¡ | `Set-Service TrafficAuditBackend -StartupType Manual` |
| Win7/2008R2 ä¸ŠæœåŠ¡å¯ä¸äº†ï¼ˆæŠ¥ `api-ms-win-*.dll`) | è§ 5.4「操作系统兼容性」:把 `WinSW.NET461.exe` æ”¹åæˆ `trafficAudit.exe` æ›¿æ¢ |
## åã€å¼€å‘环境日常启动
@@ -217,6 +301,8 @@
## åä¸€ã€å˜æ›´è®°å½•
- **2026-10-05**:版本管理采用方案 A。`pom.xml` ä¸ºå”¯ä¸€ç‰ˆæœ¬æºï¼Œjar å›ºå®šåä¸º `traffic-audit-server.jar`,构建自动注入 `app-version.properties` ä¸Ž `MANIFEST.MF`;发布包名带版本,新增 `/api/version` é¡µé¢æ˜¾ç¤ºï¼Œé¦–次升级旧服务器需一次性修改 `trafficAudit.xml`,以后只替换 jar å¹¶é‡å¯ã€‚
- **2026-10-03**:`pack-deploy.ps1` è¡¥é½å¤–ç½® `application.yml`/`docs`/`db`/启动脚本并同步 `backend\web`(修 `Access denied ... (using password: NO)`);模板端口 8080→8090;新增 Windows æœåŠ¡æ–¹å¼å¼€æœºè‡ªå¯ï¼ˆWinSW 2.12.0 `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`,自动/延迟启动、失败重启、日志滚动),计划任务方式降为备选;补操作系统兼容性说明。
- **2026-09-13**:新增一键打包脚本 `package-deploy.py`;jar æ”¹ä¸ºå†…嵌前端(部署机不再需要 nginx);删除 Redis ç›¸å…³è¯´æ˜Žï¼ˆä»£ç æœªä½¿ç”¨ï¼‰ï¼›è¡¥ `sql_wyc.sql` ä¸Ž `summary.template-dir`;补 `jwt.secret` å–值规则;补部署后验证清单。
- 2026-08-27:首版(zip éƒ¨ç½²åŒ… + nginx æ–¹æ¡ˆï¼‰ã€‚
@@ -252,7 +338,7 @@
# 1) ç¡®è®¤è¿› jar çš„配置没有真实密钥(应只看到 ${...})
Select-String -Path traffic-audit-server\src\main\resources\application.yml -Pattern "password:|api-key:|secret:"
# 2) æ‰“完包后再验证一次 jar å†…部
python -c "import zipfile;print(zipfile.ZipFile('traffic-audit-server/target/traffic-audit-server-1.0.0-SNAPSHOT.jar').read('BOOT-INF/classes/application.yml').decode('utf-8'))" | Select-String "password:|api-key:"
python -c "import zipfile;print(zipfile.ZipFile('traffic-audit-server/target/traffic-audit-server.jar').read('BOOT-INF/classes/application.yml').decode('utf-8'))" | Select-String "password:|api-key:"
```
@@ -264,12 +350,20 @@
```
脚本做四件事:
1. `mvn clean package -DskipTests` æž„建后端 jar;
1. ç”¨ `deploy-templates/jar-application.yml`(全占位符)临时替换源码 `application.yml`,再 `mvn clean package -DskipTests`,构建后自动还原开发机真实配置;
2. **解压 jar è‡ªæ£€**:检查 `BOOT-INF/classes/application.yml` æ˜¯å¦å…¨ä¸ºå ä½ç¬¦â€”—一旦发现 `sk-` æˆ–明文口令**直接中止打包**;
3. `npm run build` é‡å»ºå‰ç«¯ `dist`;
4. æŒ‰ `deploy-templates/` ç»„装完整部署包(backend / frontend / db / README-部署说明.txt),输出到 `<OutRoot>\traffic-audit-deploy-<yyyyMMdd>`。
4. æŒ‰ `deploy-templates/` ç»„装完整部署包(backend / frontend / db / README-部署说明.txt)——`backend/` å« jar、`application.yml`、启动脚本、`docs/`、`web/`(由 dist è‡ªåŠ¨åŒæ­¥ï¼Œä¾› 8090 å•端口模式),以及 Windows æœåС䏉件套 `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`,输出到 `<OutRoot>\traffic-audit-deploy-<yyyyMMdd>-v<version>`;
   WinSW ä¼˜å…ˆç”¨ `deploy-templates\tools\winsw.exe` ç¼“存(校验 SHA-256),缓存缺失或哈希不符时自动从 GitHub å®˜æ–¹å‘布页下载 v2.12.0 x64(该产物未做 Authenticode ç­¾åï¼Œå› æ­¤ä»¥å›ºå®š SHA-256 ä½œå®Œæ•´æ€§æ ¡éªŒï¼‰ï¼›
5. å¯¹ç»„装后的整包再扫一遍明文密钥。
模板件统一放在 **`deploy-templates/`**(已入库):外部 `application.yml`、`start-backend.cmd/.sh`、`nginx.conf.example`、`db\{init.sql,sql_city_bus,sql_city_taxi,sql_wyc}.sql`、`README-部署说明.txt`。改模板即改打包产物。
可选参数:`-OutRoot` æŒ‡å®šè¾“出目录(默认仓库 `deploy\`)、`-SkipFrontend` å¤ç”¨çŽ°æœ‰ dist、`-SkipBackend` å¤ç”¨çŽ°æœ‰ jar(只重组包、不重编译,用于快速补包)、`-SkipServiceWrapper` ä¸æŠŠ WinSW æœåŠ¡åŒ…è£…å™¨æ‰“è¿›åŒ…ï¼ˆå³ä¸è¦â€œæ³¨å†Œæˆ Windows æœåŠ¡â€çš„å¼€æœºè‡ªå¯ï¼‰ã€‚
> è®°å½•(2026-10-03):此前手工产出的 10-02 åŒ…只有 jar+web+dist,缺 `application.yml`/`docs`/`db`/启动脚本,部署机启动报 `Access denied ... (using password: NO)`。现已由脚本自动补齐这些文件并在打包前替换占位符配置。
模板件统一放在 **`deploy-templates/`**(已入库):外部 `application.yml`、`start-backend.cmd/.sh`、`trafficAudit.xml`、`install-service.ps1`、`start-backend-service.cmd`、`install-autostart.ps1`、`nginx.conf.example`、`db\{init.sql,sql_city_bus,sql_city_taxi,sql_wyc}.sql`、`README-部署说明.txt`。改模板即改打包产物。
> `deploy-templates\tools\`(WinSW äºŒè¿›åˆ¶ï¼Œ17.4MB)**不入库**(已加 `.gitignore`),由打包脚本按需下载并校验 SHA-256。
### è½®æ¢å¯†é’¥åŽåŒæ­¥æœ¬æœº
@@ -282,7 +376,7 @@
### éƒ¨ç½²æœºä¸ŠåŠ /换 DeepSeek key
1. æ‰“包上传后,编辑 **jar åŒçº§**的 `backend/application.yml`:`deepseek.api-key: "你的新key"`(留空则页面 AI åˆ†æžä¸å¯ç”¨ï¼Œå…¶ä½™åŠŸèƒ½æ­£å¸¸ï¼‰ï¼›
2. é‡å¯åŽç«¯ï¼š`start-backend.cmd`(或 `java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar`);
2. é‡å¯åŽç«¯ï¼š`start-backend.cmd`(或 `java -jar traffic-audit-server.jar`);
3. é¡µé¢é‡Œç‚¹ä¸€æ¬¡ AI åˆ†æžç¡®è®¤å¯ç”¨ï¼›
4. **不要**去改 jar å†…部的配置(改了下次发包就丢),也不要把 key æäº¤è¿›ä»»ä½•仓库文件。
pack-deploy.ps1
@@ -1,34 +1,88 @@
<#
  ä¸€é”®æ‰“包部署包(含"去密自检")—— 2026-09-27 æ–°å¢ž
  ä¸€é”®æ‰“包部署包(含"去密自检")—— 2026-09-27 æ–°å¢žï¼Œ2026-10-03 ä¿®è®¢
  ç”¨æ³•:
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -OutRoot "E:\试验" -SkipFrontend
  åšå››ä»¶äº‹ï¼š
    1) mvn clean package æž„建后端 jar
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper
      â€”— å•端口发布:不生成独立的 frontend\dist ç›®å½•(仍构建并同步 backend\web,单端口必需);
         æœåŠ¡å™¨å·²è£…æœåŠ¡æ—¶é…åˆ -SkipServiceWrapper åŽ»æŽ‰çº¦ 17 MB çš„ WinSW,可显著减小包体积。
  åšäº”件事:
    1) ç”¨ deploy-templates\jar-application.yml(全占位符)临时替换源码 application.yml,
       æ‰§è¡Œ mvn clean package;无论成败都还原开发机真实配置
    2) è§£åŽ‹ jar æ£€æŸ¥å†…ç½® application.yml:必须全是占位符(禁止 sk- å¯†é’¥ / æ˜Žæ–‡å£ä»¤ï¼‰ï¼Œä¸åˆæ ¼ç›´æŽ¥ä¸­æ­¢
    3) npm run build æž„建前端 dist(可 -SkipFrontend)
    4) ç»„装 deploy ç›®å½•结构(backend / frontend / db / README-部署说明.txt),输出到 <OutRoot>\traffic-audit-deploy-<yyyyMMdd>
    3) npm run build é‡å»ºå‰ç«¯ dist(可 -SkipFrontend)
    4) ç»„装完整部署包:backend(jar + application.yml + å¯åŠ¨è„šæœ¬ + æœåŠ¡åŒ…è£…å™¨ + docs + web) / frontend/dist(可选,-NoFrontendDist ä¸æ‰“) / db / README
       Windows æœåŠ¡åŒ…è£…å™¨ WinSW:优先用 deploy-templates\tools\winsw.exe ç¼“存(校验 SHA-256),
       ç¼ºå¤±æ—¶è‡ªåŠ¨ä¸‹è½½ v2.12.0 x64 åˆ°è¯¥ç¼“存目录;随包输出为 backend\trafficAudit.exe
    5) å¯¹ç»„装后的整包再扫一遍明文密钥
  ä¿®è®¢èƒŒæ™¯ï¼ˆ2026-10-03):2026-10-02 æ‰‹å·¥äº§å‡ºçš„包缺 application.yml / docs / db / å¯åŠ¨è„šæœ¬ï¼Œ
  ä¸”没有把 dist åŒæ­¥åˆ° backend\web,部署机启动直接报
  "java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)"。
  æœ¬ç‰ˆæŠŠä¸Šè¿°æ–‡ä»¶å…¨éƒ¨è¡¥é½ï¼Œå¹¶æŒ‰ jar å†…置占位符配置重新构建,避免再次出现。
#>
param(
  [string]$OutRoot = 'E:\试验',
  [switch]$SkipFrontend
  [string]$OutRoot = '',
  [switch]$SkipFrontend,
  [switch]$NoFrontendDist,  # å•端口发布:不打独立的 frontend\dist ç›®å½•,但仍构建/同步 backend\web(单端口必需)
  [switch]$SkipBackend,     # å¤ç”¨å·²å­˜åœ¨çš„ jar(不重编译、不动源码配置),仅用于补全/重组发布包
  [switch]$SkipServiceWrapper   # ä¸æŠŠ WinSW æœåŠ¡åŒ…è£…å™¨æ‰“è¿›åŒ…ï¼ˆå³ä¸æä¾›â€œæ³¨å†Œæˆ Windows æœåŠ¡â€çš„å¼€æœºè‡ªå¯ï¼‰
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$tplDir = Join-Path $root 'deploy-templates'                  # æ‰“包模板件(已入库,2026-09-27 èµ·ï¼‰
$jarRel = 'traffic-audit-server\target\traffic-audit-server-1.0.0-SNAPSHOT.jar'
$jar = Join-Path $root $jarRel
$pomPath = Join-Path $root 'traffic-audit-server\pom.xml'
if (-not (Test-Path $pomPath)) { throw "Missing Maven project: $pomPath" }
[xml]$pom = Get-Content -LiteralPath $pomPath -Raw -Encoding UTF8
$productVersion = [string]$pom.project.version
if (-not $productVersion) { throw 'Missing <version> in traffic-audit-server\pom.xml' }
if ($productVersion -match '(?i)-SNAPSHOT$') { throw "Release builds must not use a SNAPSHOT version: $productVersion" }
if (-not $OutRoot) { $OutRoot = Join-Path $root 'deploy' }      # é»˜è®¤è¾“出到仓库 deploy\(已 gitignore)
$tplDir    = Join-Path $root 'deploy-templates'                  # æ‰“包模板件(已入库)
$tplJarCfg = Join-Path $tplDir 'jar-application.yml'             # è¿› jar çš„占位符配置
$srcCfg    = Join-Path $root 'traffic-audit-server\src\main\resources\application.yml'
$jarRel    = 'traffic-audit-server\target\traffic-audit-server.jar'
$jar       = Join-Path $root $jarRel
$webDist   = Join-Path $root 'traffic-audit-web\dist'
Write-Host '[1/5] æž„建后端(mvn clean package -DskipTests)...'
Push-Location $root
cmd /c "mvn.cmd -q clean package -DskipTests -f traffic-audit-server\pom.xml > pack-build.log 2>&1"
$rc = $LASTEXITCODE
Pop-Location
if ($rc -ne 0) { throw '后端构建失败,详见 pack-build.log' }
if (-not (Test-Path $tplJarCfg)) { throw "缺少打包模板:$tplJarCfg" }
# ---------- [1/5] ç”¨å ä½ç¬¦é…ç½®æž„建 jar,构建后还原开发机真实配置 ----------
if ($SkipBackend) {
  Write-Host '[1/5] å·²æŒ‡å®š -SkipBackend,跳过后端构建,复用现有 jar'
  if (-not (Test-Path $jar)) { throw "未找到现有 jar:$jar(去掉 -SkipBackend é‡æ–°æž„建)" }
} else {
Write-Host '[1/5] æž„建后端(mvn clean package -DskipTests,构建期使用占位符配置)...'
$backupCfg = $null
$hadCfg = Test-Path $srcCfg
try {
  if ($hadCfg) {
    $backupCfg = [System.IO.Path]::GetTempFileName()
    Copy-Item $srcCfg $backupCfg -Force
  }
  Copy-Item $tplJarCfg $srcCfg -Force
  Push-Location $root
  try {
    cmd /c "mvn.cmd -q clean package -DskipTests -f traffic-audit-server\pom.xml > pack-build.log 2>&1"
    $rc = $LASTEXITCODE
  } finally { Pop-Location }
  if ($rc -ne 0) { throw '后端构建失败,详见 pack-build.log' }
} finally {
  if ($backupCfg) {
    Copy-Item $backupCfg $srcCfg -Force
    Remove-Item $backupCfg -Force -ErrorAction SilentlyContinue
    Write-Host '      å·²è¿˜åŽŸå¼€å‘æœº src\main\resources\application.yml'
  } elseif (-not $hadCfg) {
    Remove-Item $srcCfg -Force -ErrorAction SilentlyContinue
    Write-Host '      æ³¨æ„ï¼šæž„建前源码目录本来没有 application.yml,已清掉临时占位符文件'
  }
}
if (-not (Test-Path $jar)) { throw "未找到构建产物:$jar" }
}
Write-Host ("      äº§ç‰©ï¼š{0:N1} MB" -f ((Get-Item $jar).Length / 1MB))
# ---------- [2/5] åŽ»å¯†è‡ªæ£€ï¼šjar å†…置配置必须全是占位符 ----------
Write-Host '[2/5] åŽ»å¯†è‡ªæ£€ï¼šè§£åŽ‹ jar æ£€æŸ¥ BOOT-INF/classes/application.yml ...'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($jar)
@@ -38,45 +92,138 @@
  $sr = New-Object System.IO.StreamReader($entry.Open())
  $text = $sr.ReadToEnd()
  $sr.Close()
  $versionEntry = $zip.Entries | Where-Object { $_.FullName -eq 'BOOT-INF/classes/app-version.properties' }
  if (-not $versionEntry) { throw 'jar å†…缺少 BOOT-INF/classes/app-version.properties' }
  $vr = New-Object System.IO.StreamReader($versionEntry.Open())
  $versionText = $vr.ReadToEnd()
  $vr.Close()
  $manifestEntry = $zip.Entries | Where-Object { $_.FullName -eq 'META-INF/MANIFEST.MF' }
  if (-not $manifestEntry) { throw 'jar å†…缺少 META-INF/MANIFEST.MF' }
  $mr = New-Object System.IO.StreamReader($manifestEntry.Open())
  $manifestText = $mr.ReadToEnd()
  $mr.Close()
} finally { $zip.Dispose() }
$bad = @()
if ($text -match 'sk-[A-Za-z0-9]{16,}') { $bad += '疑似明文 API key(sk-…)' }
foreach ($line in ($text -split "`n")) {
  if ($line -match '^\s*(password|api-key|secret)\s*:\s*(.+)$') {
    $v = $matches[2].Trim().Trim('"')
    if ($v -and -not $v.StartsWith('${')) { $bad += ("$($matches[1]) ä¸æ˜¯å ä½ç¬¦") }
function Get-SecretHits([string]$ymlText) {
  $hits = @()
  if ($ymlText -match 'sk-[A-Za-z0-9]{16,}') { $hits += '疑似明文 API key(sk-...)' }
  foreach ($line in ($ymlText -split "`n")) {
    if ($line -match '^\s*(password|api-key|secret)\s*:\s*(.+)$') {
      $v = $matches[2].Trim().Trim('"')
      if ($v -and -not $v.StartsWith('${')) { $hits += ("$($matches[1]) ä¸æ˜¯å ä½ç¬¦") }
    }
  }
  return $hits
}
$bad = Get-SecretHits $text
if ($bad.Count -gt 0) {
  throw ('去密自检未通过:' + ($bad -join ';') + '。请检查 traffic-audit-server\src\main\resources\application.yml')
  throw ('去密自检未通过:' + ($bad -join ';') + "。请检查 $tplJarCfg")
}
if ($text -notmatch 'password:\s*\$\{TRAFFIC_DB_PASSWORD') {
  Write-Host '      è­¦å‘Šï¼šjar å†…ç½® password ä¸æ˜¯ TRAFFIC_DB_PASSWORD å ä½ç¬¦å½¢å¼ï¼Œè¯·ç¡®è®¤æ¨¡æ¿ã€‚'
}
Write-Host '      é€šè¿‡ï¼šjar å†…置配置全部为占位符,无明文密钥/口令。'
$builtVersion = ''
if ($versionText -match '(?m)^\s*version\s*=\s*(.+?)\s*$') { $builtVersion = $matches[1].Trim() }
if ($builtVersion -ne $productVersion) {
  throw "版本不一致:pom.xml=$productVersion, app-version.properties=$builtVersion"
}
$manifestVersion = ''
if ($manifestText -match '(?m)^Implementation-Version:\s*(.+?)\s*$') { $manifestVersion = $matches[1].Trim() }
if ($manifestVersion -ne $productVersion) {
  throw "版本不一致:pom.xml=$productVersion, MANIFEST.MF=$manifestVersion"
}
Write-Host "      ç‰ˆæœ¬è‡ªæ£€é€šè¿‡ï¼š$productVersion (pom / app-version.properties / MANIFEST.MF)"
# ---------- [3/5] å‰ç«¯æž„建 ----------
# å•端口部署必须有 backend\web,所以即使 -NoFrontendDist ä¹Ÿéœ€è¦ dist;只有 -SkipFrontend æ‰å¤ç”¨çŽ°æœ‰ dist。
Write-Host '[3/5] æž„建前端(npm run build,可 -SkipFrontend è·³è¿‡ï¼‰...'
if (-not $SkipFrontend) {
  Push-Location (Join-Path $root 'traffic-audit-web')
  cmd /c "npm.cmd run build > ..\pack-front.log 2>&1"
  $rc2 = $LASTEXITCODE
  Pop-Location
  try {
    cmd /c "npm.cmd run build > ..\pack-front.log 2>&1"
    $rc2 = $LASTEXITCODE
  } finally { Pop-Location }
  if ($rc2 -ne 0) { throw '前端构建失败,详见 pack-front.log' }
  Write-Host '      å‰ç«¯ dist å·²é‡å»º'
} else {
  Write-Host '      å·²æŒ‰ -SkipFrontend è·³è¿‡ï¼Œä½¿ç”¨çŽ°æœ‰ dist'
}
if (-not (Test-Path $webDist)) { throw "未找到前端产物:$webDist(去掉 -SkipFrontend é‡æ–°æž„建)" }
# ---------- WinSW(Windows æœåŠ¡åŒ…è£…å™¨ï¼‰å‡†å¤‡ï¼šæœ¬åœ°ç¼“å­˜ä¼˜å…ˆï¼Œç¼ºå¤±æ—¶ä»Žå®˜æ–¹å‘å¸ƒé¡µä¸‹è½½å¹¶æ ¡éªŒ SHA-256 ----------
# WinSW v2.12.0 å®˜æ–¹äº§ç‰©æœªåš Authenticode ç­¾åï¼Œæ‰€ä»¥ä»¥å›ºå®š SHA-256 ä½œä¸ºå®Œæ•´æ€§æ ¡éªŒã€‚
$winswUrl    = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe'
$winswSha256 = '05B82D46AD331CC16BDC00DE5C6332C1EF818DF8CEEFCD49C726553209B3A0DA'
$winswSrc    = Join-Path $tplDir 'tools\winsw.exe'
function Test-WinswCache {
  param([string]$Path)
  if (-not (Test-Path -LiteralPath $Path)) { return $false }
  $hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
  if ($hash -ne $winswSha256) {
    Write-Host ('      è­¦å‘Šï¼šWinSW ç¼“å­˜ SHA-256 ä¸åŒ¹é…ï¼Œå°†é‡æ–°ä¸‹è½½ï¼ˆæœŸæœ› {0}...,实际 {1}...)' -f $winswSha256.Substring(0, 12), $hash.Substring(0, 12))
    return $false
  }
  return $true
}
if ($SkipServiceWrapper) {
  Write-Host '      å·²æŒ‰ -SkipServiceWrapper è·³è¿‡ï¼šåŒ…内不含 Windows æœåŠ¡æ–¹å¼å¼€æœºè‡ªå¯'
} else {
  if (-not (Test-WinswCache $winswSrc)) {
    New-Item -ItemType Directory -Force -Path (Split-Path -Parent $winswSrc) | Out-Null
    Write-Host '      ä¸‹è½½ WinSW v2.12.0 x64 ...'
    try {
      [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
      Invoke-WebRequest -Uri $winswUrl -OutFile $winswSrc -UseBasicParsing
    } catch {
      throw ("下载 WinSW å¤±è´¥ï¼š{0}。`n请手动下载 {1} å­˜ä¸º {2} åŽé‡è¯•,或用 -SkipServiceWrapper è·³è¿‡ã€‚" -f $_.Exception.Message, $winswUrl, $winswSrc)
    }
    if (-not (Test-WinswCache $winswSrc)) { throw "WinSW æ ¡éªŒå¤±è´¥ï¼Œå·²ä¸­æ­¢æ‰“包:$winswSrc" }
  }
  Write-Host ('      WinSW å°±ç»ªï¼ˆSHA-256 å·²æ ¡éªŒï¼‰ï¼š{0:N1} MB' -f ((Get-Item -LiteralPath $winswSrc).Length / 1MB))
}
# ---------- [4/5] ç»„装完整部署目录 ----------
Write-Host '[4/5] ç»„装部署目录...'
$stamp = Get-Date -Format 'yyyyMMdd'
$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp)
$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion)
$n = 2
while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-" + $n); $n++ }
New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion + "-" + $n); $n++ }
if ($NoFrontendDist) {
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'db') | Out-Null
} else {
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
}
Copy-Item $jar (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\application.yml') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend.cmd') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend.sh') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $root 'traffic-audit-web\dist') (Join-Path $dest 'frontend') -Recurse -Force
Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend-service.cmd') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\install-autostart.ps1') (Join-Path $dest 'backend') -Force
if (-not $SkipServiceWrapper) {
  # WinSW è¦æ±‚配置文件名与 exe åŒåï¼štrafficAudit.exe è¯»åŒç›®å½•çš„ trafficAudit.xml
  Copy-Item -LiteralPath $winswSrc (Join-Path $dest 'backend\trafficAudit.exe') -Force
  Copy-Item (Join-Path $tplDir 'backend\trafficAudit.xml') (Join-Path $dest 'backend') -Force
  Copy-Item (Join-Path $tplDir 'backend\install-service.ps1') (Join-Path $dest 'backend') -Force
  Copy-Item (Join-Path $tplDir 'backend\install-service.cmd') (Join-Path $dest 'backend') -Force
}
if (-not $NoFrontendDist) {
  Copy-Item $webDist (Join-Path $dest 'frontend') -Recurse -Force
  Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
}
Copy-Item (Join-Path $tplDir 'db\*.sql') (Join-Path $dest 'db') -Force
# å•端口 8090 æ¨¡å¼ï¼šåŽç«¯ä»Ž jar åŒçº§ web\ è¯»å‰ç«¯é™æ€æ–‡ä»¶ï¼Œå¿…须和 dist å†…容一致。
# å•端口发布(-NoFrontendDist)同样必须同步,否则页面 404。
Copy-Item $webDist (Join-Path $dest 'backend\web') -Recurse -Force
# docs:运行时模板 + ç”¨æˆ·æ–‡æ¡£ï¼›æŽ’除开发过程文档与缓存/备份(减小体积、避免把内部清单带出去)
$docsDest = Join-Path $dest 'backend\docs'
@@ -84,15 +231,31 @@
cmd /c "robocopy `"$root\docs`" `"$docsDest`" /E /NFL /NDL /NJH /NJS /NP /XD _cache å·¥ä½œæ—¥ç»“ åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š é—®é¢˜æ±‡æ€» /XF ç”Ÿæˆ_*.xlsx _bak_* >nul"
if ($LASTEXITCODE -ge 8) { throw "docs å¤åˆ¶å¤±è´¥ï¼ˆrobocopy è¿”回 $LASTEXITCODE)" }
# db è„šæœ¬ä¹Ÿæ”¾ä¸€ä»½åˆ° docs(与旧包一致:docs/init.sql ç­‰ï¼‰
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql') $docsDest -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_vehicle_adjustment_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
# README:用旧包模板并替换日期占位
# README:用模板并替换日期占位
$readme = Get-Content (Join-Path $tplDir 'README-部署说明.txt') -Raw -Encoding UTF8
$readme = $readme.Replace('{stamp}', $stamp)
$readme = $readme.Replace('{stamp}', $stamp).Replace('{version}', $productVersion)
Set-Content -LiteralPath (Join-Path $dest 'README-部署说明.txt') -Value $readme -Encoding UTF8
Write-Host '[5/5] æ±‡æ€»...'
# ---------- [5/5] æ•´åŒ…明文密钥扫描 + æ±‡æ€» ----------
Write-Host '[5/5] æ•´åŒ…扫描 + æ±‡æ€»...'
$leak = @()
foreach ($f in (Get-ChildItem (Join-Path $dest 'backend') -File | Where-Object { $_.Extension -in '.yml', '.yaml', '.cmd', '.sh', '.txt' })) {
  $t = Get-Content $f.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
  if ($t -and $t -match 'sk-[A-Za-z0-9]{16,}') { $leak += $f.Name + '(疑似明文 API key)' }
}
if ($leak.Count -gt 0) { throw ('整包扫描未通过:' + ($leak -join ';')) }
$files = Get-ChildItem $dest -Recurse -File
$mb = (($files | Measure-Object Length -Sum).Sum / 1MB)
Write-Host ("      è¾“出目录:{0}" -f $dest)
Write-Host ("      æ–‡ä»¶æ•°ï¼š{0} 合计:{1:N2} MB" -f $files.Count, (($files | Measure-Object Length -Sum).Sum / 1MB))
Write-Host '      æé†’:把包拷到部署机后,只需改 backend\application.yml é‡Œçš„æ•°æ®åº“三处 + éœ€è¦æ—¶å¡« deepseek.api-key。'
Write-Host ("      æ–‡ä»¶æ•°ï¼š{0}  åˆè®¡ï¼š{1:N2} MB" -f $files.Count, $mb)
$zipPath = $dest + '.zip'
if (Get-Command Compress-Archive -ErrorAction SilentlyContinue) {
  if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
  Compress-Archive -Path (Join-Path $dest '*') -DestinationPath $zipPath -CompressionLevel Optimal
  Write-Host ("      åŽ‹ç¼©åŒ…ï¼š{0}({1:N2} MB)" -f $zipPath, ((Get-Item $zipPath).Length / 1MB))
}
Write-Host '      æé†’:拷到部署机后,务必先改 backend\application.yml çš„ spring.datasource(url/username/password)再启动。'
package-deploy.py
@@ -2,16 +2,21 @@
"""TrafficAudit ä¸€é”®æ‰“包脚本:前端构建 -> å†…嵌进后端 jar -> ç»„装部署包 -> æ‰“ zip
用法:python package-deploy.py [--no-build]
"""
import io, os, sys, shutil, zipfile, subprocess, datetime
import io, os, sys, shutil, zipfile, subprocess, datetime, xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.abspath(__file__))
WEB = os.path.join(ROOT, "traffic-audit-web")
SERVER = os.path.join(ROOT, "traffic-audit-server")
STATIC = os.path.join(SERVER, "src", "main", "resources", "static")
_pom_tree = ET.parse(os.path.join(SERVER, "pom.xml"))
_pom_ns = {"m": "http://maven.apache.org/POM/4.0.0"}
PRODUCT_VERSION = (_pom_tree.getroot().findtext("m:version", namespaces=_pom_ns) or "").strip()
if not PRODUCT_VERSION:
    raise SystemExit("Missing <version> in traffic-audit-server/pom.xml")
STAMP = datetime.datetime.now().strftime("%Y%m%d")
STAGE = os.path.join(ROOT, "deploy", "traffic-audit-deploy-" + STAMP)
ZIP = os.path.join(ROOT, "traffic-audit-deploy-" + STAMP + ".zip")
JAR_NAME = "traffic-audit-server-1.0.0-SNAPSHOT.jar"
STAGE = os.path.join(ROOT, "deploy", "traffic-audit-deploy-" + STAMP + "-v" + PRODUCT_VERSION)
ZIP = os.path.join(ROOT, "traffic-audit-deploy-" + STAMP + "-v" + PRODUCT_VERSION + ".zip")
JAR_NAME = "traffic-audit-server.jar"
SKIP_DOC_DIRS = {"ai_demo", "usage_screenshots", "功能测试报告", "工作日结"}
def rmtree_force(path):
@@ -97,7 +102,7 @@
    io.open(os.path.join(be, "start-backend.cmd"), "w", encoding="utf-8", newline="\r\n").write(START_CMD)
    io.open(os.path.join(be, "start-backend.sh"), "w", encoding="utf-8", newline="\n").write(START_SH)
    io.open(os.path.join(STAGE, "frontend", "nginx.conf.example"), "w", encoding="utf-8", newline="\n").write(NGINX)
    io.open(os.path.join(STAGE, "README-部署说明.txt"), "w", encoding="utf-8", newline="\r\n").write(README)
    io.open(os.path.join(STAGE, "README-部署说明.txt"), "w", encoding="utf-8", newline="\r\n").write(README.replace("{version}", PRODUCT_VERSION))
    print("[5/6] æ‰“ zip ...")
    if os.path.isfile(ZIP):
@@ -183,14 +188,14 @@
echo  å‰ç«¯å·²å†…嵌,直接访问 8090 å³å¯ï¼ˆä¸éœ€è¦ nginx)
echo  æ—¥å¿—显示在本窗口,关闭窗口即停止服务
echo ============================================
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
java -jar traffic-audit-server.jar
pause
"""
START_SH = u"""#!/bin/sh
cd "$(dirname "$0")" || exit 1
echo "TrafficAudit å¯åŠ¨ä¸­ï¼šhttp://localhost:8090 ï¼ˆCtrl+C åœæ­¢ï¼‰"
java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar 2>&1 | tee app.log
java -jar traffic-audit-server.jar 2>&1 | tee app.log
"""
NGINX = u"""# å‰ç«¯ nginx é…ç½®ç¤ºä¾‹ï¼ˆåªæœ‰åœ¨ä¸æƒ³ç”¨ã€Œjar å†…嵌前端」时才需要)
@@ -210,12 +215,12 @@
}
"""
README = u"""TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp})
README = u"""TrafficAudit äº¤é€šè´§è¿ç»Ÿè®¡å®¡æ ¸ç³»ç»Ÿ â€”— éƒ¨ç½²åŒ…({stamp},版本 v{version})
========================================================
【目录结构】
  backend/
    traffic-audit-server-1.0.0-SNAPSHOT.jar  åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**)
    traffic-audit-server.jar                 åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**,固定文件名)
    docs/                                    æŠ¥è¡¨æ¨¡æ¿ä¸Žè¿è¡Œæ•°æ®ç›®å½•(必需,勿删)
    application.yml                          å¤–部配置(改这里即可,无需重新打包)
    start-backend.cmd / start-backend.sh     å¯åŠ¨è„šæœ¬
traffic-audit-server/pom.xml
@@ -10,7 +10,7 @@
    </parent>
    <groupId>com.trafficaudit</groupId>
    <artifactId>traffic-audit-server</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <name>traffic-audit-server</name>
    <properties>
@@ -19,6 +19,7 @@
        <hutool.version>5.4.0</hutool.version>
        <easyexcel.version>4.0.3</easyexcel.version>
        <jjwt.version>0.9.1</jjwt.version>
        <jfreechart.version>1.5.4</jfreechart.version>
    </properties>
    <dependencies>
        <dependency>
@@ -59,6 +60,11 @@
            <version>${hutool.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jfree</groupId>
            <artifactId>jfreechart</artifactId>
            <version>${jfreechart.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
@@ -70,8 +76,36 @@
        </dependency>
    </dependencies>
    <build>
        <finalName>traffic-audit-server</finalName>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>false</filtering>
                <excludes>
                    <exclude>app-version.properties</exclude>
                </excludes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>app-version.properties</include>
                </includes>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java
New file
@@ -0,0 +1,116 @@
package com.trafficaudit.holiday;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
public final class HolidayConstants {
    public static final int COMPARISON_BASE_YEAR = 2026;
    public static final String TYPE_SPRING_FESTIVAL = "SPRING_FESTIVAL";
    public static final String TYPE_LABOR_DAY = "LABOR_DAY";
    public static final String TYPE_NATIONAL_DAY = "NATIONAL_DAY";
    public static final String CATEGORY_HOLIDAY = "HOLIDAY";
    public static final String CATEGORY_NON_HOLIDAY = "NON_HOLIDAY";
    public static final String MODE_RAIL = "RAIL";
    public static final String MODE_CIVIL_AVIATION = "CIVIL_AVIATION";
    public static final String MODE_ROAD = "ROAD";
    public static final String MODE_WATER = "WATER";
    public static final String ROAD_EXPRESSWAY = "EXPRESSWAY";
    public static final String ROAD_NATIONAL_PROVINCIAL = "NATIONAL_PROVINCIAL";
    public static final String VEHICLE_SMALL_PASSENGER = "SMALL_PASSENGER";
    public static final String VEHICLE_MEDIUM_SMALL_PASSENGER = "MEDIUM_SMALL_PASSENGER";
    public static final Map<String, String> HOLIDAY_TYPE_LABELS;
    public static final Map<String, String> DATE_CATEGORY_LABELS;
    public static final Map<String, String> TRANSPORT_MODE_LABELS;
    public static final Map<String, String> ROAD_CATEGORY_LABELS;
    public static final Map<String, String> VEHICLE_TYPE_LABELS;
    private static final Set<String> HOLIDAY_TYPES;
    private static final Set<String> DATE_CATEGORIES;
    private static final Set<String> TRANSPORT_MODES;
    private static final Set<String> ROAD_CATEGORIES;
    private static final Set<String> VEHICLE_TYPES;
    static {
        Map<String, String> holidayTypes = new LinkedHashMap<>();
        holidayTypes.put(TYPE_SPRING_FESTIVAL, "春节");
        holidayTypes.put(TYPE_LABOR_DAY, "五一");
        holidayTypes.put(TYPE_NATIONAL_DAY, "十一");
        HOLIDAY_TYPE_LABELS = Collections.unmodifiableMap(holidayTypes);
        HOLIDAY_TYPES = new LinkedHashSet<>(holidayTypes.keySet());
        Map<String, String> dateCategories = new LinkedHashMap<>();
        dateCategories.put(CATEGORY_HOLIDAY, "节假日");
        dateCategories.put(CATEGORY_NON_HOLIDAY, "非节假日");
        DATE_CATEGORY_LABELS = Collections.unmodifiableMap(dateCategories);
        DATE_CATEGORIES = new LinkedHashSet<>(dateCategories.keySet());
        Map<String, String> transportModes = new LinkedHashMap<>();
        transportModes.put(MODE_RAIL, "铁路");
        transportModes.put(MODE_CIVIL_AVIATION, "民航");
        transportModes.put(MODE_ROAD, "道路");
        transportModes.put(MODE_WATER, "æ°´è·¯");
        TRANSPORT_MODE_LABELS = Collections.unmodifiableMap(transportModes);
        TRANSPORT_MODES = new LinkedHashSet<>(transportModes.keySet());
        Map<String, String> roadCategories = new LinkedHashMap<>();
        roadCategories.put(ROAD_EXPRESSWAY, "高速公路");
        roadCategories.put(ROAD_NATIONAL_PROVINCIAL, "普通国省道");
        ROAD_CATEGORY_LABELS = Collections.unmodifiableMap(roadCategories);
        ROAD_CATEGORIES = new LinkedHashSet<>(roadCategories.keySet());
        Map<String, String> vehicleTypes = new LinkedHashMap<>();
        vehicleTypes.put(VEHICLE_SMALL_PASSENGER, "小客车");
        vehicleTypes.put(VEHICLE_MEDIUM_SMALL_PASSENGER, "中小客车");
        VEHICLE_TYPE_LABELS = Collections.unmodifiableMap(vehicleTypes);
        VEHICLE_TYPES = new LinkedHashSet<>(vehicleTypes.keySet());
    }
    private HolidayConstants() {
    }
    public static boolean supportsComparisonBase(int year) {
        return year == COMPARISON_BASE_YEAR;
    }
    public static boolean isValidHolidayType(String value) {
        return HOLIDAY_TYPES.contains(value);
    }
    public static boolean isValidDateCategory(String value) {
        return DATE_CATEGORIES.contains(value);
    }
    public static boolean isValidTransportMode(String value) {
        return TRANSPORT_MODES.contains(value);
    }
    public static boolean isValidRoadCategory(String value) {
        return ROAD_CATEGORIES.contains(value);
    }
    public static boolean isValidVehicleType(String value) {
        return VEHICLE_TYPES.contains(value);
    }
    public static Map<String, Object> optionMap() {
        Map<String, Object> options = new LinkedHashMap<>();
        options.put("holidayTypes", HOLIDAY_TYPE_LABELS);
        options.put("dateCategories", DATE_CATEGORY_LABELS);
        options.put("transportModes", TRANSPORT_MODE_LABELS);
        options.put("roadCategories", ROAD_CATEGORY_LABELS);
        options.put("vehicleTypes", VEHICLE_TYPE_LABELS);
        options.put("years", Arrays.asList(2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030));
        return options;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java
New file
@@ -0,0 +1,70 @@
package com.trafficaudit.holiday;
import org.apache.poi.ss.util.NumberToTextConverter;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
public final class HolidayNumberUtils {
    public static final MathContext EXCEL_MC = new MathContext(15, RoundingMode.HALF_UP);
    public static final BigDecimal ZERO = BigDecimal.ZERO;
    private HolidayNumberUtils() {
    }
    public static BigDecimal excelValue(double value) {
        return normalize(new BigDecimal(NumberToTextConverter.toText(value)));
    }
    public static BigDecimal decimalValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return normalize(new BigDecimal(value.toString().trim()));
    }
    public static BigDecimal normalize(BigDecimal value) {
        if (value == null) return null;
        BigDecimal rounded = value.round(EXCEL_MC);
        return rounded.signum() == 0 ? ZERO : rounded.stripTrailingZeros();
    }
    public static BigDecimal valueOrZero(BigDecimal value) {
        return value == null ? ZERO : value;
    }
    public static BigDecimal sumNullable(BigDecimal left, BigDecimal right) {
        if (left == null && right == null) return null;
        return normalize(valueOrZero(left).add(valueOrZero(right)));
    }
    public static BigDecimal multiply(BigDecimal left, BigDecimal right) {
        if (left == null || right == null) return null;
        return normalize(left.multiply(right));
    }
    public static BigDecimal divide(BigDecimal dividend, BigDecimal divisor) {
        if (dividend == null || divisor == null || divisor.signum() == 0) return null;
        return normalize(dividend.divide(divisor, EXCEL_MC));
    }
    public static boolean sameValue(BigDecimal left, BigDecimal right) {
        if (left == null || right == null) return false;
        return normalize(left).compareTo(normalize(right)) == 0;
    }
    public static String changeText(BigDecimal value) {
        if (value == null) return "";
        return normalize(value).toPlainString();
    }
    public static String format1(BigDecimal value) {
        if (value == null) return "";
        return value.setScale(1, RoundingMode.HALF_UP).toPlainString();
    }
    public static String format2(BigDecimal value) {
        if (value == null) return "";
        return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java
New file
@@ -0,0 +1,95 @@
package com.trafficaudit.holiday.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import com.trafficaudit.holiday.service.HolidayConfigService;
import com.trafficaudit.security.utils.JwtUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/holiday/config")
public class HolidayConfigController {
    @Resource
    private HolidayConfigService configService;
    @Resource
    private JwtUtils jwtUtils;
    @GetMapping("/options")
    public Result<Map<String, Object>> options() {
        return Result.ok(HolidayConstants.optionMap());
    }
    @GetMapping("/holidays")
    public Result<List<HolidayCalendar>> holidays(@RequestParam(value = "year", required = false) Integer year,
                                                   @RequestParam(value = "holidayType", required = false) String holidayType) {
        return Result.ok(configService.listHolidays(year, holidayType));
    }
    @PostMapping("/holidays/save")
    public Result<HolidayCalendar> saveHoliday(@RequestHeader(value = "Authorization", required = false) String auth,
                                               @RequestBody Map<String, Object> params) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†èŠ‚å‡æ—¥é…ç½®");
        try {
            return Result.ok(configService.saveHoliday(params));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/holidays/delete")
    public Result<Void> deleteHoliday(@RequestHeader(value = "Authorization", required = false) String auth,
                                      @RequestParam("id") Long id) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†èŠ‚å‡æ—¥é…ç½®");
        try {
            configService.deleteHoliday(id);
            return Result.ok();
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @GetMapping("/coefficients")
    public Result<List<HolidayPassengerCoefficient>> coefficients(@RequestParam(value = "year", required = false) Integer year) {
        return Result.ok(configService.listCoefficients(year));
    }
    @PostMapping("/coefficients/save")
    public Result<HolidayPassengerCoefficient> saveCoefficient(@RequestHeader(value = "Authorization", required = false) String auth,
                                                               @RequestBody Map<String, Object> params) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†å®¢è¿ç³»æ•°");
        try {
            return Result.ok(configService.saveCoefficient(params));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/coefficients/delete")
    public Result<Void> deleteCoefficient(@RequestHeader(value = "Authorization", required = false) String auth,
                                          @RequestParam("id") Long id) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç®¡ç†å®¢è¿ç³»æ•°");
        try {
            configService.deleteCoefficient(id);
            return Result.ok();
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    private boolean isAdmin(String auth) {
        return "admin".equals(jwtUtils.resolveUsername(auth));
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java
New file
@@ -0,0 +1,98 @@
package com.trafficaudit.holiday.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.holiday.service.HolidayBulletinService;
import com.trafficaudit.holiday.service.HolidayTrendService;
import com.trafficaudit.holiday.service.HolidayStatService;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.net.URLEncoder;
import java.time.LocalDate;
import java.util.Map;
@RestController
@RequestMapping("/api/holiday")
public class HolidayStatController {
    @Resource
    private HolidayStatService statService;
    @Resource
    private HolidayBulletinService bulletinService;
    @Resource
    private HolidayTrendService trendService;
    @GetMapping("/overview")
    public Result<Map<String, Object>> overview(@RequestParam("year") int year,
                                                 @RequestParam("holidayType") String holidayType) {
        try {
            return Result.ok(statService.overview(year, holidayType));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/import")
    public Result<Map<String, Object>> importWorkbook(@RequestParam(value = "file", required = false) MultipartFile file,
                                                       @RequestParam("year") int year,
                                                       @RequestParam("holidayType") String holidayType,
                                                       @RequestParam(value = "allowHistoricalOverwrite", defaultValue = "false") boolean allowHistoricalOverwrite) {
        try {
            return Result.ok(statService.importWorkbook(file, year, holidayType, allowHistoricalOverwrite));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @GetMapping("/export")
    public ResponseEntity<byte[]> exportWorkbook(@RequestParam("year") int year,
                                                  @RequestParam("holidayType") String holidayType) throws Exception {
        byte[] data = statService.exportWorkbook(year, holidayType);
        String fileName = year + "å¹´" + holidayTypeFileLabel(holidayType) + "数据.xlsx";
        String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
                .body(data);
    }
    @GetMapping("/bulletin")
    public ResponseEntity<byte[]> bulletin(@RequestParam(value = "date", required = false) String date) throws Exception {
        LocalDate targetDate = (date == null || date.trim().isEmpty())
                ? LocalDate.now().minusDays(1) : LocalDate.parse(date);
        byte[] data = bulletinService.generate(targetDate);
        String fileName = String.format("湖北省国庆假期综合运输客流快报(10月%d日).docx", targetDate.getDayOfMonth());
        String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
                .body(data);
    }
    @GetMapping("/trend")
    public ResponseEntity<byte[]> trend(@RequestParam("year") int year,
                                        @RequestParam("holidayType") String holidayType) throws Exception {
        byte[] data = trendService.generate(year, holidayType);
        String fileName = year + "å¹´" + holidayTypeFileLabel(holidayType) + "假期全省综合运输客流量走势图.docx";
        String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
                .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
                .body(data);
    }
    private String holidayTypeFileLabel(String holidayType) {
        if ("SPRING_FESTIVAL".equals(holidayType)) return "春节";
        if ("LABOR_DAY".equals(holidayType)) return "五一";
        return "十一";
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java
New file
@@ -0,0 +1,71 @@
package com.trafficaudit.holiday.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.service.HolidayVehicleAdjustmentService;
import com.trafficaudit.security.utils.JwtUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/holiday/adjustments")
public class HolidayVehicleAdjustmentController {
    @Resource
    private HolidayVehicleAdjustmentService adjustmentService;
    @Resource
    private JwtUtils jwtUtils;
    @GetMapping
    public Result<List<HolidayVehicleAdjustment>> list(@RequestParam(value = "year", required = false) Integer year) {
        return Result.ok(adjustmentService.list(year));
    }
    @GetMapping("/source-value")
    public Result<BigDecimal> sourceValue(@RequestParam("year") Integer year,
                                         @RequestParam("flowDate") String flowDate) {
        try {
            return Result.ok(adjustmentService.findOriginalValue(year, LocalDate.parse(flowDate)));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/save")
    public Result<HolidayVehicleAdjustment> save(@RequestHeader(value = "Authorization", required = false) String auth,
                                                 @RequestBody Map<String, Object> params) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç»´æŠ¤å¾€å¹´å›½çœé“数据调整");
        try {
            return Result.ok(adjustmentService.save(params));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/delete")
    public Result<Void> delete(@RequestHeader(value = "Authorization", required = false) String auth,
                               @RequestParam("id") Long id) {
        if (!isAdmin(auth)) return Result.error(403, "仅 admin å¯ä»¥ç»´æŠ¤å¾€å¹´å›½çœé“数据调整");
        try {
            adjustmentService.delete(id);
            return Result.ok();
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    private boolean isAdmin(String auth) {
        return "admin".equals(jwtUtils.resolveUsername(auth));
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java
New file
@@ -0,0 +1,22 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_calendar")
public class HolidayCalendar {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private String holidayType;
    private LocalDate holidayDate;
    private Integer dayIndex;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java
New file
@@ -0,0 +1,21 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("holiday_passenger_coefficient")
public class HolidayPassengerCoefficient {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private String dateCategory;
    private BigDecimal coefficient;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java
New file
@@ -0,0 +1,23 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_passenger_flow")
public class HolidayPassengerFlow {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private LocalDate flowDate;
    private String transportMode;
    private BigDecimal passengerCount;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java
New file
@@ -0,0 +1,26 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_vehicle_adjustment")
public class HolidayVehicleAdjustment {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private LocalDate flowDate;
    private BigDecimal adjustedValue;
    @TableField(exist = false)
    private BigDecimal originalValue;
    private String reason;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java
New file
@@ -0,0 +1,28 @@
package com.trafficaudit.holiday.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("holiday_vehicle_flow")
public class HolidayVehicleFlow {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Integer year;
    private LocalDate flowDate;
    private String roadCategory;
    private String vehicleType;
    private BigDecimal vehicleCount;
    @TableField(updateStrategy = FieldStrategy.IGNORED)
    private BigDecimal comparisonBase;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayCalendarMapper extends BaseMapper<HolidayCalendar> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayPassengerCoefficientMapper extends BaseMapper<HolidayPassengerCoefficient> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayPassengerFlowMapper extends BaseMapper<HolidayPassengerFlow> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayVehicleAdjustmentMapper extends BaseMapper<HolidayVehicleAdjustment> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java
New file
@@ -0,0 +1,9 @@
package com.trafficaudit.holiday.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HolidayVehicleFlowMapper extends BaseMapper<HolidayVehicleFlow> {
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
New file
@@ -0,0 +1,554 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.annotation.Resource;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@Slf4j
@Service
public class HolidayBulletinService {
    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private static final BigDecimal EPSILON = new BigDecimal("0.0000001");
    private static final int EXCEL_ONE_DECIMAL_PERCENT = 1;
    private static final int EXCEL_TWO_DECIMAL_PERCENT = 2;
    private static final int EXCEL_SEVEN_DECIMAL_YI = 7;
    private static final BigDecimal TEN_THOUSAND = new BigDecimal("10000");
    @Value("${holiday.bulletin-template:docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx}")
    private String bulletinTemplatePath;
    private final HolidayStatService statService;
    @Resource
    private HolidayConfigService configService;
    private static final ThreadLocal<Document> CURRENT_DOCUMENT = new ThreadLocal<>();
    public HolidayBulletinService(HolidayStatService statService) {
        this.statService = statService;
    }
    public byte[] generate(LocalDate date) {
        DailyMetrics current = statService.findMetricsByDate(date);
        if (current == null) {
            throw new IllegalArgumentException("所选日期不在已配置的节假日范围内");
        }
        DailyMetrics previousDay = current.getDayIndex() > 1
                ? statService.ringRatioBaseMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex()) : null;
        HolidayCalendar previousDayConfig = configService.findPreviousDay(current.getYear(), current.getHolidayType());
        LocalDate previousDayDate = previousDayConfig == null || previousDayConfig.getHolidayDate() == null
                ? current.getDate().minusDays(1) : previousDayConfig.getHolidayDate();
        DailyMetrics previousCalendarDay = previousDay != null
                ? previousDay : statService.dailyMetricsByDate(previousDayDate);
        DailyMetrics previousYear = statService.dailyMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
        DailyMetrics twoYearsAgo = statService.dailyMetrics(current.getYear() - 2, current.getHolidayType(), current.getDayIndex());
        DailyMetrics currentCumulative = statService.cumulativeMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex());
        DailyMetrics previousYearCumulative = statService.cumulativeMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
        DailyMetrics twoYearsAgoCumulative = statService.cumulativeMetrics(current.getYear() - 2, current.getHolidayType(), current.getDayIndex());
        if (current.getTotalPersonFlow() == null) {
            throw new IllegalArgumentException("所选日期的全社会跨区域人员流动量没有足够数据,无法生成快报");
        }
        String currentTotal = formatNumber(current.getTotalPersonFlow());
        String ringRatioClause = previousDay == null ? "" : ",环比" + ratioPhrase(
                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow(), EXCEL_TWO_DECIMAL_PERCENT);
        String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s,比2024年同期%s。",
                date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal,
                ringRatioClause,
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(), EXCEL_ONE_DECIMAL_PERCENT),
                ratioPhrase(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(), EXCEL_ONE_DECIMAL_PERCENT));
        String paragraph2 = cumulativeParagraph(currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
        File template = resolveTemplateFile();
        if (!template.exists() || !template.isFile()) {
            throw new IllegalArgumentException("快报模板文件不存在: " + template.getAbsolutePath());
        }
        try {
            byte[] templateBytes;
            try (InputStream in = new FileInputStream(template)) {
                templateBytes = readAll(in);
            }
            byte[] documentXml = readZipEntry(templateBytes, "word/document.xml");
            Document document = parseDocument(documentXml);
            CURRENT_DOCUMENT.set(document);
            try {
                replaceParagraph(document, text -> text.startsWith("10月") && text.contains("假期第"), paragraph1);
                replaceParagraph(document, text -> text.startsWith("假期以来"), paragraph2);
                fillTable(document, current, previousDay, previousCalendarDay, previousYear, twoYearsAgo,
                        currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
                byte[] replacedXml = serializeDocument(document);
                return replaceZipEntry(templateBytes, "word/document.xml", replacedXml);
            } finally {
                CURRENT_DOCUMENT.remove();
            }
        } catch (Exception e) {
            throw new IllegalArgumentException("快报生成失败: " + e.getMessage(), e);
        }
    }
    private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay,
                           DailyMetrics previousCalendarDay, DailyMetrics previousYear, DailyMetrics twoYearsAgo,
                           DailyMetrics currentCumulative, DailyMetrics previousYearCumulative,
                           DailyMetrics twoYearsAgoCumulative) {
        DailyMetrics firstDayRailBase = previousDay == null ? previousCalendarDay : previousDay;
        DailyMetrics firstDayCivilAviationBase = previousDay == null ? previousCalendarDay : previousDay;
        Element table = firstElement(document, "tbl");
        if (table == null) {
            throw new IllegalArgumentException("快报模板中找不到数据表");
        }
        DailyMetrics previousYearCivilCumulative = comparableCumulative(
                current, previousYear, DailyMetrics::getCivilAviation);
        DailyMetrics twoYearsAgoCivilCumulative = comparableCumulative(
                current, twoYearsAgo, DailyMetrics::getCivilAviation);
        writeTableRow(table, 2, new String[]{
                number(current.getNonBusinessPersonFlow()),
                ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getNonBusinessPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getNonBusinessPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                numberCumulative(currentCumulative.getNonBusinessPersonFlow()),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getNonBusinessPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT)
        });
        writeTableRow(table, 3, new String[]{
                number(current.getRoad()),
                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getRoad(), twoYearsAgo == null ? null : twoYearsAgo.getRoad(), EXCEL_TWO_DECIMAL_PERCENT),
                number(currentCumulative.getRoad()),
                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(currentCumulative.getRoad(), twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoad(),
                        EXCEL_ONE_DECIMAL_PERCENT)
        });
        writeTableRow(table, 4, new String[]{
                number(current.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getRoadPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getRoadPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                numberCumulative(currentCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(currentCumulative.getRoadPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoadPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT)
        });
        writeTableRow(table, 5, transportRow(current.getRail(),
                firstDayRailBase == null ? null : firstDayRailBase.getRail(),
                previousYear == null ? null : previousYear.getRail(),
                twoYearsAgo == null ? null : twoYearsAgo.getRail(),
                currentCumulative.getRail(),
                previousYearCumulative == null ? null : previousYearCumulative.getRail(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRail(),
                EXCEL_ONE_DECIMAL_PERCENT));
        writeTableRow(table, 6, transportRow(current.getCivilAviation(),
                firstDayCivilAviationBase == null ? null : firstDayCivilAviationBase.getCivilAviation(),
                previousYear == null ? null : previousYear.getCivilAviation(),
                twoYearsAgo == null ? null : twoYearsAgo.getCivilAviation(),
                currentCumulative.getCivilAviation(),
                previousYearCivilCumulative == null ? null : previousYearCivilCumulative.getCivilAviation(),
                twoYearsAgoCivilCumulative == null ? null : twoYearsAgoCivilCumulative.getCivilAviation(),
                EXCEL_ONE_DECIMAL_PERCENT));
        writeTableRow(table, 7, transportRow(current.getWater(),
                previousDay == null ? null : previousDay.getWater(),
                previousYear == null ? null : previousYear.getWater(),
                twoYearsAgo == null ? null : twoYearsAgo.getWater(),
                currentCumulative.getWater(),
                previousYearCumulative == null ? null : previousYearCumulative.getWater(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getWater(),
                EXCEL_TWO_DECIMAL_PERCENT));
        writeTableRow(table, 8, new String[]{
                number(current.getTotalPersonFlow()),
                ratio(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(),
                        EXCEL_ONE_DECIMAL_PERCENT),
                numberCumulative(currentCumulative.getTotalPersonFlow()),
                ratio(currentCumulative.getTotalPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratio(currentCumulative.getTotalPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT)
        });
    }
    private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
                                  BigDecimal twoYearsAgo, BigDecimal currentCumulative,
                                  BigDecimal previousYearCumulative, BigDecimal twoYearsAgoCumulative,
                                  int ringRatioExcelScale) {
        return new String[]{
                number(current),
                ratio(current, previousDay, ringRatioExcelScale),
                ratio(current, previousYear, EXCEL_ONE_DECIMAL_PERCENT),
                ratio(current, twoYearsAgo, EXCEL_ONE_DECIMAL_PERCENT),
                number(currentCumulative),
                ratio(currentCumulative, previousYearCumulative, EXCEL_ONE_DECIMAL_PERCENT),
                ratio(currentCumulative, twoYearsAgoCumulative, EXCEL_ONE_DECIMAL_PERCENT)
        };
    }
    private void writeTableRow(Element table, int rowIndex, String[] values) {
        List<Element> rows = childElements(table, "tr");
        if (rowIndex >= rows.size()) return;
        Element row = rows.get(rowIndex);
        List<Element> cells = cellRefs(row);
        int logicalColumn = 0;
        for (Element cell : cells) {
            int span = gridSpan(cell);
            if (logicalColumn >= 2) {
                int valueIndex = logicalColumn - 2;
                if (valueIndex < values.length) {
                    setCellText(cell, values[valueIndex]);
                }
            }
            logicalColumn += span;
        }
    }
    private List<Element> cellRefs(Element row) {
        List<Element> cells = new ArrayList<>();
        for (Element child : childElements(row, "tc")) cells.add(child);
        return cells;
    }
    private int gridSpan(Element cell) {
        Element tcPr = firstChildElement(cell, "tcPr");
        if (tcPr == null) return 1;
        Element span = firstChildElement(tcPr, "gridSpan");
        if (span == null) return 1;
        String value = span.getAttributeNS(WORD_NS, "val");
        try {
            return Integer.parseInt(value);
        } catch (Exception e) {
            return 1;
        }
    }
    private void setCellText(Element cell, String text) {
        Element tcPr = firstChildElement(cell, "tcPr");
        Element templateParagraph = firstChildElement(cell, "p");
        Element paragraph = createElement("p");
        if (templateParagraph != null) {
            Element pPr = firstChildElement(templateParagraph, "pPr");
            if (pPr != null) paragraph.appendChild(pPr.cloneNode(true));
        }
        Element templateRun = templateParagraph == null ? null : firstChildElement(templateParagraph, "r");
        Element rPr = templateRun == null ? null : firstChildElement(templateRun, "rPr");
        Element run = createElement("r");
        if (rPr != null) run.appendChild(rPr.cloneNode(true));
        Element t = createElement("t");
        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
        t.setTextContent(text == null ? "" : text);
        run.appendChild(t);
        paragraph.appendChild(run);
        List<Node> removals = new ArrayList<>();
        Node child = cell.getFirstChild();
        while (child != null) {
            Node next = child.getNextSibling();
            if (child instanceof Element && "p".equals(localName(child))) {
                removals.add(child);
            }
            child = next;
        }
        for (Node node : removals) cell.removeChild(node);
        if (tcPr != null && tcPr.getNextSibling() != null) {
            cell.insertBefore(paragraph, tcPr.getNextSibling());
        } else if (tcPr != null) {
            cell.appendChild(paragraph);
        } else {
            cell.insertBefore(paragraph, cell.getFirstChild());
        }
    }
    private void replaceParagraph(Document document, ParagraphMatcher matcher, String replacement) {
        NodeList paragraphs = document.getElementsByTagNameNS(WORD_NS, "p");
        for (int i = 0; i < paragraphs.getLength(); i++) {
            Element paragraph = (Element) paragraphs.item(i);
            String text = paragraphText(paragraph);
            if (matcher.matches(text)) {
                setParagraphText(paragraph, replacement);
                return;
            }
        }
        throw new IllegalArgumentException("快报模板缺少可替换段落: " + replacement);
    }
    private void setParagraphText(Element paragraph, String text) {
        Element pPr = firstChildElement(paragraph, "pPr");
        Element firstRun = firstChildElement(paragraph, "r");
        Element rPr = firstRun == null ? null : firstChildElement(firstRun, "rPr");
        List<Node> removals = new ArrayList<>();
        Node child = paragraph.getFirstChild();
        while (child != null) {
            Node next = child.getNextSibling();
            if (child != pPr) removals.add(child);
            child = next;
        }
        for (Node node : removals) paragraph.removeChild(node);
        Element run = createElement("r");
        if (rPr != null) run.appendChild(rPr.cloneNode(true));
        Element t = createElement("t");
        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
        t.setTextContent(text);
        run.appendChild(t);
        paragraph.appendChild(run);
    }
    private String paragraphText(Element paragraph) {
        StringBuilder builder = new StringBuilder();
        NodeList texts = paragraph.getElementsByTagNameNS(WORD_NS, "t");
        for (int i = 0; i < texts.getLength(); i++) {
            builder.append(texts.item(i).getTextContent());
        }
        return builder.toString();
    }
    private String ratioPhrase(BigDecimal current, BigDecimal base, int excelPercentScale) {
        BigDecimal percent = bulletinPercent(current, base, excelPercentScale);
        if (percent == null) return "无可比数据";
        if (percent.signum() == 0) return "持平";
        return (percent.signum() > 0 ? "增长" : "下降") + HolidayNumberUtils.format1(percent.abs()) + "%";
    }
    private DailyMetrics comparableCumulative(DailyMetrics current, DailyMetrics comparisonYear,
                                              Function<DailyMetrics, BigDecimal> selector) {
        if (current == null || comparisonYear == null) return null;
        int coveredDay = 0;
        for (int day = current.getDayIndex(); day >= 1; day--) {
            DailyMetrics metrics = statService.dailyMetrics(current.getYear(), current.getHolidayType(), day);
            if (metrics != null && selector.apply(metrics) != null) {
                coveredDay = day;
                break;
            }
        }
        if (coveredDay == 0) return null;
        return statService.cumulativeMetrics(comparisonYear.getYear(), current.getHolidayType(), coveredDay);
    }
    private String cumulativeParagraph(DailyMetrics currentCumulative,
                                       DailyMetrics previousYearCumulative,
                                       DailyMetrics twoYearsAgoCumulative) {
        BigDecimal total = currentCumulative == null ? null : currentCumulative.getTotalPersonFlow();
        return String.format("假期以来,全省全社会跨区域人员流动量累计%s万人次,比2025年同期%s,比2024年同期%s。",
                formatNumber(displayedCumulativeWan(total)),
                ratioPhrase(total, previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT),
                ratioPhrase(total, twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow(),
                        EXCEL_TWO_DECIMAL_PERCENT));
    }
    private String ratio(BigDecimal current, BigDecimal base, int excelPercentScale) {
        BigDecimal percent = bulletinPercent(current, base, excelPercentScale);
        if (percent == null) return "—";
        return percent.signum() == 0 ? "0%" : percent.toPlainString() + "%";
    }
    /**
     * Mirrors the source Excel percentage cell format, then applies the bulletin's one-decimal rule.
     * Excel stores full precision but displays 0.0% or 0.00%; reviewers compare against that display.
     */
    private BigDecimal bulletinPercent(BigDecimal current, BigDecimal base, int excelPercentScale) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return null;
        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
        if (ratio == null) return null;
        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
        if (percent == null) return null;
        BigDecimal displayed = percent.setScale(excelPercentScale, RoundingMode.HALF_UP);
        return displayed.setScale(1, RoundingMode.HALF_UP);
    }
    private String number(BigDecimal value) {
        return value == null ? "—" : formatNumber(value);
    }
    private String numberCumulative(BigDecimal value) {
        return value == null ? "—" : formatNumber(displayedCumulativeWan(value));
    }
    /**
     * Source cumulative cells U5/X5/AA5 are stored in 100-million units and displayed with seven decimals.
     * Mirror that displayed precision before the bulletin converts the value to 10-thousand units.
     */
    private BigDecimal displayedCumulativeWan(BigDecimal cumulativeWan) {
        if (cumulativeWan == null) return null;
        BigDecimal yi = cumulativeWan.divide(TEN_THOUSAND, EXCEL_SEVEN_DECIMAL_YI, RoundingMode.HALF_UP);
        return HolidayNumberUtils.normalize(yi.multiply(TEN_THOUSAND));
    }
    private String formatNumber(BigDecimal value) {
        if (value == null) return "";
        return HolidayNumberUtils.format2(value);
    }
    private File resolveTemplateFile() {
        List<File> candidates = new ArrayList<>();
        File configured = new File(bulletinTemplatePath);
        candidates.add(configured);
        if (!configured.isAbsolute()) {
            candidates.add(new File(System.getProperty("user.dir"), bulletinTemplatePath));
            candidates.add(new File(System.getProperty("user.dir"), "../" + bulletinTemplatePath));
        }
        for (File candidate : candidates) {
            try {
                if (candidate.exists() && candidate.isFile()) {
                    return candidate.getCanonicalFile();
                }
            } catch (Exception ignored) {
                // Keep checking.
            }
        }
        return configured;
    }
    private byte[] readZipEntry(byte[] zipBytes, String entryName) throws Exception {
        try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
            ZipEntry entry;
            while ((entry = zin.getNextEntry()) != null) {
                if (entryName.equals(entry.getName())) {
                    return readAll(zin);
                }
            }
        }
        throw new IllegalArgumentException("模板中缺少 " + entryName);
    }
    private byte[] replaceZipEntry(byte[] zipBytes, String entryName, byte[] replacement) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes));
             ZipOutputStream zout = new ZipOutputStream(out)) {
            ZipEntry entry;
            byte[] buffer = new byte[8192];
            while ((entry = zin.getNextEntry()) != null) {
                ZipEntry target = new ZipEntry(entry.getName());
                zout.putNextEntry(target);
                if (entryName.equals(entry.getName())) {
                    zout.write(replacement);
                } else {
                    int length;
                    while ((length = zin.read(buffer)) > 0) {
                        zout.write(buffer, 0, length);
                    }
                }
                zout.closeEntry();
            }
        }
        return out.toByteArray();
    }
    private Document parseDocument(byte[] xml) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
    }
    private byte[] serializeDocument(Document document) throws Exception {
        Transformer transformer = TransformerFactory.newInstance().newTransformer();
        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
        transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        transformer.transform(new DOMSource(document), new StreamResult(out));
        return out.toByteArray();
    }
    private Element firstElement(Document document, String localName) {
        NodeList list = document.getElementsByTagNameNS(WORD_NS, localName);
        return list.getLength() == 0 ? null : (Element) list.item(0);
    }
    private Element createElement(String localName) {
        Document document = CURRENT_DOCUMENT.get();
        if (document == null) {
            throw new IllegalStateException("Word document is not initialized");
        }
        return document.createElementNS(WORD_NS, "w:" + localName);
    }
    private List<Element> childElements(Element parent, String localName) {
        List<Element> result = new ArrayList<>();
        Node child = parent.getFirstChild();
        while (child != null) {
            if (child instanceof Element && localName.equals(localName(child))) {
                result.add((Element) child);
            }
            child = child.getNextSibling();
        }
        return result;
    }
    private Element firstChildElement(Element parent, String localName) {
        Node child = parent.getFirstChild();
        while (child != null) {
            if (child instanceof Element && localName.equals(localName(child))) {
                return (Element) child;
            }
            child = child.getNextSibling();
        }
        return null;
    }
    private String localName(Node node) {
        return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName();
    }
    private byte[] readAll(InputStream in) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] buffer = new byte[8192];
        int length;
        while ((length = in.read(buffer)) > 0) {
            out.write(buffer, 0, length);
        }
        return out.toByteArray();
    }
    private interface ParagraphMatcher {
        boolean matches(String text);
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java
New file
@@ -0,0 +1,211 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import com.trafficaudit.holiday.mapper.HolidayCalendarMapper;
import com.trafficaudit.holiday.mapper.HolidayPassengerCoefficientMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class HolidayConfigService {
    @Resource
    private HolidayCalendarMapper holidayCalendarMapper;
    @Resource
    private HolidayPassengerCoefficientMapper coefficientMapper;
    public List<HolidayCalendar> listHolidays(Integer year, String holidayType) {
        LambdaQueryWrapper<HolidayCalendar> wrapper = new LambdaQueryWrapper<HolidayCalendar>()
                .eq(year != null, HolidayCalendar::getYear, year)
                .eq(holidayType != null && !holidayType.trim().isEmpty(), HolidayCalendar::getHolidayType, holidayType)
                .orderByAsc(HolidayCalendar::getYear)
                .orderByAsc(HolidayCalendar::getHolidayType)
                .orderByAsc(HolidayCalendar::getDayIndex);
        return holidayCalendarMapper.selectList(wrapper);
    }
    public List<HolidayCalendar> findHolidayDays(int year, String holidayType) {
        return holidayCalendarMapper.selectList(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .ge(HolidayCalendar::getDayIndex, 1)
                .orderByAsc(HolidayCalendar::getDayIndex));
    }
    public HolidayCalendar findPreviousDay(int year, String holidayType) {
        return holidayCalendarMapper.selectOne(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .eq(HolidayCalendar::getDayIndex, 0)
                .last("limit 1"));
    }
    public HolidayCalendar findHolidayByDate(LocalDate date) {
        return holidayCalendarMapper.selectOne(new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getHolidayDate, date)
                .orderByAsc(HolidayCalendar::getDayIndex)
                .last("limit 1"));
    }
    @Transactional
    public HolidayCalendar saveHoliday(Map<String, Object> params) {
        Long id = longValue(params.get("id"));
        Integer year = intValue(params.get("year"));
        String holidayType = stringValue(params.get("holidayType"));
        LocalDate holidayDate = dateValue(params.get("holidayDate"));
        Integer dayIndex = intValue(params.get("dayIndex"));
        if (year == null || year < 2000 || year > 2100) {
            throw new IllegalArgumentException("年份不正确");
        }
        if (!HolidayConstants.isValidHolidayType(holidayType)) {
            throw new IllegalArgumentException("节假日分类不正确");
        }
        if (holidayDate == null || holidayDate.getYear() != year) {
            throw new IllegalArgumentException("日期必须属于所选年份");
        }
        if (dayIndex == null || dayIndex < 0) {
            throw new IllegalArgumentException("日期序号不能小于 0");
        }
        LambdaQueryWrapper<HolidayCalendar> duplicate = new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
                .eq(HolidayCalendar::getHolidayType, holidayType)
                .and(w -> w.eq(HolidayCalendar::getHolidayDate, holidayDate)
                        .or().eq(HolidayCalendar::getDayIndex, dayIndex));
        if (id != null) {
            duplicate.ne(HolidayCalendar::getId, id);
        }
        if (holidayCalendarMapper.selectCount(duplicate) > 0) {
            throw new IllegalArgumentException("同一节假日下的日期或第几日已存在");
        }
        HolidayCalendar entity = id == null ? new HolidayCalendar() : holidayCalendarMapper.selectById(id);
        if (entity == null) {
            throw new IllegalArgumentException("节假日记录不存在");
        }
        if (id == null) {
            entity.setCreatedAt(LocalDateTime.now());
        }
        entity.setYear(year);
        entity.setHolidayType(holidayType);
        entity.setHolidayDate(holidayDate);
        entity.setDayIndex(dayIndex);
        entity.setUpdatedAt(LocalDateTime.now());
        if (id == null) {
            holidayCalendarMapper.insert(entity);
        } else {
            holidayCalendarMapper.updateById(entity);
        }
        return entity;
    }
    @Transactional
    public void deleteHoliday(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("缺少记录 id");
        }
        holidayCalendarMapper.deleteById(id);
    }
    public List<HolidayPassengerCoefficient> listCoefficients(Integer year) {
        return coefficientMapper.selectList(new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                .eq(year != null, HolidayPassengerCoefficient::getYear, year)
                .orderByAsc(HolidayPassengerCoefficient::getYear)
                .orderByAsc(HolidayPassengerCoefficient::getDateCategory));
    }
    public BigDecimal coefficientOf(int year, String dateCategory) {
        HolidayPassengerCoefficient row = coefficientMapper.selectOne(
                new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                        .eq(HolidayPassengerCoefficient::getYear, year)
                        .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory)
                        .last("limit 1"));
        return row == null ? null : row.getCoefficient();
    }
    @Transactional
    public HolidayPassengerCoefficient saveCoefficient(Map<String, Object> params) {
        Long id = longValue(params.get("id"));
        Integer year = intValue(params.get("year"));
        String dateCategory = stringValue(params.get("dateCategory"));
        BigDecimal coefficient = decimalValue(params.get("coefficient"));
        if (year == null || year < 2000 || year > 2100) {
            throw new IllegalArgumentException("年份不正确");
        }
        if (!HolidayConstants.isValidDateCategory(dateCategory)) {
            throw new IllegalArgumentException("日期分类不正确");
        }
        if (coefficient == null || coefficient.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("客运系数必须大于 0");
        }
        LambdaQueryWrapper<HolidayPassengerCoefficient> duplicate = new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                .eq(HolidayPassengerCoefficient::getYear, year)
                .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory);
        if (id != null) {
            duplicate.ne(HolidayPassengerCoefficient::getId, id);
        }
        if (coefficientMapper.selectCount(duplicate) > 0) {
            throw new IllegalArgumentException("该年份和日期分类的客运系数已存在");
        }
        HolidayPassengerCoefficient entity = id == null ? new HolidayPassengerCoefficient() : coefficientMapper.selectById(id);
        if (entity == null) {
            throw new IllegalArgumentException("客运系数记录不存在");
        }
        if (id == null) {
            entity.setCreatedAt(LocalDateTime.now());
        }
        entity.setYear(year);
        entity.setDateCategory(dateCategory);
        entity.setCoefficient(coefficient);
        entity.setUpdatedAt(LocalDateTime.now());
        if (id == null) {
            coefficientMapper.insert(entity);
        } else {
            coefficientMapper.updateById(entity);
        }
        return entity;
    }
    @Transactional
    public void deleteCoefficient(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("缺少记录 id");
        }
        coefficientMapper.deleteById(id);
    }
    private Long longValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Long.valueOf(value.toString());
    }
    private Integer intValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Integer.valueOf(value.toString());
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return com.trafficaudit.holiday.HolidayNumberUtils.decimalValue(value);
    }
    private String stringValue(Object value) {
        return value == null ? null : value.toString().trim();
    }
    private LocalDate dateValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return LocalDate.parse(value.toString());
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java
New file
@@ -0,0 +1,21 @@
package com.trafficaudit.holiday.service;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
@Order(100)
public class HolidayReferenceDataInitializer implements ApplicationRunner {
    @Resource
    private HolidayWorkbookService workbookService;
    @Override
    public void run(ApplicationArguments args) {
        workbookService.seedReferenceDataIfNeeded();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
New file
@@ -0,0 +1,369 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.dataimport.entity.ImportBatch;
import com.trafficaudit.dataimport.mapper.ImportBatchMapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import lombok.Data;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class HolidayStatService {
    @Resource
    private HolidayConfigService configService;
    @Resource
    private HolidayWorkbookService workbookService;
    @Resource
    private HolidayPassengerFlowMapper passengerFlowMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    @Resource
    private HolidayVehicleAdjustmentMapper vehicleAdjustmentMapper;
    @Resource
    private ImportBatchMapper importBatchMapper;
    public Map<String, Object> overview(int year, String holidayType) {
        List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("year", year);
        result.put("holidayType", holidayType);
        result.put("holidayTypeLabel", HolidayConstants.HOLIDAY_TYPE_LABELS.get(holidayType));
        result.put("days", calendar);
        result.put("rows", buildOverviewRows(year, holidayType, calendar));
        result.put("templateFileName", workbookService.referenceFileName());
        return result;
    }
    @Transactional
    public Map<String, Object> importWorkbook(MultipartFile file, int year, String holidayType, boolean allowHistoricalOverwrite) {
        HolidayWorkbookService.ImportSummary summary = workbookService.importWorkbook(file, year, holidayType, allowHistoricalOverwrite);
        if (summary.isConfirmationRequired()) {
            return importResult(summary);
        }
        ImportBatch batch = new ImportBatch();
        batch.setFileName(file == null ? null : file.getOriginalFilename());
        batch.setImportType("HOLIDAY_FLOW");
        batch.setReportPeriod(year + "-" + holidayType);
        batch.setTotalRows(summary.getSuccessRows() + summary.getFailRows());
        batch.setSuccessRows(summary.getSuccessRows());
        batch.setFailRows(summary.getFailRows());
        batch.setErrorDetail(joinErrors(summary.getErrors()));
        batch.setCreatedAt(LocalDateTime.now());
        importBatchMapper.insert(batch);
        return importResult(summary);
    }
    private Map<String, Object> importResult(HolidayWorkbookService.ImportSummary summary) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("passengerRows", summary.getPassengerRows());
        result.put("vehicleRows", summary.getVehicleRows());
        result.put("skippedRows", summary.getSkippedRows());
        result.put("successRows", summary.getSuccessRows());
        result.put("failRows", summary.getFailRows());
        result.put("errors", summary.getErrors());
        result.put("confirmationRequired", summary.isConfirmationRequired());
        result.put("historicalChanges", summary.getHistoricalChanges());
        return result;
    }
    public byte[] exportWorkbook(int year, String holidayType) {
        return workbookService.exportWorkbook(year, holidayType);
    }
    public List<Map<String, Object>> buildOverviewRows(int year, String holidayType, List<HolidayCalendar> calendar) {
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        Map<Integer, HolidayCalendar> calendarByDay = new LinkedHashMap<>();
        for (HolidayCalendar day : calendar) {
            if (day.getDayIndex() != null) calendarByDay.put(day.getDayIndex(), day);
        }
        List<Map<String, Object>> rows = new ArrayList<>();
        for (HolidayCalendar day : calendar) {
            DailyMetrics current = buildDailyMetrics(year, holidayType, day.getDayIndex(), day.getHolidayDate(),
                    passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
            DailyMetrics ringRatioBase = ringRatioBaseFromMaps(year, holidayType, day.getDayIndex(), calendarByDay,
                    passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
            BigDecimal nationalProvincialRingRatio = ratio(current.getNationalProvincial(),
                    ringRatioBase == null ? null : ringRatioBase.getNationalProvincial());
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("dayIndex", day.getDayIndex());
            row.put("date", day.getHolidayDate());
            row.put("rail", current.getRail());
            row.put("civilAviation", current.getCivilAviation());
            row.put("road", current.getRoad());
            row.put("water", current.getWater());
            row.put("expressway", current.getExpressway());
            row.put("nationalProvincial", current.getNationalProvincial());
            row.put("comparisonBase", current.getComparisonBase());
            row.put("nationalProvincialRingRatio", nationalProvincialRingRatio);
            row.put("coefficient", current.getCoefficient());
            row.put("nonBusinessPersonFlow", current.getNonBusinessPersonFlow());
            row.put("totalPersonFlow", current.getTotalPersonFlow());
            rows.add(row);
        }
        return rows;
    }
    public DailyMetrics dailyMetrics(int year, String holidayType, int dayIndex) {
        List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
        HolidayCalendar target = null;
        for (HolidayCalendar day : calendar) {
            if (day.getDayIndex() != null && day.getDayIndex() == dayIndex) {
                target = day;
                break;
            }
        }
        if (target == null) return null;
        return dailyMetrics(year, holidayType, dayIndex, target.getHolidayDate());
    }
    public DailyMetrics dailyMetricsByDate(LocalDate date) {
        if (date == null) return null;
        return dailyMetrics(date.getYear(), null, 0, date);
    }
    private DailyMetrics dailyMetrics(int year, String holidayType, int dayIndex, LocalDate date) {
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        return buildDailyMetrics(year, holidayType, dayIndex, date, passengerByDate, vehicleByDate,
                comparisonBaseByDate, coefficient);
    }
    public DailyMetrics ringRatioBaseMetrics(int year, String holidayType, int dayIndex) {
        if (dayIndex <= 1) return null;
        List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
        Map<Integer, HolidayCalendar> calendarByDay = new LinkedHashMap<>();
        for (HolidayCalendar day : calendar) {
            if (day.getDayIndex() != null) calendarByDay.put(day.getDayIndex(), day);
        }
        Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
        Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
        Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        return ringRatioBaseFromMaps(year, holidayType, dayIndex, calendarByDay, passengerByDate,
                vehicleByDate, comparisonBaseByDate, coefficient);
    }
    private DailyMetrics buildDailyMetrics(int year, String holidayType, int dayIndex, LocalDate date,
                                           Map<LocalDate, Map<String, BigDecimal>> passengerByDate,
                                           Map<LocalDate, Map<String, BigDecimal>> vehicleByDate,
                                           Map<LocalDate, BigDecimal> comparisonBaseByDate,
                                           BigDecimal coefficient) {
        Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(date, Collections.emptyMap());
        Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(date, Collections.emptyMap());
        BigDecimal expressway = vehicle.get(HolidayConstants.ROAD_EXPRESSWAY);
        BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
        BigDecimal comparisonBase = comparisonBaseByDate.get(date);
        BigDecimal nonBusiness = nonBusinessPersonFlow(expressway, nationalProvincial, coefficient);
        DailyMetrics metrics = new DailyMetrics();
        metrics.setYear(year);
        metrics.setHolidayType(holidayType);
        metrics.setDayIndex(dayIndex);
        metrics.setDate(date);
        metrics.setRail(passenger.get(HolidayConstants.MODE_RAIL));
        metrics.setCivilAviation(passenger.get(HolidayConstants.MODE_CIVIL_AVIATION));
        metrics.setRoad(passenger.get(HolidayConstants.MODE_ROAD));
        metrics.setWater(passenger.get(HolidayConstants.MODE_WATER));
        metrics.setExpressway(expressway);
        metrics.setNationalProvincial(nationalProvincial);
        metrics.setComparisonBase(comparisonBase);
        metrics.setCoefficient(coefficient);
        metrics.setNonBusinessPersonFlow(nonBusiness);
        recalculateDerived(metrics);
        return metrics;
    }
    private DailyMetrics ringRatioBaseFromMaps(int year, String holidayType, int dayIndex,
                                               Map<Integer, HolidayCalendar> calendarByDay,
                                               Map<LocalDate, Map<String, BigDecimal>> passengerByDate,
                                               Map<LocalDate, Map<String, BigDecimal>> vehicleByDate,
                                               Map<LocalDate, BigDecimal> comparisonBaseByDate,
                                               BigDecimal coefficient) {
        if (dayIndex <= 1) return null;
        HolidayCalendar previous = calendarByDay.get(dayIndex - 1);
        if (previous == null || previous.getHolidayDate() == null) return null;
        DailyMetrics base = buildDailyMetrics(year, holidayType, previous.getDayIndex(), previous.getHolidayDate(),
                passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
        BigDecimal comparisonBase = comparisonBaseByDate.get(previous.getHolidayDate());
        if (comparisonBase != null) {
            base.setNationalProvincial(comparisonBase);
            recalculateDerived(base);
        }
        return base;
    }
    private void recalculateDerived(DailyMetrics metrics) {
        BigDecimal nonBusiness = nonBusinessPersonFlow(metrics.getExpressway(), metrics.getNationalProvincial(),
                metrics.getCoefficient());
        metrics.setNonBusinessPersonFlow(nonBusiness);
        metrics.setRoadPersonFlow(sumNullable(metrics.getRoad(), nonBusiness));
        metrics.setTotalPersonFlow(totalPersonFlow(nonBusiness, metrics.getRoad(), metrics.getRail(),
                metrics.getCivilAviation(), metrics.getWater()));
    }
    private BigDecimal nonBusinessPersonFlow(BigDecimal expressway, BigDecimal nationalProvincial,
                                            BigDecimal coefficient) {
        if (coefficient == null || (expressway == null && nationalProvincial == null)) return null;
        return HolidayNumberUtils.multiply(
                HolidayNumberUtils.sumNullable(expressway, nationalProvincial), coefficient);
    }
    public DailyMetrics cumulativeMetrics(int year, String holidayType, int dayIndex) {
        DailyMetrics cumulative = new DailyMetrics();
        cumulative.setYear(year);
        cumulative.setHolidayType(holidayType);
        cumulative.setDayIndex(dayIndex);
        for (int day = 1; day <= dayIndex; day++) {
            DailyMetrics item = dailyMetrics(year, holidayType, day);
            if (item == null) continue;
            cumulative.setRail(sumNullable(cumulative.getRail(), item.getRail()));
            cumulative.setCivilAviation(sumNullable(cumulative.getCivilAviation(), item.getCivilAviation()));
            cumulative.setRoad(sumNullable(cumulative.getRoad(), item.getRoad()));
            cumulative.setWater(sumNullable(cumulative.getWater(), item.getWater()));
            cumulative.setExpressway(sumNullable(cumulative.getExpressway(), item.getExpressway()));
            cumulative.setNationalProvincial(sumNullable(cumulative.getNationalProvincial(), item.getNationalProvincial()));
            cumulative.setNonBusinessPersonFlow(sumNullable(cumulative.getNonBusinessPersonFlow(), item.getNonBusinessPersonFlow()));
            cumulative.setRoadPersonFlow(sumNullable(cumulative.getRoadPersonFlow(), item.getRoadPersonFlow()));
            cumulative.setTotalPersonFlow(sumNullable(cumulative.getTotalPersonFlow(), item.getTotalPersonFlow()));
        }
        return cumulative;
    }
    public DailyMetrics findMetricsByDate(LocalDate date) {
        HolidayCalendar calendar = configService.findHolidayByDate(date);
        if (calendar == null) return null;
        return dailyMetrics(calendar.getYear(), calendar.getHolidayType(), calendar.getDayIndex());
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
        for (HolidayPassengerFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getTransportMode(), row.getPassengerCount());
        }
        return result;
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadVehicleByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>().eq(HolidayVehicleFlow::getYear, year));
        for (HolidayVehicleFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getRoadCategory(), row.getVehicleCount());
        }
        Map<LocalDate, BigDecimal> adjustments = loadNationalProvincialAdjustmentsByDate(year);
        for (Map.Entry<LocalDate, BigDecimal> entry : adjustments.entrySet()) {
            result.computeIfAbsent(entry.getKey(), key -> new LinkedHashMap<>())
                    .put(HolidayConstants.ROAD_NATIONAL_PROVINCIAL, entry.getValue());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadNationalProvincialAdjustmentsByDate(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleAdjustment> rows = vehicleAdjustmentMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleAdjustment>().eq(HolidayVehicleAdjustment::getYear, year));
        for (HolidayVehicleAdjustment row : rows) {
            if (row.getFlowDate() != null && row.getAdjustedValue() != null) {
                result.put(row.getFlowDate(), row.getAdjustedValue());
            }
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadComparisonBaseByDate(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
        for (HolidayVehicleFlow row : rows) {
            if (row.getComparisonBase() != null) result.put(row.getFlowDate(), row.getComparisonBase());
        }
        return result;
    }
    private BigDecimal totalPersonFlow(BigDecimal nonBusinessPersonFlow, BigDecimal road, BigDecimal rail,
                                       BigDecimal civilAviation, BigDecimal water) {
        BigDecimal total = nonBusinessPersonFlow;
        total = sumNullable(total, road);
        total = sumNullable(total, rail);
        total = sumNullable(total, civilAviation);
        total = sumNullable(total, water);
        return total;
    }
    private BigDecimal ratio(BigDecimal current, BigDecimal base) {
        BigDecimal quotient = HolidayNumberUtils.divide(current, base);
        return quotient == null ? null : HolidayNumberUtils.normalize(quotient.subtract(BigDecimal.ONE));
    }
    private BigDecimal sumNullable(BigDecimal left, BigDecimal right) {
        return HolidayNumberUtils.sumNullable(left, right);
    }
    private String joinErrors(List<String> errors) {
        if (errors == null || errors.isEmpty()) return null;
        StringBuilder builder = new StringBuilder();
        int limit = Math.min(errors.size(), 200);
        for (int i = 0; i < limit; i++) {
            if (i > 0) builder.append('\n');
            builder.append(errors.get(i));
        }
        if (errors.size() > limit) {
            builder.append("\n... ").append(errors.size() - limit).append(" more");
        }
        return builder.toString();
    }
    @Data
    public static class DailyMetrics {
        private int year;
        private String holidayType;
        private int dayIndex;
        private LocalDate date;
        private BigDecimal rail;
        private BigDecimal civilAviation;
        private BigDecimal road;
        private BigDecimal water;
        private BigDecimal expressway;
        private BigDecimal nationalProvincial;
        private BigDecimal comparisonBase;
        private BigDecimal coefficient;
        private BigDecimal nonBusinessPersonFlow;
        private BigDecimal roadPersonFlow;
        private BigDecimal totalPersonFlow;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
New file
@@ -0,0 +1,1186 @@
package com.trafficaudit.holiday.service;
import lombok.Data;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.StandardChartTheme;
import org.jfree.chart.axis.AxisLocation;
import org.jfree.chart.axis.CategoryAxis;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.axis.SymbolAxis;
import org.jfree.chart.axis.ValueAxis;
import org.jfree.chart.labels.StandardPieSectionLabelGenerator;
import org.jfree.chart.plot.CategoryPlot;
import org.jfree.chart.plot.PiePlot;
import org.jfree.chart.plot.Plot;
import org.jfree.chart.plot.PieLabelRecord;
import org.jfree.chart.plot.PiePlotState;
import org.jfree.chart.text.TextFragment;
import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.category.StackedBarRenderer;
import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
import org.jfree.chart.ui.RectangleEdge;
import org.jfree.chart.ui.RectangleInsets;
import org.jfree.chart.ui.TextAnchor;
import org.jfree.data.DefaultKeyedValues;
import org.jfree.data.KeyedValues;
import org.jfree.data.category.CategoryDataset;
import org.jfree.data.category.DefaultCategoryDataset;
import org.jfree.data.general.DefaultPieDataset;
import org.jfree.data.xy.XYDataset;
import org.jfree.data.xy.XYSeries;
import org.jfree.data.xy.XYSeriesCollection;
import org.springframework.stereotype.Service;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Paint;
import java.awt.Shape;
import java.awt.image.BufferedImage;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class HolidayTrendChartService {
    private static final Font BASE_FONT = new Font("Microsoft YaHei", Font.PLAIN, 20);
    private static final Font TICK_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
    private static final Font LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
    private static final Font ITEM_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 20f);
    private static final Font LEGEND_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
    private static final Font STACK_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 17f);
    private static final Color AXIS_COLOR = Color.BLACK;
    private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102);
    private static final double UNIT_LABEL_BAND = 58.0;
    private static final double RIGHT_LABEL_BAND = 74.0;
    private static final double ITEM_LABEL_GAP = 10.0;
    private static final float HALO_OUTLINE_WIDTH = 4.0f;
    private static final int PERCENT_ITEM_LABEL_DECIMALS = 1;
    private static final double LINE_CHART_HEIGHT_SCALE = 1.20;
    private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
    private static final Color[] SERIES_COLORS = {
            new Color(192, 80, 77),
            new Color(79, 129, 189),
            new Color(155, 187, 89),
            new Color(128, 100, 162),
            new Color(75, 172, 198)
    };
    static {
        System.setProperty("java.awt.headless", "true");
    }
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis) {
        return lineChart(yAxisLabel, series, percentAxis, 1100, 590);
    }
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int width, int height) {
        return lineChart(yAxisLabel, series, percentAxis, 2, width, height);
    }
    public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
                            int valueDecimals, int width, int height) {
        XYSeriesCollection dataset = new XYSeriesCollection();
        for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
            ChartSeries item = series.get(seriesIndex);
            XYSeries xySeries = new XYSeries(item.getName(), false, false);
            for (int i = 0; i < item.getValues().size(); i++) {
                BigDecimal value = item.getValues().get(i);
                if (value != null) {
                    xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue());
                }
            }
            dataset.addSeries(xySeries);
        }
        JFreeChart chart = ChartFactory.createXYLineChart(
                null, null, null, dataset, PlotOrientation.VERTICAL, true, false, false);
        applyTheme(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
        chart.getLegend().setPosition(RectangleEdge.BOTTOM);
        chart.getLegend().setItemFont(LEGEND_FONT);
        chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
        chart.getLegend().setBackgroundPaint(Color.WHITE);
        chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
        XYPlot plot = chart.getXYPlot();
        stylePlot(plot);
        plot.setDomainGridlinesVisible(false);
        plot.setRangeGridlinesVisible(false);
        plot.setDomainTickBandPaint(null);
        plot.setRangeTickBandPaint(null);
        SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
        domainAxis.setGridBandPaint(new Color(0, 0, 0, 0));
        domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0));
        domainAxis.setLowerMargin(0.04);
        domainAxis.setUpperMargin(0.04);
        plot.setDomainAxis(domainAxis);
        styleDomainAxis(plot.getDomainAxis());
        NumberAxis rangeAxis = new NumberAxis();
        rangeAxis.setAutoRangeIncludesZero(false);
        rangeAxis.setLowerMargin(0.04);
        rangeAxis.setUpperMargin(0.07);
        rangeAxis.setTickLabelFont(TICK_FONT);
        rangeAxis.setLabelFont(LABEL_FONT);
        rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
        rangeAxis.setLabelPaint(AXIS_COLOR);
        rangeAxis.setAxisLinePaint(AXIS_COLOR);
        rangeAxis.setTickMarkPaint(AXIS_COLOR);
        if (percentAxis) {
            rangeAxis.setRange(0.0, 1.0);
            rangeAxis.setAutoRangeIncludesZero(true);
        }
        NumberFormat valueFormat = percentAxis
                ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
        valueFormat.setMaximumFractionDigits(percentAxis ? 0 : valueDecimals);
        rangeAxis.setNumberFormatOverride(valueFormat);
        plot.setRangeAxis(rangeAxis);
        plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
        XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(true, true);
        renderer.setDefaultShapesVisible(true);
        renderer.setDefaultShapesFilled(true);
        renderer.setDefaultLinesVisible(true);
        renderer.setDefaultItemLabelsVisible(false);
        for (int i = 0; i < series.size(); i++) {
            renderer.setSeriesPaint(i, seriesColor(i));
            renderer.setSeriesFillPaint(i, seriesColor(i));
            renderer.setSeriesShape(i, seriesShape(i));
            renderer.setSeriesStroke(i, new BasicStroke(6.0f,
                    BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        }
        plot.setRenderer(renderer);
        return render(chart, width, scaleLineChartHeight(height),
                yAxisLabel, true, percentAxis, valueDecimals);
    }
    public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
                           int width, int height) {
        DefaultPieDataset<String> dataset = new DefaultPieDataset<>();
        for (ChartCategory category : categories) {
            dataset.setValue(category.getName(), category.getValue());
        }
        PiePlot<String> piePlot = new WaterRightPiePlot<>(dataset);
        JFreeChart chart = new JFreeChart(null, JFreeChart.DEFAULT_TITLE_FONT, piePlot, true);
        applyTheme(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
        chart.getLegend().setPosition(RectangleEdge.BOTTOM);
        chart.getLegend().setItemFont(LEGEND_FONT);
        chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
        chart.getLegend().setBackgroundPaint(Color.WHITE);
        chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
        PiePlot plot = piePlot;
        plot.setBackgroundPaint(Color.WHITE);
        plot.setOutlineVisible(false);
        plot.setShadowPaint(null);
        plot.setLabelFont(ITEM_LABEL_FONT);
        plot.setLabelPaint(ITEM_LABEL_COLOR);
        plot.setLabelBackgroundPaint(Color.WHITE);
        plot.setLabelOutlinePaint(new Color(160, 160, 160));
        plot.setLabelShadowPaint(null);
        plot.setLabelLinkPaint(Color.BLACK);
        plot.setLabelLinkStroke(new BasicStroke(2.0f));
        plot.setLabelGap(0.02);
        plot.setMaximumLabelWidth(0.24);
        plot.setSimpleLabels(false);
        plot.setInteriorGap(0.06);
        plot.setStartAngle(90.0);
        for (ChartCategory category : categories) {
            plot.setSectionOutlinePaint(category.getName(), Color.WHITE);
            plot.setSectionOutlineStroke(category.getName(), new BasicStroke(1.2f));
        }
        plot.setLabelGenerator(new StandardPieSectionLabelGenerator(
                "{0} {2}", new DecimalFormat("0"), new DecimalFormat("0.00%")));
        int index = 0;
        for (ChartCategory category : categories) {
            plot.setSectionPaint(category.getName(), seriesColor(index++));
        }
        return render(chart, width, height, valueSuffix, false, false, 2);
    }
    public byte[] stackedBarChart(String yAxisLabel, List<String> categories,
                                  List<ChartSeries> series, boolean percentAxis,
                                  int width, int height) {
        DefaultCategoryDataset dataset = new DefaultCategoryDataset();
        for (ChartSeries item : series) {
            for (int i = 0; i < categories.size() && i < item.getValues().size(); i++) {
                BigDecimal value = item.getValues().get(i);
                if (value != null) {
                    dataset.addValue(value, item.getName(), categories.get(i));
                }
            }
        }
        JFreeChart chart = ChartFactory.createStackedBarChart(
                null, null, null, dataset, PlotOrientation.HORIZONTAL, true, false, false);
        applyTheme(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
        chart.getLegend().setPosition(RectangleEdge.BOTTOM);
        chart.getLegend().setItemFont(LEGEND_FONT);
        chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
        chart.getLegend().setBackgroundPaint(Color.WHITE);
        chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
        CategoryPlot plot = chart.getCategoryPlot();
        plot.setBackgroundPaint(Color.WHITE);
        plot.setDomainGridlinesVisible(false);
        plot.setRangeGridlinesVisible(false);
        plot.setOutlineVisible(false);
        plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
        // Horizontal stacked bars: value axis along the bottom, no vertical axis line.
        plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT);
        CategoryAxis domainAxis = plot.getDomainAxis();
        domainAxis.setAxisLineVisible(false);
        domainAxis.setTickMarksVisible(false);
        styleDomainAxis(domainAxis);
        if (series.size() >= 3) {
            RectangleInsets insets = plot.getInsets();
            plot.setInsets(new RectangleInsets(insets.getTop(), insets.getLeft(),
                    insets.getBottom(), insets.getRight() + RIGHT_LABEL_BAND));
        }
        NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
        rangeAxis.setTickLabelFont(TICK_FONT);
        rangeAxis.setLabelFont(LABEL_FONT);
        rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
        rangeAxis.setLabelPaint(AXIS_COLOR);
        rangeAxis.setAxisLinePaint(AXIS_COLOR);
        rangeAxis.setTickMarkPaint(AXIS_COLOR);
        rangeAxis.setAutoRangeIncludesZero(true);
        rangeAxis.setLowerMargin(0.05);
        rangeAxis.setUpperMargin(0.12);
        if (percentAxis) {
            rangeAxis.setRange(0.0, 1.0);
        }
        NumberFormat valueFormat = percentAxis
                ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
        valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2);
        rangeAxis.setNumberFormatOverride(valueFormat);
        StackedBarRenderer renderer = new StackedBarRenderer();
        renderer.setDefaultItemLabelsVisible(false);
        renderer.setBarPainter(new org.jfree.chart.renderer.category.StandardBarPainter());
        renderer.setShadowVisible(false);
        renderer.setItemMargin(0.05);
        for (int i = 0; i < series.size(); i++) {
            renderer.setSeriesPaint(i, seriesColor(i));
        }
        plot.setRenderer(renderer);
        return render(chart, width, height, yAxisLabel, true, percentAxis, 2);
    }
    private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> {
        private DefaultKeyedValues<K> waterKeys;
        private WaterRightPiePlot(org.jfree.data.general.PieDataset<K> dataset) {
            super(dataset);
        }
        @Override
        protected void drawLeftLabels(KeyedValues<K> leftKeys, Graphics2D g2,
                                      Rectangle2D plotArea, Rectangle2D linkArea,
                                      float maxLabelWidth, PiePlotState state) {
            DefaultKeyedValues<K> filtered = new DefaultKeyedValues<>();
            waterKeys = new DefaultKeyedValues<>();
            for (int i = 0; i < leftKeys.getItemCount(); i++) {
                K key = leftKeys.getKey(i);
                if ("æ°´è·¯".equals(String.valueOf(key))) {
                    waterKeys.addValue(key, leftKeys.getValue(i));
                } else {
                    filtered.addValue(key, leftKeys.getValue(i));
                }
            }
            super.drawLeftLabels(filtered, g2, plotArea, linkArea, maxLabelWidth, state);
        }
        @Override
        protected void drawRightLabels(KeyedValues<K> rightKeys, Graphics2D g2,
                                       Rectangle2D plotArea, Rectangle2D linkArea,
                                       float maxLabelWidth, PiePlotState state) {
            DefaultKeyedValues<K> merged = new DefaultKeyedValues<>();
            if (waterKeys != null) {
                for (int i = 0; i < waterKeys.getItemCount(); i++) {
                    merged.addValue(waterKeys.getKey(i), waterKeys.getValue(i));
                }
            }
            for (int i = 0; i < rightKeys.getItemCount(); i++) {
                merged.addValue(rightKeys.getKey(i), rightKeys.getValue(i));
            }
            super.drawRightLabels(merged, g2, plotArea, linkArea, maxLabelWidth, state);
        }
        @Override
        protected void drawLeftLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) {
            recolorLabel(record);
            super.drawLeftLabel(g2, state, record);
        }
        @Override
        protected void drawRightLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) {
            recolorLabel(record);
            super.drawRightLabel(g2, state, record);
        }
        private void recolorLabel(PieLabelRecord record) {
            Paint paint = getSectionPaint(record.getKey());
            Object label = record.getLabel();
            if (paint == null || label == null) return;
            recolorPaintFields(label, paint, new IdentityHashMap<Object, Boolean>());
        }
        private void recolorPaintFields(Object node, Paint paint,
                                        Map<Object, Boolean> visited) {
            if (node == null || visited.put(node, Boolean.TRUE) != null) return;
            if (node instanceof Paint || node instanceof String || node instanceof Number
                    || node instanceof Font) {
                return;
            }
            if (node instanceof Iterable) {
                for (Object child : (Iterable<?>) node) {
                    recolorPaintFields(child, paint, visited);
                }
                return;
            }
            if (!node.getClass().getName().startsWith("org.jfree.chart.text")) return;
            Class<?> type = node.getClass();
            while (type != null && type != Object.class) {
                for (Field field : type.getDeclaredFields()) {
                    if (Modifier.isStatic(field.getModifiers())) continue;
                    try {
                        field.setAccessible(true);
                        Object value = field.get(node);
                        if (value instanceof Paint && node instanceof TextFragment) {
                            field.set(node, paint);
                        } else if (value instanceof Iterable
                                || value != null && value.getClass().getName()
                                .startsWith("org.jfree.chart.text")) {
                            recolorPaintFields(value, paint, visited);
                        }
                    } catch (IllegalAccessException ignored) {
                        // Keep the default label paint if a JFreeChart field cannot be read.
                    }
                }
                type = type.getSuperclass();
            }
        }
    }
    private Shape seriesShape(int seriesIndex) {
        if (seriesIndex % 3 == 1) {
            return new Rectangle2D.Double(-6.0, -6.0, 12.0, 12.0);
        }
        if (seriesIndex % 3 == 2) {
            Path2D.Double diamond = new Path2D.Double();
            diamond.moveTo(0.0, -7.0);
            diamond.lineTo(7.0, 0.0);
            diamond.lineTo(0.0, 7.0);
            diamond.lineTo(-7.0, 0.0);
            diamond.closePath();
            return diamond;
        }
        return new Ellipse2D.Double(-6.0, -6.0, 12.0, 12.0);
    }
    private void stylePlot(XYPlot plot) {
        plot.setBackgroundPaint(Color.WHITE);
        plot.setOutlineVisible(false);
        plot.setDomainGridlinesVisible(false);
        plot.setRangeGridlinesVisible(false);
    }
    private void styleDomainAxis(org.jfree.chart.axis.CategoryAxis axis) {
        axis.setTickLabelFont(TICK_FONT);
        axis.setLabelFont(LABEL_FONT);
        axis.setTickLabelPaint(MUTED_TEXT_COLOR);
        axis.setLabelPaint(AXIS_COLOR);
        axis.setAxisLinePaint(AXIS_COLOR);
        axis.setTickMarkPaint(AXIS_COLOR);
        axis.setCategoryMargin(0.05);
    }
    private void styleDomainAxis(org.jfree.chart.axis.ValueAxis axis) {
        axis.setTickLabelFont(TICK_FONT);
        axis.setLabelFont(LABEL_FONT);
        axis.setTickLabelPaint(MUTED_TEXT_COLOR);
        axis.setLabelPaint(AXIS_COLOR);
        axis.setAxisLinePaint(AXIS_COLOR);
        axis.setTickMarkPaint(AXIS_COLOR);
    }
    private void applyTheme(JFreeChart chart) {
        StandardChartTheme theme = new StandardChartTheme("CN");
        theme.setExtraLargeFont(BASE_FONT.deriveFont(Font.BOLD, 22f));
        theme.setLargeFont(BASE_FONT.deriveFont(Font.BOLD, 18f));
        theme.setRegularFont(BASE_FONT);
        theme.setSmallFont(TICK_FONT);
        theme.setChartBackgroundPaint(Color.WHITE);
        theme.setPlotBackgroundPaint(Color.WHITE);
        theme.setAxisLabelPaint(AXIS_COLOR);
        theme.setTickLabelPaint(MUTED_TEXT_COLOR);
        theme.setLegendItemPaint(MUTED_TEXT_COLOR);
        theme.setLegendBackgroundPaint(Color.WHITE);
        theme.apply(chart);
        chart.setBackgroundPaint(Color.WHITE);
        chart.setBorderVisible(false);
    }
    private Color seriesColor(int index) {
        return SERIES_COLORS[index % SERIES_COLORS.length];
    }
    private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
                         boolean axisChart, boolean percentItemLabels, int valueDecimals) {
        try {
            reserveUnitLabelBand(chart, axisChart, unitLabel);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            org.jfree.chart.ChartRenderingInfo info = new org.jfree.chart.ChartRenderingInfo();
            java.awt.image.BufferedImage image =
                    chart.createBufferedImage(width, height, info);
            Graphics2D graphics = image.createGraphics();
            try {
                graphics.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING,
                        java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
                if (axisChart && isHorizontalStackedChart(chart)) {
                    drawUnitLabelTopRight(graphics, unitLabel, info.getPlotInfo().getDataArea());
                    drawStackedValueAxis(graphics, info.getPlotInfo().getDataArea());
                } else if (axisChart) {
                    drawAxisArrows(graphics, info.getPlotInfo().getDataArea());
                    drawUnitLabel(graphics, unitLabel, info.getPlotInfo().getDataArea());
                    drawDateLabel(graphics, info.getPlotInfo().getDataArea());
                }
                drawXYItemLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
                drawStackedBarLabels(graphics, image, chart,
                        info.getPlotInfo().getDataArea(), percentItemLabels);
            } finally {
                graphics.dispose();
            }
            javax.imageio.ImageIO.write(image, "png", out);
            return out.toByteArray();
        } catch (Exception e) {
            throw new IllegalArgumentException("走势图图片生成失败: " + e.getMessage(), e);
        }
    }
    private void drawAxisArrows(Graphics2D graphics, Rectangle2D dataArea) {
        if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return;
        graphics.setColor(AXIS_COLOR);
        graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        int x = (int) Math.round(dataArea.getX());
        int y = (int) Math.round(dataArea.getY());
        int right = (int) Math.round(dataArea.getMaxX());
        int bottom = (int) Math.round(dataArea.getMaxY());
        graphics.drawLine(x, y, x, bottom);
        graphics.drawLine(x, bottom, right, bottom);
        drawArrowHead(graphics, right, bottom, 1, 0);
        drawArrowHead(graphics, x, y, 0, -1);
    }
    private void drawArrowHead(Graphics2D graphics, int x, int y, int dx, int dy) {
        int length = 11;
        int width = 5;
        if (dx != 0) {
            graphics.drawLine(x, y, x - length, y - width);
            graphics.drawLine(x, y, x - length, y + width);
        } else {
            graphics.drawLine(x, y, x - width, y + length);
            graphics.drawLine(x, y, x + width, y + length);
        }
    }
    private void drawUnitLabel(Graphics2D graphics, String unitLabel, Rectangle2D dataArea) {
        if (unitLabel == null || unitLabel.isEmpty()) return;
        graphics.setFont(LABEL_FONT);
        graphics.setColor(MUTED_TEXT_COLOR);
        java.awt.FontMetrics metrics = graphics.getFontMetrics();
        int centerX = (int) Math.round(dataArea.getX());
        int x = Math.max(4, centerX - metrics.stringWidth(unitLabel) / 2);
        int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0));
        graphics.drawString(unitLabel, x, y);
    }
    private void drawUnitLabelTopRight(Graphics2D graphics, String unitLabel,
                                       Rectangle2D dataArea) {
        if (unitLabel == null || unitLabel.isEmpty() || dataArea == null) return;
        graphics.setFont(LABEL_FONT);
        graphics.setColor(MUTED_TEXT_COLOR);
        FontMetrics metrics = graphics.getFontMetrics();
        int x = (int) Math.round(dataArea.getMaxX() - metrics.stringWidth(unitLabel));
        int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0));
        graphics.drawString(unitLabel, x, y);
    }
    private void reserveUnitLabelBand(JFreeChart chart, boolean axisChart, String unitLabel) {
        if (!axisChart || unitLabel == null || unitLabel.isEmpty()) return;
        Plot plot = chart.getPlot();
        RectangleInsets insets = plot.getInsets();
        plot.setInsets(new RectangleInsets(Math.max(insets.getTop(), UNIT_LABEL_BAND),
                insets.getLeft(), insets.getBottom(), insets.getRight()));
    }
    private boolean isHorizontalStackedChart(JFreeChart chart) {
        return chart.getPlot() instanceof CategoryPlot
                && PlotOrientation.HORIZONTAL.equals(
                ((CategoryPlot) chart.getPlot()).getOrientation());
    }
    private void drawDateLabel(Graphics2D graphics, Rectangle2D dataArea) {
        if (dataArea == null || dataArea.getWidth() <= 0) return;
        graphics.setFont(LABEL_FONT);
        graphics.setColor(MUTED_TEXT_COLOR);
        int right = (int) Math.round(dataArea.getMaxX());
        int bottom = (int) Math.round(dataArea.getMaxY());
        graphics.drawString("日期", right - 50, bottom - 28);
    }
    private void drawStackedValueAxis(Graphics2D graphics, Rectangle2D dataArea) {
        if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return;
        graphics.setColor(AXIS_COLOR);
        graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        int left = (int) Math.round(dataArea.getX());
        int right = (int) Math.round(dataArea.getMaxX());
        int bottom = (int) Math.round(dataArea.getMaxY());
        graphics.drawLine(left, bottom, right, bottom);
    }
    private void drawStackedBarLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
                                     Rectangle2D dataArea, boolean percentAxis) {
        if (!(chart.getPlot() instanceof CategoryPlot) || dataArea == null
                || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
            return;
        }
        CategoryPlot plot = (CategoryPlot) chart.getPlot();
        CategoryDataset dataset = plot.getDataset();
        CategoryAxis domainAxis = plot.getDomainAxis();
        ValueAxis rangeAxis = plot.getRangeAxis();
        if (dataset == null || domainAxis == null || rangeAxis == null) return;
        graphics.setFont(STACK_LABEL_FONT);
        FontMetrics metrics = graphics.getFontMetrics();
        int columns = dataset.getColumnCount();
        int rows = dataset.getRowCount();
        boolean horizontal = PlotOrientation.HORIZONTAL.equals(plot.getOrientation());
        List<List<StackCallout>> callouts = new ArrayList<>();
        for (int column = 0; column < columns; column++) callouts.add(new ArrayList<>());
        List<HorizontalStackCallout> horizontalCallouts = new ArrayList<>();
        List<Rectangle2D> occupied = new ArrayList<>();
        for (int column = 0; column < columns; column++) {
            double cumulative = 0.0;
            for (int row = 0; row < rows; row++) {
                Number number = dataset.getValue(row, column);
                if (number == null) continue;
                double value = number.doubleValue();
                if (value == 0.0) continue;
                String text = formatStackLabel(value, percentAxis);
                if (horizontal) {
                    double startX = rangeAxis.valueToJava2D(cumulative, dataArea,
                            plot.getRangeAxisEdge());
                    double endX = rangeAxis.valueToJava2D(cumulative + value, dataArea,
                            plot.getRangeAxisEdge());
                    double segmentWidth = Math.abs(endX - startX);
                    double centerY = domainAxis.getCategoryMiddle(column, columns, dataArea,
                            plot.getDomainAxisEdge());
                    int width = metrics.stringWidth(text);
                    if (segmentWidth >= width + 4.0) {
                        int textX = (int) Math.round((startX + endX - width) / 2.0);
                        int baseline = (int) Math.round(centerY
                                + (metrics.getAscent() - metrics.getDescent()) / 2.0);
                        graphics.setColor(Color.WHITE);
                        graphics.drawString(text, textX, baseline);
                        occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(),
                                width, metrics.getHeight()));
                    } else {
                        horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX),
                                Math.max(startX, endX), centerY, text, seriesColor(row)));
                    }
                } else {
                    double top = rangeAxis.valueToJava2D(cumulative + value, dataArea,
                            plot.getRangeAxisEdge());
                    double bottom = rangeAxis.valueToJava2D(cumulative, dataArea,
                            plot.getRangeAxisEdge());
                    double segmentHeight = Math.abs(bottom - top);
                    double x = domainAxis.getCategoryMiddle(column, columns, dataArea,
                            plot.getDomainAxisEdge());
                    if (segmentHeight >= metrics.getHeight() + 4.0) {
                        int width = metrics.stringWidth(text);
                        int y = (int) Math.round(Math.min(top, bottom)
                                + (segmentHeight - metrics.getHeight()) / 2.0
                                + metrics.getAscent());
                        graphics.setColor(Color.WHITE);
                        graphics.drawString(text, (int) Math.round(x - width / 2.0), y);
                        occupied.add(new Rectangle2D.Double(x - width / 2.0,
                                y - metrics.getAscent(), width, metrics.getHeight()));
                    } else if (estimatePercentShare(value, percentAxis) <= 0.03
                            || !percentAxis) {
                        callouts.get(column).add(new StackCallout(x,
                                (top + bottom) / 2.0, text, seriesColor(row)));
                    }
                }
                cumulative += value;
            }
        }
        if (horizontal) {
            drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied);
        } else {
            for (int column = 0; column < columns; column++) {
                drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns,
                        dataArea, metrics, occupied);
            }
        }
    }
    private void drawHorizontalStackCallouts(Graphics2D graphics,
                                              List<HorizontalStackCallout> items,
                                              Rectangle2D dataArea, FontMetrics metrics,
                                              List<Rectangle2D> occupied) {
        if (items.isEmpty()) return;
        Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>();
        for (HorizontalStackCallout item : items) {
            Long key = Long.valueOf(Math.round(item.getY()));
            List<HorizontalStackCallout> row = rows.get(key);
            if (row == null) {
                row = new ArrayList<>();
                rows.put(key, row);
            }
            row.add(item);
        }
        for (List<HorizontalStackCallout> row : rows.values()) {
            Collections.sort(row, new Comparator<HorizontalStackCallout>() {
                @Override
                public int compare(HorizontalStackCallout first, HorizontalStackCallout second) {
                    return Double.compare(first.getStartX(), second.getStartX());
                }
            });
            int widest = 0;
            double columnX = 0.0;
            for (HorizontalStackCallout item : row) {
                widest = Math.max(widest, metrics.stringWidth(item.getText()));
                columnX = Math.max(columnX, item.getEndX() + 8.0);
            }
            // Keep the label column inside the reserved right margin.
            columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest);
            int height = metrics.getHeight();
            double step = height + 3.0;
            double centerY = row.get(0).getY();
            double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0;
            for (int i = 0; i < row.size(); i++) {
                HorizontalStackCallout item = row.get(i);
                int width = metrics.stringWidth(item.getText());
                double x = columnX;
                double y = top + i * step;
                graphics.setColor(item.getColor());
                graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND,
                        BasicStroke.JOIN_ROUND));
                graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()),
                        (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0));
                drawLabelWithHalo(graphics, item.getText(),
                        new Rectangle2D.Double(x, y, width, height), metrics, item.getColor());
                occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0,
                        width + 4.0, height + 4.0));
            }
        }
    }
    private void drawStackCalloutColumn(Graphics2D graphics, BufferedImage image,
                                        List<StackCallout> items, int column, int columnCount,
                                        Rectangle2D dataArea, FontMetrics metrics,
                                        List<Rectangle2D> occupied) {
        if (items.isEmpty()) return;
        Collections.sort(items, new Comparator<StackCallout>() {
            @Override
            public int compare(StackCallout first, StackCallout second) {
                return Double.compare(first.getY(), second.getY());
            }
        });
        double categoryWidth = dataArea.getWidth() / Math.max(1, columnCount);
        double barHalf = Math.min(80.0, Math.max(18.0, categoryWidth * 0.30));
        double spacing = metrics.getHeight() + 4.0;
        double totalHeight = (items.size() - 1) * spacing;
        double averageY = 0.0;
        for (StackCallout item : items) averageY += item.getY();
        averageY /= items.size();
        double startY = averageY - totalHeight / 2.0 - metrics.getHeight() / 2.0;
        double maxStartY = dataArea.getMaxY() - metrics.getHeight() - 3.0 - totalHeight;
        startY = Math.max(dataArea.getMinY() + 3.0, Math.min(startY, maxStartY));
        double centerX = items.get(0).getX();
        double maxWidth = 0.0;
        for (StackCallout item : items) {
            maxWidth = Math.max(maxWidth, metrics.stringWidth(item.getText()));
        }
        double rightLeft = centerX + barHalf + 14.0;
        double leftLeft = centerX - barHalf - 14.0 - maxWidth;
        double rightPenalty = stackLanePenalty(rightLeft, startY, items, metrics,
                occupied, dataArea);
        double leftPenalty = stackLanePenalty(leftLeft, startY, items, metrics,
                occupied, dataArea);
        boolean rightSide = rightPenalty <= leftPenalty;
        double laneLeft = rightSide ? rightLeft : leftLeft;
        for (int i = 0; i < items.size(); i++) {
            StackCallout item = items.get(i);
            int width = metrics.stringWidth(item.getText());
            int height = metrics.getHeight();
            int y = (int) Math.round(startY + i * spacing);
            int textX = (int) Math.round(rightSide ? laneLeft : laneLeft + maxWidth - width);
            int textY = y + metrics.getAscent();
            double lineStartX = centerX + (rightSide ? barHalf : -barHalf);
            double lineEndX = rightSide ? laneLeft : laneLeft + maxWidth;
            graphics.setColor(item.getColor());
            graphics.setStroke(new BasicStroke(1.8f, BasicStroke.CAP_ROUND,
                    BasicStroke.JOIN_ROUND));
            graphics.drawLine((int) Math.round(lineStartX), (int) Math.round(item.getY()),
                    (int) Math.round(lineEndX), (int) Math.round(y + height / 2.0));
            drawLabelWithHalo(graphics, item.getText(),
                    new Rectangle2D.Double(textX, y, width, height), metrics, item.getColor());
            occupied.add(new Rectangle2D.Double(textX - 2.0, y - 2.0,
                    width + 4.0, height + 4.0));
        }
    }
    private double stackLanePenalty(double laneLeft, double startY, List<StackCallout> items,
                                    FontMetrics metrics, List<Rectangle2D> occupied,
                                    Rectangle2D dataArea) {
        double penalty = 0.0;
        for (int i = 0; i < items.size(); i++) {
            double width = metrics.stringWidth(items.get(i).getText());
            double height = metrics.getHeight();
            Rectangle2D rectangle = new Rectangle2D.Double(laneLeft, startY + i * (height + 4.0),
                    width, height);
            if (rectangle.getMinX() < dataArea.getMinX()
                    || rectangle.getMaxX() > dataArea.getMaxX()
                    || rectangle.getMaxY() > dataArea.getMaxY()) {
                penalty += 10000.0;
            }
            penalty += overlapArea(rectangle, occupied) * 100.0;
        }
        return penalty;
    }
    private double estimatePercentShare(double value, boolean percentAxis) {
        return percentAxis ? value : value / 1.0;
    }
    private String formatStackLabel(double value, boolean percentAxis) {
        if (percentAxis) {
            NumberFormat formatter = NumberFormat.getPercentInstance();
            formatter.setMaximumFractionDigits(1);
            return formatter.format(value);
        }
        return new DecimalFormat("#,##0").format(value);
    }
    private static class HorizontalStackCallout {
        private final double startX;
        private final double endX;
        private final double y;
        private final String text;
        private final Color color;
        private HorizontalStackCallout(double startX, double endX, double y,
                                       String text, Color color) {
            this.startX = startX;
            this.endX = endX;
            this.y = y;
            this.text = text;
            this.color = color;
        }
        private double getStartX() { return startX; }
        private double getEndX() { return endX; }
        private double getY() { return y; }
        private String getText() { return text; }
        private Color getColor() { return color; }
    }
    private static class StackCallout {
        private final double x;
        private final double y;
        private final String text;
        private final Color color;
        private StackCallout(double x, double y, String text, Color color) {
            this.x = x;
            this.y = y;
            this.text = text;
            this.color = color;
        }
        private double getX() { return x; }
        private double getY() { return y; }
        private String getText() { return text; }
        private Color getColor() { return color; }
    }
    private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
                                  Rectangle2D dataArea, boolean percentItemLabels,
                                  String unitLabel, int valueDecimals) {
        if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
                || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
            return;
        }
        XYPlot plot = (XYPlot) chart.getXYPlot();
        XYDataset dataset = plot.getDataset();
        if (dataset == null) return;
        graphics.setFont(ITEM_LABEL_FONT);
        FontMetrics metrics = graphics.getFontMetrics();
        List<Rectangle2D> placedLabels = new ArrayList<>();
        if (unitLabel != null && !unitLabel.isEmpty()) {
            placedLabels.add(new Rectangle2D.Double(0.0, 0.0,
                    dataArea.getMinX() + 100.0, dataArea.getMinY() + 30.0));
        }
        int itemCount = 0;
        for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
            itemCount = Math.max(itemCount, dataset.getItemCount(seriesIndex));
        }
        for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) {
            List<DayLabel> labels = new ArrayList<>();
            for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
                if (itemIndex >= dataset.getItemCount(seriesIndex)) continue;
                Number yValue = dataset.getY(seriesIndex, itemIndex);
                Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
                if (yValue == null || point == null) continue;
                labels.add(new DayLabel(seriesIndex, point,
                        formatLineLabel(yValue, percentItemLabels, valueDecimals)));
            }
            // A day's labels are stacked in the same top-to-bottom order as its data
            // points, so a label never looks attached to a neighbouring line.
            Collections.sort(labels, new Comparator<DayLabel>() {
                @Override
                public int compare(DayLabel first, DayLabel second) {
                    return Double.compare(first.getPoint().getY(), second.getPoint().getY());
                }
            });
            List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
            for (DayLabel label : labels) {
                List<LineLabelCandidate> candidates = lineLabelCandidates(
                        label, metrics, dataArea);
                if (candidates.isEmpty()) {
                    candidateSets.clear();
                    break;
                }
                candidateSets.add(candidates);
            }
            if (candidateSets.isEmpty()) continue;
            // Dynamic programming keeps each label near its own point while preserving
            // the same top-to-bottom order as the data points for that date.
            List<int[]> backPointers = new ArrayList<>();
            List<LineLabelCandidate> firstCandidates = candidateSets.get(0);
            double[] previousCosts = new double[firstCandidates.size()];
            for (int i = 0; i < firstCandidates.size(); i++) {
                previousCosts[i] = lineLabelCost(firstCandidates.get(i), labels.get(0),
                        labels, placedLabels);
            }
            for (int labelIndex = 1; labelIndex < candidateSets.size(); labelIndex++) {
                List<LineLabelCandidate> previousCandidates = candidateSets.get(labelIndex - 1);
                List<LineLabelCandidate> currentCandidates = candidateSets.get(labelIndex);
                double[] currentCosts = new double[currentCandidates.size()];
                Arrays.fill(currentCosts, Double.MAX_VALUE);
                int[] back = new int[currentCandidates.size()];
                Arrays.fill(back, -1);
                for (int currentIndex = 0; currentIndex < currentCandidates.size(); currentIndex++) {
                    LineLabelCandidate current = currentCandidates.get(currentIndex);
                    double currentCost = lineLabelCost(current, labels.get(labelIndex),
                            labels, placedLabels);
                    for (int previousIndex = 0; previousIndex < previousCandidates.size(); previousIndex++) {
                        if (previousCosts[previousIndex] == Double.MAX_VALUE) continue;
                        LineLabelCandidate previous = previousCandidates.get(previousIndex);
                        if (previous.getRectangle().getCenterY()
                                > current.getRectangle().getCenterY() + 0.1) {
                            continue;
                        }
                        double overlapPenalty = overlapArea(previous.getRectangle(),
                                Collections.singletonList(current.getRectangle())) * 1000000.0;
                        double cost = previousCosts[previousIndex] + currentCost + overlapPenalty;
                        if (cost < currentCosts[currentIndex]) {
                            currentCosts[currentIndex] = cost;
                            back[currentIndex] = previousIndex;
                        }
                    }
                }
                previousCosts = currentCosts;
                backPointers.add(back);
            }
            int bestLastIndex = -1;
            double bestCost = Double.MAX_VALUE;
            for (int i = 0; i < previousCosts.length; i++) {
                if (previousCosts[i] < bestCost) {
                    bestCost = previousCosts[i];
                    bestLastIndex = i;
                }
            }
            if (bestLastIndex < 0) continue;
            int[] chosenIndexes = new int[candidateSets.size()];
            chosenIndexes[chosenIndexes.length - 1] = bestLastIndex;
            for (int labelIndex = chosenIndexes.length - 2; labelIndex >= 0; labelIndex--) {
                int nextIndex = chosenIndexes[labelIndex + 1];
                chosenIndexes[labelIndex] = backPointers.get(labelIndex)[nextIndex];
            }
            for (int labelIndex = 0; labelIndex < chosenIndexes.length; labelIndex++) {
                DayLabel label = labels.get(labelIndex);
                Rectangle2D chosen = candidateSets.get(labelIndex)
                        .get(chosenIndexes[labelIndex]).getRectangle();
                drawLabelWithHalo(graphics, label.getText(), chosen, metrics,
                        seriesColor(label.getSeriesIndex()));
                placedLabels.add(new Rectangle2D.Double(chosen.getX() - 4.0, chosen.getY() - 4.0,
                        chosen.getWidth() + 8.0, chosen.getHeight() + 8.0));
            }
        }
    }
    private static class LineLabelCandidate {
        private final Rectangle2D rectangle;
        private LineLabelCandidate(Rectangle2D rectangle) {
            this.rectangle = rectangle;
        }
        private Rectangle2D getRectangle() { return rectangle; }
    }
    private static class DayLabel {
        private final int seriesIndex;
        private final Point2D point;
        private final String text;
        private DayLabel(int seriesIndex, Point2D point, String text) {
            this.seriesIndex = seriesIndex;
            this.point = point;
            this.text = text;
        }
        private int getSeriesIndex() { return seriesIndex; }
        private Point2D getPoint() { return point; }
        private String getText() { return text; }
    }
    private Point2D itemPoint(XYPlot plot, XYDataset dataset, int seriesIndex,
                              int itemIndex, Rectangle2D dataArea) {
        Number yValue = dataset.getY(seriesIndex, itemIndex);
        if (yValue == null || Double.isNaN(yValue.doubleValue())
                || Double.isInfinite(yValue.doubleValue())) {
            return null;
        }
        double x = plot.getDomainAxis().valueToJava2D(dataset.getXValue(seriesIndex, itemIndex),
                dataArea, plot.getDomainAxisEdge());
        double y = plot.getRangeAxis().valueToJava2D(yValue.doubleValue(),
                dataArea, plot.getRangeAxisEdge());
        return new Point2D.Double(x, y);
    }
    private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics,
                                                         Rectangle2D dataArea) {
        List<LineLabelCandidate> result = new ArrayList<>();
        int width = metrics.stringWidth(label.getText());
        int height = metrics.getHeight();
        double x = label.getPoint().getX();
        double y = label.getPoint().getY();
        double horizontalOffset = ITEM_LABEL_GAP + width / 2.0;
        double farHorizontalOffset = horizontalOffset + 18.0;
        double verticalOffset = ITEM_LABEL_GAP + height / 2.0;
        double farVerticalOffset = verticalOffset + height + 6.0;
        addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height, farHorizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height, -farHorizontalOffset, 0.0, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, -verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, verticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height, 0.0, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                horizontalOffset, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -horizontalOffset, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                farHorizontalOffset, farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, -farVerticalOffset, dataArea);
        addLabelCandidate(result, x, y, width, height,
                -farHorizontalOffset, farVerticalOffset, dataArea);
        return result;
    }
    private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y,
                                   int width, int height, double dx, double dy,
                                   Rectangle2D dataArea) {
        Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double(
                x + dx - width / 2.0, y + dy - height / 2.0, width, height), dataArea);
        if (candidate == null) return;
        for (LineLabelCandidate existing : result) {
            if (Math.abs(existing.getRectangle().getX() - candidate.getX()) < 0.5
                    && Math.abs(existing.getRectangle().getY() - candidate.getY()) < 0.5) {
                return;
            }
        }
        result.add(new LineLabelCandidate(candidate));
    }
    private double lineLabelCost(LineLabelCandidate candidate, DayLabel label,
                                List<DayLabel> sameDayLabels,
                                List<Rectangle2D> placedLabels) {
        Rectangle2D rectangle = candidate.getRectangle();
        double dx = rectangle.getCenterX() - label.getPoint().getX();
        double dy = rectangle.getCenterY() - label.getPoint().getY();
        double ownDistance = Math.sqrt(dx * dx + dy * dy);
        double cost = ownDistance * 10.0 + overlapArea(rectangle, placedLabels) * 10000.0;
        Rectangle2D ownMarker = new Rectangle2D.Double(label.getPoint().getX() - 9.0,
                label.getPoint().getY() - 9.0, 18.0, 18.0);
        if (rectangle.intersects(ownMarker)) cost += 3000.0;
        for (DayLabel other : sameDayLabels) {
            if (other == label) continue;
            double otherDx = rectangle.getCenterX() - other.getPoint().getX();
            double otherDy = rectangle.getCenterY() - other.getPoint().getY();
            double otherDistance = Math.sqrt(otherDx * otherDx + otherDy * otherDy);
            if (otherDistance < ownDistance - 1.0) {
                cost += (ownDistance - otherDistance) * 5000.0;
            }
            Rectangle2D otherMarker = new Rectangle2D.Double(other.getPoint().getX() - 9.0,
                    other.getPoint().getY() - 9.0, 18.0, 18.0);
            if (rectangle.intersects(otherMarker)) cost += 2000.0;
        }
        return cost;
    }
    private Rectangle2D boundToDataArea(Rectangle2D rectangle, Rectangle2D dataArea) {
        double minX = dataArea.getMinX() + 16.0;
        double minY = dataArea.getMinY() + 2.0;
        double maxX = dataArea.getMaxX() - rectangle.getWidth() - 2.0;
        double maxY = dataArea.getMaxY() - rectangle.getHeight() - 2.0;
        if (maxX < minX || maxY < minY) return null;
        double x = Math.max(minX, Math.min(rectangle.getX(), maxX));
        double y = Math.max(minY, Math.min(rectangle.getY(), maxY));
        return new Rectangle2D.Double(x, y, rectangle.getWidth(), rectangle.getHeight());
    }
    private double overlapArea(Rectangle2D rectangle, List<Rectangle2D> occupied) {
        double total = 0.0;
        for (Rectangle2D other : occupied) {
            double width = Math.min(rectangle.getMaxX(), other.getMaxX())
                    - Math.max(rectangle.getMinX(), other.getMinX());
            double height = Math.min(rectangle.getMaxY(), other.getMaxY())
                    - Math.max(rectangle.getMinY(), other.getMinY());
            if (width > 0.0 && height > 0.0) total += width * height;
        }
        return total;
    }
    static int scaleLineChartHeight(int height) {
        return (int) Math.round(height * LINE_CHART_HEIGHT_SCALE);
    }
    static long scaleLineChartHeight(long height) {
        return Math.round(height * LINE_CHART_HEIGHT_SCALE);
    }
    static BigDecimal displayValue(BigDecimal value, boolean percentItemLabels, int valueDecimals) {
        if (value == null) return null;
        int scale = percentItemLabels ? PERCENT_ITEM_LABEL_DECIMALS + 2 : valueDecimals;
        return value.setScale(scale, RoundingMode.HALF_UP);
    }
    private String formatLineLabel(Number value, boolean percentItemLabels, int valueDecimals) {
        if (percentItemLabels) {
            NumberFormat formatter = NumberFormat.getPercentInstance();
            formatter.setMaximumFractionDigits(PERCENT_ITEM_LABEL_DECIMALS);
            return formatter.format(value.doubleValue());
        }
        DecimalFormat formatter = new DecimalFormat("#,##0");
        formatter.setMaximumFractionDigits(valueDecimals);
        return formatter.format(value.doubleValue());
    }
    private void drawLabelWithHalo(Graphics2D graphics, String text, Rectangle2D rectangle,
                                   FontMetrics metrics, Color color) {
        int x = (int) Math.round(rectangle.getX());
        int y = (int) Math.round(rectangle.getY() + metrics.getAscent());
        // A stroked white outline keeps the digit legible even when a line runs
        // underneath it, which lets labels stay right next to their own point.
        java.awt.font.TextLayout layout = new java.awt.font.TextLayout(
                text, graphics.getFont(), graphics.getFontRenderContext());
        java.awt.Shape outline = layout.getOutline(
                java.awt.geom.AffineTransform.getTranslateInstance(x, y));
        graphics.setColor(Color.WHITE);
        graphics.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH,
                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        graphics.draw(outline);
        graphics.setColor(color);
        graphics.fill(outline);
    }
    public static final List<String> DAY_LABELS = Arrays.asList(
            "10月1日", "10月2日", "10月3日", "10月4日",
            "10月5日", "10月6日", "10月7日", "10月8日");
    public static List<ChartSeries> emptySeries(int size) {
        List<ChartSeries> result = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            result.add(new ChartSeries());
        }
        return result;
    }
    @Data
    public static class ChartSeries {
        private String name;
        private List<BigDecimal> values = new ArrayList<>();
    }
    @Data
    public static class ChartCategory {
        private String name;
        private BigDecimal value;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
New file
@@ -0,0 +1,450 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
import com.trafficaudit.observation.dto.ObservationDailyOperation;
import com.trafficaudit.observation.mapper.ObservationStationFlowMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@Service
public class HolidayTrendService {
    private static final BigDecimal HUNDRED = new BigDecimal("100");
    @Value("${holiday.trend-template:docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx}")
    private String trendTemplatePath;
    @Resource
    private HolidayStatService statService;
    @Resource
    private HolidayConfigService configService;
    @Resource
    private HolidayTrendChartService chartService;
    @Resource
    private HolidayTrendWordService wordService;
    @Resource
    private ObservationStationFlowMapper observationStationFlowMapper;
    public byte[] generate(int year, String holidayType) {
        String holidayLabel = HolidayConstants.HOLIDAY_TYPE_LABELS.get(holidayType);
        if (holidayLabel == null) {
            throw new IllegalArgumentException("不支持的节假日类型");
        }
        List<Integer> years = Arrays.asList(year, year - 1, year - 2);
        Map<Integer, YearData> yearData = new LinkedHashMap<>();
        for (Integer item : years) {
            yearData.put(item, buildYearData(item, holidayType));
        }
        YearData selected = yearData.get(year);
        if (selected == null || !selected.hasAnyData()) {
            throw new IllegalArgumentException("所选年份没有足够的走势图数据");
        }
        String prefix = "“" + holidayLabel + "”假期";
        List<HolidayTrendWordService.DocumentBlock> blocks = new ArrayList<>();
        blocks.add(new HolidayTrendWordService.SectionBlock("一、总体情况"));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "全省跨区域人员流动量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.totalDaily), false, 1100, 715),
                lineImageSize(5268595L, 3424555L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期累计跨区域人员流动量走势",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.totalCumulative), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期各交通运输方式客运量占比情况",
                chartService.pieChart("人次", selected.modalShare(), 1000, 607), true,
                new HolidayTrendWordService.ImageSize(5106670L, 3097530L)));
        blocks.add(new HolidayTrendWordService.SectionBlock("二、各种运输方式客流量走势"));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期自驾出行量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.selfDrivingPersonFlow), false, 1100, 522),
                lineImageSize(5232400L, 2484120L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期铁路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.rail), false, 1100, 445),
                lineImageSize(5264785L, 2129155L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期水路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.water), false, 1100, 433),
                lineImageSize(5274310L, 2075815L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期民航客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.civilAviation), false, 1100, 521),
                lineImageSize(5271135L, 2496185L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期道路客运量情况",
                chartService.lineChart("万人次",
                        series(years, yearData, item -> item.road), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.SectionBlock("三、车流量走势"));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期高速公路车流量情况",
                chartService.lineChart("万辆次",
                        series(years, yearData, item -> item.expressway), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期普通国省道车流量情况",
                chartService.lineChart("万辆次",
                        series(years, yearData, item -> item.nationalProvincial), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期全省车流总量情况",
                chartService.lineChart("万辆次",
                        series(years, yearData, item -> item.totalVehicleFlow), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期公路出行结构",
                chartService.stackedBarChart("万人次",
                        Arrays.asList((year - 2) + "å¹´", (year - 1) + "å¹´", year + "å¹´"),
                        roadStructureSeries(years, yearData), false, 1000, 607), true,
                new HolidayTrendWordService.ImageSize(5106670L, 3097530L)));
        blocks.add(new HolidayTrendWordService.SectionBlock("附录一 æ¯æ—¥å®¢æµæž„成变化"));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                "“" + holidayLabel + "”假期每日客流构成变化",
                chartService.stackedBarChart("占比",
                        HolidayTrendChartService.DAY_LABELS,
                        dailyShareSeries(selected), true, 1000, 607), true,
                new HolidayTrendWordService.ImageSize(5106670L, 3097530L)));
        blocks.add(new HolidayTrendWordService.SectionBlock("附录二 æ™®é€šå›½çœé“观测站路网运行"));
        ObservationData observation = observationData(year, holidayType);
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均机动车速度",
                chartService.lineChart("公里/小时",
                        Collections.singletonList(series("平均速度", observation.speed)), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        blocks.add(new HolidayTrendWordService.ChartBlock(
                prefix + "普通国省道观测站平均拥挤度",
                chartService.lineChart("拥挤度",
                        Collections.singletonList(series("平均拥挤度", observation.congestion)), false, 1100, 590),
                lineImageSize(5268595L, 2821305L)));
        File template = resolveTemplateFile();
        if (!template.exists() || !template.isFile()) {
            throw new IllegalArgumentException("走势图模板文件不存在: " + template.getAbsolutePath());
        }
        try (InputStream input = new FileInputStream(template)) {
            return wordService.generate(readAll(input), year, blocks);
        } catch (Exception e) {
            throw new IllegalArgumentException("走势图生成失败: " + e.getMessage(), e);
        }
    }
    private YearData buildYearData(int year, String holidayType) {
        List<DailyMetrics> metrics = new ArrayList<>();
        for (int day = 1; day <= HolidayTrendChartService.DAY_LABELS.size(); day++) {
            metrics.add(statService.dailyMetrics(year, holidayType, day));
        }
        YearData data = new YearData();
        data.year = year;
        data.rail = values(metrics, DailyMetrics::getRail);
        data.civilAviation = values(metrics, DailyMetrics::getCivilAviation);
        data.road = values(metrics, DailyMetrics::getRoad);
        data.water = values(metrics, DailyMetrics::getWater);
        data.expressway = values(metrics, DailyMetrics::getExpressway);
        data.nationalProvincial = values(metrics, DailyMetrics::getNationalProvincial);
        data.selfDrivingPersonFlow = values(metrics, DailyMetrics::getNonBusinessPersonFlow);
        data.selfDrivingVehicleFlow = combine(metrics,
                DailyMetrics::getExpressway, DailyMetrics::getNationalProvincial);
        data.totalVehicleFlow = combine(metrics,
                DailyMetrics::getExpressway, DailyMetrics::getNationalProvincial);
        data.totalDaily = values(metrics, DailyMetrics::getTotalPersonFlow);
        data.totalCumulative = cumulative(data.totalDaily);
        data.railCumulative = cumulative(data.rail);
        data.civilAviationCumulative = cumulative(data.civilAviation);
        data.roadCumulative = cumulative(data.road);
        data.waterCumulative = cumulative(data.water);
        data.selfDrivingPersonFlowCumulative = cumulative(data.selfDrivingPersonFlow);
        return data;
    }
    private List<HolidayTrendChartService.ChartSeries> series(
            List<Integer> years, Map<Integer, YearData> data,
            Function<YearData, List<BigDecimal>> selector) {
        List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>();
        for (Integer year : years) {
            YearData item = data.get(year);
            result.add(series(year + "å¹´", selector.apply(item)));
        }
        return result;
    }
    private List<HolidayTrendChartService.ChartSeries> yoySeries(
            String label, List<Integer> years, Map<Integer, YearData> data,
            Function<YearData, List<BigDecimal>> selector) {
        List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>();
        YearData selected = data.get(years.get(0));
        List<BigDecimal> selectedValues = selector.apply(selected);
        for (int i = 1; i < years.size(); i++) {
            YearData comparison = data.get(years.get(i));
            result.add(series(label + "同比" + comparison.year,
                    percentageChanges(selectedValues, selector.apply(comparison))));
        }
        return result;
    }
    private List<HolidayTrendChartService.ChartSeries> dailyShareSeries(YearData data) {
        List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>();
        result.add(series("自驾", share(data.selfDrivingPersonFlow, data)));
        result.add(series("道路", share(data.road, data)));
        result.add(series("铁路", share(data.rail, data)));
        result.add(series("民航", share(data.civilAviation, data)));
        result.add(series("æ°´è·¯", share(data.water, data)));
        return result;
    }
    private List<BigDecimal> share(List<BigDecimal> values, YearData data) {
        List<BigDecimal> result = new ArrayList<>();
        for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
            BigDecimal total = dayTotal(data, i);
            BigDecimal value = valueAt(values, i);
            result.add(total == null || total.signum() == 0
                    ? null : HolidayNumberUtils.divide(value == null ? BigDecimal.ZERO : value, total));
        }
        return result;
    }
    private BigDecimal dayTotal(YearData data, int index) {
        BigDecimal total = BigDecimal.ZERO;
        boolean hasValue = false;
        for (List<BigDecimal> values : Arrays.asList(
                data.selfDrivingPersonFlow, data.road, data.rail,
                data.civilAviation, data.water)) {
            BigDecimal value = valueAt(values, index);
            if (value != null) {
                total = total.add(value);
                hasValue = true;
            }
        }
        return hasValue ? HolidayNumberUtils.normalize(total) : null;
    }
    private List<HolidayTrendChartService.ChartSeries> roadStructureSeries(
            List<Integer> years, Map<Integer, YearData> data) {
        List<BigDecimal> road = new ArrayList<>();
        List<BigDecimal> selfDriving = new ArrayList<>();
        for (int i = years.size() - 1; i >= 0; i--) {
            YearData item = data.get(years.get(i));
            road.add(sum(item.road));
            selfDriving.add(sum(item.selfDrivingPersonFlow));
        }
        return Arrays.asList(
                series("自驾出行量", selfDriving),
                series("道路客运量", road));
    }
    private ObservationData observationData(int year, String holidayType) {
        List<HolidayCalendar> days = configService.findHolidayDays(year, holidayType);
        if (days.isEmpty()) {
            return new ObservationData(nulls(), nulls());
        }
        LocalDate start = days.get(0).getHolidayDate();
        LocalDate end = start.plusDays(HolidayTrendChartService.DAY_LABELS.size() - 1L);
        List<ObservationDailyOperation> rows = observationStationFlowMapper.selectDailyOperations(start, end);
        Map<LocalDate, ObservationDailyOperation> byDate = new LinkedHashMap<>();
        for (ObservationDailyOperation row : rows) {
            byDate.put(row.getObsDate(), row);
        }
        List<BigDecimal> speed = new ArrayList<>();
        List<BigDecimal> congestion = new ArrayList<>();
        for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
            ObservationDailyOperation row = byDate.get(start.plusDays(i));
            speed.add(row == null ? null : row.getAverageSpeed());
            congestion.add(row == null ? null : row.getAverageCongestion());
        }
        return new ObservationData(speed, congestion);
    }
    private HolidayTrendChartService.ChartSeries series(String name, List<BigDecimal> values) {
        HolidayTrendChartService.ChartSeries result = new HolidayTrendChartService.ChartSeries();
        result.setName(name);
        result.setValues(values == null ? nulls() : values);
        return result;
    }
    private List<BigDecimal> values(List<DailyMetrics> metrics, Function<DailyMetrics, BigDecimal> selector) {
        List<BigDecimal> result = new ArrayList<>();
        for (DailyMetrics item : metrics) {
            result.add(item == null ? null : selector.apply(item));
        }
        return result;
    }
    private List<BigDecimal> combine(List<DailyMetrics> metrics,
                                     Function<DailyMetrics, BigDecimal> left,
                                     Function<DailyMetrics, BigDecimal> right) {
        List<BigDecimal> result = new ArrayList<>();
        for (DailyMetrics item : metrics) {
            result.add(item == null ? null : HolidayNumberUtils.sumNullable(left.apply(item), right.apply(item)));
        }
        return result;
    }
    private List<BigDecimal> cumulative(List<BigDecimal> values) {
        List<BigDecimal> result = new ArrayList<>();
        BigDecimal total = null;
        for (BigDecimal value : values) {
            if (value != null) total = HolidayNumberUtils.sumNullable(total, value);
            result.add(value == null ? null : total);
        }
        return result;
    }
    private List<BigDecimal> percentageChanges(List<BigDecimal> current, List<BigDecimal> previous) {
        List<BigDecimal> result = new ArrayList<>();
        for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
            BigDecimal currentValue = valueAt(current, i);
            BigDecimal previousValue = valueAt(previous, i);
            BigDecimal ratio = HolidayNumberUtils.divide(currentValue, previousValue);
            result.add(ratio == null ? null : HolidayNumberUtils.multiply(
                    ratio.subtract(BigDecimal.ONE), HUNDRED));
        }
        return result;
    }
    private BigDecimal sum(List<BigDecimal> values) {
        BigDecimal total = null;
        for (BigDecimal value : values) {
            if (value != null) total = HolidayNumberUtils.sumNullable(total, value);
        }
        return total;
    }
    private BigDecimal valueAt(List<BigDecimal> values, int index) {
        return values == null || index >= values.size() ? null : values.get(index);
    }
    private List<BigDecimal> nulls() {
        List<BigDecimal> result = new ArrayList<>();
        for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) result.add(null);
        return result;
    }
    private HolidayTrendWordService.ImageSize lineImageSize(long width, long height) {
        return new HolidayTrendWordService.ImageSize(
                width, HolidayTrendChartService.scaleLineChartHeight(height));
    }
    private File resolveTemplateFile() {
        List<File> candidates = new ArrayList<>();
        File configured = new File(trendTemplatePath);
        candidates.add(configured);
        if (!configured.isAbsolute()) {
            candidates.add(new File(System.getProperty("user.dir"), trendTemplatePath));
            candidates.add(new File(System.getProperty("user.dir"), "../" + trendTemplatePath));
        }
        for (File candidate : candidates) {
            try {
                if (candidate.exists() && candidate.isFile()) return candidate.getCanonicalFile();
            } catch (Exception ignored) {
                // Keep checking.
            }
        }
        return configured;
    }
    private byte[] readAll(InputStream input) throws Exception {
        java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
        byte[] buffer = new byte[8192];
        int length;
        while ((length = input.read(buffer)) > 0) {
            output.write(buffer, 0, length);
        }
        return output.toByteArray();
    }
    private static class YearData {
        private int year;
        private List<BigDecimal> rail;
        private List<BigDecimal> civilAviation;
        private List<BigDecimal> road;
        private List<BigDecimal> water;
        private List<BigDecimal> expressway;
        private List<BigDecimal> nationalProvincial;
        private List<BigDecimal> selfDrivingPersonFlow;
        private List<BigDecimal> selfDrivingVehicleFlow;
        private List<BigDecimal> totalVehicleFlow;
        private List<BigDecimal> totalDaily;
        private List<BigDecimal> totalCumulative;
        private List<BigDecimal> railCumulative;
        private List<BigDecimal> civilAviationCumulative;
        private List<BigDecimal> roadCumulative;
        private List<BigDecimal> waterCumulative;
        private List<BigDecimal> selfDrivingPersonFlowCumulative;
        private boolean hasAnyData() {
            return hasValue(totalDaily) || hasValue(rail) || hasValue(road)
                    || hasValue(expressway) || hasValue(nationalProvincial);
        }
        private boolean hasValue(List<BigDecimal> values) {
            if (values == null) return false;
            for (BigDecimal value : values) {
                if (value != null) return true;
            }
            return false;
        }
        private List<HolidayTrendChartService.ChartCategory> modalShare() {
            List<HolidayTrendChartService.ChartCategory> result = new ArrayList<>();
            result.add(category("自驾", sum(selfDrivingPersonFlow)));
            result.add(category("道路", sum(road)));
            result.add(category("铁路", sum(rail)));
            result.add(category("民航", sum(civilAviation)));
            result.add(category("æ°´è·¯", sum(water)));
            return result;
        }
        private BigDecimal sum(List<BigDecimal> values) {
            BigDecimal total = null;
            for (BigDecimal value : values) {
                if (value != null) total = HolidayNumberUtils.sumNullable(total, value);
            }
            return total;
        }
        private HolidayTrendChartService.ChartCategory category(String name, BigDecimal value) {
            HolidayTrendChartService.ChartCategory category = new HolidayTrendChartService.ChartCategory();
            category.setName(name);
            category.setValue(value == null ? BigDecimal.ZERO : value);
            return category;
        }
    }
    private static class ObservationData {
        private final List<BigDecimal> speed;
        private final List<BigDecimal> congestion;
        private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion) {
            this.speed = speed;
            this.congestion = congestion;
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java
New file
@@ -0,0 +1,535 @@
package com.trafficaudit.holiday.service;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@Service
public class HolidayTrendWordService {
    private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
    private static final String DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main";
    private static final String WORD_DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
    private static final String REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
    private static final String PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
    public byte[] generate(byte[] templateBytes, int year, List<DocumentBlock> blocks) {
        try {
            byte[] documentXml = readEntry(templateBytes, "word/document.xml");
            Document document = parse(documentXml);
            Element body = firstElement(document.getDocumentElement(), "body");
            if (body == null) throw new IllegalArgumentException("走势图模板缺少正文");
            TemplateParts parts = TemplateParts.from(document, body);
            Map<String, byte[]> media = new LinkedHashMap<>();
            List<ImageReference> imageReferences = new ArrayList<>();
            int imageIndex = 1;
            for (DocumentBlock block : blocks) {
                if (!(block instanceof ChartBlock)) continue;
                String imageName = "image" + imageIndex++ + ".png";
                imageReferences.add(new ImageReference("rId" + (100 + imageIndex - 2),
                        "media/" + imageName, ((ChartBlock) block).getPng()));
                media.put("word/media/" + imageName, ((ChartBlock) block).getPng());
            }
            Element sectPr = directChild(body, "sectPr");
            List<Node> removals = directChildren(body);
            for (Node node : removals) {
                if (node != sectPr) body.removeChild(node);
            }
            appendClone(body, parts.titleLine, year + "年“十一”假期全省综合运输客流量", sectPr);
            appendClone(body, parts.subtitleLine, null, sectPr);
            appendClone(body, parts.titleBlank, null, sectPr);
            int chartCursor = 0;
            int chartsOnPage = 0;
            for (int blockIndex = 0; blockIndex < blocks.size(); blockIndex++) {
                DocumentBlock block = blocks.get(blockIndex);
                if (block instanceof PageBreakBlock) {
                    appendPageBreak(body, sectPr);
                    chartsOnPage = 0;
                } else if (block instanceof SectionBlock) {
                    appendClone(body, parts.sectionTemplate, ((SectionBlock) block).getText(), sectPr, true);
                } else if (block instanceof ChartBlock) {
                    ChartBlock chart = (ChartBlock) block;
                    Element titleTemplate = chart.isCompact()
                            ? parts.compactTitleTemplate : parts.lineTitleTemplate;
                    Element imageTemplate = chart.isCompact()
                            ? parts.compactImageTemplate : parts.lineImageTemplate;
                    appendClone(body, titleTemplate, chart.getTitle(), sectPr);
                    Element imageParagraph = clone(imageTemplate);
                    ImageReference imageReference = imageReferences.get(chartCursor++);
                    bindImage(imageParagraph, imageReference, chart.getTitle(), chartCursor,
                            ((ChartBlock) block).getImageSize());
                    body.insertBefore(imageParagraph, sectPr);
                    appendClone(body, parts.chartBlank, null, sectPr);
                    chartsOnPage++;
                    boolean firstChartPageBreak = chartCursor == 1
                            && needsAutomaticPageBreak(blocks, blockIndex + 1);
                    if (firstChartPageBreak
                            || chartsOnPage >= 2
                            && needsAutomaticPageBreak(blocks, blockIndex + 1)) {
                        appendPageBreak(body, sectPr);
                        chartsOnPage = 0;
                    }
                }
            }
            byte[] updatedDocument = serialize(document);
            byte[] updatedRels = buildRelationships(templateBytes, imageReferences);
            Map<String, byte[]> replacements = new HashMap<>();
            replacements.put("word/document.xml", updatedDocument);
            replacements.put("word/_rels/document.xml.rels", updatedRels);
            replacements.putAll(media);
            return rewriteZip(templateBytes, replacements);
        } catch (Exception e) {
            throw new IllegalArgumentException("走势图文档生成失败: " + e.getMessage(), e);
        }
    }
    private byte[] buildRelationships(byte[] templateBytes, List<ImageReference> imageReferences) throws Exception {
        Document rels = parse(readEntry(templateBytes, "word/_rels/document.xml.rels"));
        Element root = rels.getDocumentElement();
        NodeList children = root.getChildNodes();
        List<Node> removals = new ArrayList<>();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node instanceof Element) {
                Element element = (Element) node;
                if ("Relationship".equals(element.getLocalName())
                        && element.getAttribute("Type").endsWith("/image")) {
                    removals.add(node);
                }
            }
        }
        for (Node node : removals) root.removeChild(node);
        for (ImageReference reference : imageReferences) {
            Element relationship = rels.createElementNS(PACKAGE_REL_NS, "Relationship");
            relationship.setAttribute("Id", reference.getRelationshipId());
            relationship.setAttribute("Type",
                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image");
            relationship.setAttribute("Target", reference.getTarget());
            root.appendChild(relationship);
        }
        return serialize(rels);
    }
    private void bindImage(Element paragraph, ImageReference reference, String title, int index,
                           ImageSize imageSize) {
        Element blip = firstElement(paragraph, "blip");
        if (blip == null) throw new IllegalArgumentException("走势图模板图片节点缺失");
        blip.setAttributeNS(REL_NS, "r:embed", reference.getRelationshipId());
        Element srcRect = firstElement(paragraph, "srcRect");
        if (srcRect != null) srcRect.getParentNode().removeChild(srcRect);
        resizeDrawing(paragraph, imageSize);
        Element docPr = firstElement(paragraph, "docPr");
        if (docPr != null) {
            docPr.setAttribute("id", String.valueOf(1000 + index));
            docPr.setAttribute("name", title);
            docPr.setAttribute("descr", title);
        }
    }
    private void resizeDrawing(Element paragraph, ImageSize imageSize) {
        if (imageSize == null) return;
        NodeList extents = paragraph.getElementsByTagNameNS(WORD_DRAWING_NS, "extent");
        for (int i = 0; i < extents.getLength(); i++) {
            Element extent = (Element) extents.item(i);
            extent.setAttribute("cx", String.valueOf(imageSize.getWidth()));
            extent.setAttribute("cy", String.valueOf(imageSize.getHeight()));
        }
        NodeList transforms = paragraph.getElementsByTagNameNS(DRAWING_NS, "xfrm");
        for (int i = 0; i < transforms.getLength(); i++) {
            Element transform = (Element) transforms.item(i);
            Element extent = firstElement(transform, "ext");
            if (extent == null) continue;
            extent.setAttribute("cx", String.valueOf(imageSize.getWidth()));
            extent.setAttribute("cy", String.valueOf(imageSize.getHeight()));
        }
    }
    private void appendClone(Element body, Element template, String text, Element before) {
        appendClone(body, template, text, before, false);
    }
    private void appendClone(Element body, Element template, String text, Element before,
                             boolean removeNumbering) {
        if (template == null) return;
        Element copy = clone(template);
        if (removeNumbering) removeNumbering(copy);
        if (text != null) setParagraphText(copy, text);
        body.insertBefore(copy, before);
    }
    private void removeNumbering(Element paragraph) {
        NodeList numbered = paragraph.getElementsByTagNameNS(WORD_NS, "numPr");
        while (numbered.getLength() > 0) {
            Node node = numbered.item(0);
            node.getParentNode().removeChild(node);
        }
    }
    private boolean needsAutomaticPageBreak(List<DocumentBlock> blocks, int nextIndex) {
        if (nextIndex >= blocks.size()) return false;
        return !(blocks.get(nextIndex) instanceof PageBreakBlock);
    }
    private void appendPageBreak(Element body, Element before) {
        Document document = body.getOwnerDocument();
        Element paragraph = document.createElementNS(WORD_NS, "w:p");
        Element run = document.createElementNS(WORD_NS, "w:r");
        Element br = document.createElementNS(WORD_NS, "w:br");
        br.setAttributeNS(WORD_NS, "w:type", "page");
        run.appendChild(br);
        paragraph.appendChild(run);
        body.insertBefore(paragraph, before);
    }
    private void setParagraphText(Element paragraph, String text) {
        Element pPr = firstDirectChild(paragraph, "pPr");
        Element firstRun = firstDirectChild(paragraph, "r");
        Element rPr = firstRun == null ? null : firstDirectChild(firstRun, "rPr");
        List<Node> removals = new ArrayList<>();
        Node child = paragraph.getFirstChild();
        while (child != null) {
            Node next = child.getNextSibling();
            if (child != pPr) removals.add(child);
            child = next;
        }
        for (Node node : removals) paragraph.removeChild(node);
        Element run = paragraph.getOwnerDocument().createElementNS(WORD_NS, "w:r");
        if (rPr != null) run.appendChild(rPr.cloneNode(true));
        Element t = paragraph.getOwnerDocument().createElementNS(WORD_NS, "w:t");
        t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
        t.setTextContent(text);
        run.appendChild(t);
        paragraph.appendChild(run);
    }
    private Element clone(Element source) {
        return (Element) source.cloneNode(true);
    }
    private byte[] rewriteZip(byte[] source, Map<String, byte[]> replacements) throws Exception {
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        Map<String, Boolean> seen = new HashMap<>();
        try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(source));
             ZipOutputStream zipOutput = new ZipOutputStream(output)) {
            ZipEntry entry;
            byte[] buffer = new byte[8192];
            while ((entry = input.getNextEntry()) != null) {
                seen.put(entry.getName(), Boolean.TRUE);
                byte[] replacement = replacements.get(entry.getName());
                if (isGeneratedChartImage(entry.getName()) && replacement == null) continue;
                zipOutput.putNextEntry(new ZipEntry(entry.getName()));
                if (replacement != null) {
                    zipOutput.write(replacement);
                } else {
                    int length;
                    while ((length = input.read(buffer)) > 0) {
                        zipOutput.write(buffer, 0, length);
                    }
                }
                zipOutput.closeEntry();
            }
            for (Map.Entry<String, byte[]> replacement : replacements.entrySet()) {
                if (Boolean.TRUE.equals(seen.get(replacement.getKey()))) continue;
                zipOutput.putNextEntry(new ZipEntry(replacement.getKey()));
                zipOutput.write(replacement.getValue());
                zipOutput.closeEntry();
            }
        }
        return output.toByteArray();
    }
    private boolean isGeneratedChartImage(String entryName) {
        return entryName != null && entryName.matches("word/media/image[0-9]+\\.png");
    }
    private byte[] readEntry(byte[] zipBytes, String entryName) throws Exception {
        try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
            ZipEntry entry;
            while ((entry = input.getNextEntry()) != null) {
                if (entryName.equals(entry.getName())) return readAll(input);
            }
        }
        throw new IllegalArgumentException("模板中缺少 " + entryName);
    }
    private Document parse(byte[] xml) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
    }
    private byte[] serialize(Document document) throws Exception {
        Transformer transformer = TransformerFactory.newInstance().newTransformer();
        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
        transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        transformer.transform(new DOMSource(document), new StreamResult(output));
        return output.toByteArray();
    }
    private byte[] readAll(InputStream input) throws Exception {
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        byte[] buffer = new byte[8192];
        int length;
        while ((length = input.read(buffer)) > 0) {
            output.write(buffer, 0, length);
        }
        return output.toByteArray();
    }
    private Element firstElement(Element parent, String localName) {
        NodeList nodes = parent.getElementsByTagNameNS("*", localName);
        if (nodes.getLength() > 0) return (Element) nodes.item(0);
        if (DRAWING_NS.equals(parent.getNamespaceURI()) && parent.getLocalName() != null
                && localName.equals(parent.getLocalName())) return parent;
        NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node instanceof Element) {
                Element found = firstElement((Element) node, localName);
                if (found != null) return found;
            }
        }
        return null;
    }
    private Element firstDirectChild(Element parent, String localName) {
        Node node = parent.getFirstChild();
        while (node != null) {
            if (node instanceof Element && localName.equals(localName(node))) return (Element) node;
            node = node.getNextSibling();
        }
        return null;
    }
    private Element directChild(Element parent, String localName) {
        return firstDirectChild(parent, localName);
    }
    private List<Node> directChildren(Element parent) {
        List<Node> result = new ArrayList<>();
        Node node = parent.getFirstChild();
        while (node != null) {
            result.add(node);
            node = node.getNextSibling();
        }
        return result;
    }
    private String localName(Node node) {
        return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName();
    }
    public static class DocumentBlock {
    }
    public static class SectionBlock extends DocumentBlock {
        private final String text;
        public SectionBlock(String text) {
            this.text = text;
        }
        public String getText() {
            return text;
        }
    }
    public static class PageBreakBlock extends DocumentBlock {
    }
    public static class ChartBlock extends DocumentBlock {
        private final String title;
        private final byte[] png;
        private final boolean compact;
        private final ImageSize imageSize;
        public ChartBlock(String title, byte[] png) {
            this(title, png, false);
        }
        public ChartBlock(String title, byte[] png, boolean compact) {
            this(title, png, compact, null);
        }
        public ChartBlock(String title, byte[] png, boolean compact, ImageSize imageSize) {
            this.title = title;
            this.png = png;
            this.compact = compact;
            this.imageSize = imageSize;
        }
        public ChartBlock(String title, byte[] png, ImageSize imageSize) {
            this(title, png, false, imageSize);
        }
        public String getTitle() {
            return title;
        }
        public byte[] getPng() {
            return png;
        }
        public boolean isCompact() {
            return compact;
        }
        public ImageSize getImageSize() {
            return imageSize;
        }
    }
    public static class ImageSize {
        private final long width;
        private final long height;
        public ImageSize(long width, long height) {
            this.width = width;
            this.height = height;
        }
        public long getWidth() {
            return width;
        }
        public long getHeight() {
            return height;
        }
    }
    private static class ImageReference {
        private final String relationshipId;
        private final String target;
        private final byte[] image;
        private ImageReference(String relationshipId, String target, byte[] image) {
            this.relationshipId = relationshipId;
            this.target = target;
            this.image = image;
        }
        private String getRelationshipId() {
            return relationshipId;
        }
        private String getTarget() {
            return target;
        }
        private byte[] getImage() {
            return image;
        }
    }
    private static class TemplateParts {
        private Element titleLine;
        private Element subtitleLine;
        private Element titleBlank;
        private Element sectionTemplate;
        private Element sectionBlank;
        private Element lineTitleTemplate;
        private Element lineImageTemplate;
        private Element compactTitleTemplate;
        private Element compactImageTemplate;
        private Element chartBlank;
        private static TemplateParts from(Document document, Element body) {
            TemplateParts parts = new TemplateParts();
            List<Element> paragraphs = directParagraphs(body);
            parts.titleLine = byText(paragraphs, "2026年“十一”假期全省综合运输客流量");
            parts.subtitleLine = byText(paragraphs, "èµ° åŠ¿ å›¾");
            parts.sectionTemplate = byText(paragraphs, "总体情况");
            parts.lineTitleTemplate = byText(paragraphs, "2026年“十一”假期全省跨区域人员流动量情况");
            parts.lineImageTemplate = imageAfter(paragraphs, parts.lineTitleTemplate);
            parts.compactTitleTemplate = byText(paragraphs, "“十一”假期各交通运输方式客运量占比情况");
            parts.compactImageTemplate = imageAfter(paragraphs, parts.compactTitleTemplate);
            parts.titleBlank = nextParagraph(paragraphs, parts.subtitleLine);
            parts.sectionBlank = nextParagraph(paragraphs, parts.sectionTemplate);
            parts.chartBlank = afterParagraph(paragraphs, parts.lineImageTemplate);
            if (parts.titleLine == null || parts.subtitleLine == null || parts.sectionTemplate == null
                    || parts.lineTitleTemplate == null || parts.lineImageTemplate == null
                    || parts.compactTitleTemplate == null || parts.compactImageTemplate == null) {
                throw new IllegalArgumentException("走势图模板结构不完整");
            }
            return parts;
        }
        private static List<Element> directParagraphs(Element body) {
            List<Element> result = new ArrayList<>();
            Node node = body.getFirstChild();
            while (node != null) {
                if (node instanceof Element && "p".equals(((Element) node).getLocalName())) {
                    result.add((Element) node);
                }
                node = node.getNextSibling();
            }
            return result;
        }
        private static Element byText(List<Element> paragraphs, String text) {
            for (Element paragraph : paragraphs) {
                if (text.equals(paragraphText(paragraph))) return paragraph;
            }
            return null;
        }
        private static Element imageAfter(List<Element> paragraphs, Element anchor) {
            int index = paragraphs.indexOf(anchor);
            if (index < 0) return null;
            for (int i = index + 1; i < paragraphs.size(); i++) {
                if (firstDescendant(paragraphs.get(i), "blip") != null) return paragraphs.get(i);
            }
            return null;
        }
        private static Element nextParagraph(List<Element> paragraphs, Element anchor) {
            int index = paragraphs.indexOf(anchor);
            return index >= 0 && index + 1 < paragraphs.size() ? paragraphs.get(index + 1) : null;
        }
        private static Element afterParagraph(List<Element> paragraphs, Element anchor) {
            int index = paragraphs.indexOf(anchor);
            return index >= 0 && index + 1 < paragraphs.size() ? paragraphs.get(index + 1) : null;
        }
        private static Element firstDescendant(Element parent, String localName) {
            NodeList nodes = parent.getElementsByTagNameNS("*", localName);
            return nodes.getLength() == 0 ? null : (Element) nodes.item(0);
        }
        private static String paragraphText(Element paragraph) {
            StringBuilder builder = new StringBuilder();
            NodeList nodes = paragraph.getElementsByTagNameNS(WORD_NS, "t");
            for (int i = 0; i < nodes.getLength(); i++) {
                builder.append(nodes.item(i).getTextContent());
            }
            return builder.toString();
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java
New file
@@ -0,0 +1,177 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class HolidayVehicleAdjustmentService {
    private static final int REASON_MAX_LENGTH = 500;
    @Resource
    private HolidayVehicleAdjustmentMapper adjustmentMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    public List<HolidayVehicleAdjustment> list(Integer year) {
        List<HolidayVehicleAdjustment> rows = adjustmentMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleAdjustment>()
                        .eq(year != null, HolidayVehicleAdjustment::getYear, year)
                        .orderByDesc(HolidayVehicleAdjustment::getYear)
                        .orderByAsc(HolidayVehicleAdjustment::getFlowDate));
        if (rows.isEmpty()) return rows;
        Set<Integer> years = new HashSet<>();
        for (HolidayVehicleAdjustment row : rows) {
            if (row.getYear() != null) years.add(row.getYear());
        }
        if (years.isEmpty()) return rows;
        List<HolidayVehicleFlow> sourceRows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL)
                        .eq(HolidayVehicleFlow::getVehicleType, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER)
                        .in(HolidayVehicleFlow::getYear, years));
        Map<String, BigDecimal> originalValues = new HashMap<>();
        for (HolidayVehicleFlow sourceRow : sourceRows) {
            if (sourceRow.getYear() == null || sourceRow.getFlowDate() == null || sourceRow.getVehicleCount() == null) {
                continue;
            }
            originalValues.put(sourceKey(sourceRow.getYear(), sourceRow.getFlowDate()), sourceRow.getVehicleCount());
        }
        for (HolidayVehicleAdjustment row : rows) {
            if (row.getYear() != null && row.getFlowDate() != null) {
                row.setOriginalValue(originalValues.get(sourceKey(row.getYear(), row.getFlowDate())));
            }
        }
        return rows;
    }
    public BigDecimal findOriginalValue(Integer year, LocalDate flowDate) {
        if (year == null || flowDate == null || flowDate.getYear() != year) {
            throw new IllegalArgumentException("年份和日期不正确");
        }
        HolidayVehicleFlow sourceRow = vehicleFlowMapper.selectOne(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getFlowDate, flowDate)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL)
                        .eq(HolidayVehicleFlow::getVehicleType, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER)
                        .last("limit 1"));
        return sourceRow == null ? null : sourceRow.getVehicleCount();
    }
    @Transactional
    public HolidayVehicleAdjustment save(Map<String, Object> params) {
        Long id = longValue(params.get("id"));
        Integer year = intValue(params.get("year"));
        LocalDate flowDate = dateValue(params.get("flowDate"));
        BigDecimal adjustedValue = decimalValue(params.get("adjustedValue"));
        String reason = textValue(params.get("reason"));
        if (year == null || year < 2000 || year > 2100) {
            throw new IllegalArgumentException("年份不正确");
        }
        if (year >= LocalDate.now().getYear()) {
            throw new IllegalArgumentException("往年国省道数据调整仅维护历史年份");
        }
        if (flowDate == null || flowDate.getYear() != year) {
            throw new IllegalArgumentException("日期必须属于所选年份");
        }
        if (adjustedValue == null || adjustedValue.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("调整后的国省道车流量不能小于 0");
        }
        if (reason.isEmpty()) {
            throw new IllegalArgumentException("调整原因不能为空");
        }
        if (reason.length() > REASON_MAX_LENGTH) {
            throw new IllegalArgumentException("调整原因不能超过 " + REASON_MAX_LENGTH + " ä¸ªå­—符");
        }
        HolidayVehicleAdjustment sameKey = adjustmentMapper.selectOne(
                new LambdaQueryWrapper<HolidayVehicleAdjustment>()
                        .eq(HolidayVehicleAdjustment::getYear, year)
                        .eq(HolidayVehicleAdjustment::getFlowDate, flowDate)
                        .last("limit 1"));
        HolidayVehicleAdjustment entity;
        if (id == null) {
            entity = sameKey == null ? new HolidayVehicleAdjustment() : sameKey;
        } else {
            entity = adjustmentMapper.selectById(id);
            if (entity == null) {
                throw new IllegalArgumentException("调整记录不存在");
            }
            if (sameKey != null && !sameKey.getId().equals(id)) {
                throw new IllegalArgumentException("该年份和日期已存在调整记录");
            }
        }
        if (entity.getId() == null) {
            entity.setCreatedAt(LocalDateTime.now());
        }
        entity.setYear(year);
        entity.setFlowDate(flowDate);
        entity.setAdjustedValue(HolidayNumberUtils.normalize(adjustedValue));
        entity.setReason(reason);
        entity.setUpdatedAt(LocalDateTime.now());
        if (entity.getId() == null) {
            adjustmentMapper.insert(entity);
        } else {
            adjustmentMapper.updateById(entity);
        }
        return entity;
    }
    @Transactional
    public void delete(Long id) {
        if (id == null) {
            throw new IllegalArgumentException("缺少记录 id");
        }
        adjustmentMapper.deleteById(id);
    }
    private String sourceKey(Integer year, LocalDate flowDate) {
        return year + "|" + flowDate;
    }
    private Long longValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Long.valueOf(value.toString().trim());
    }
    private Integer intValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return Integer.valueOf(value.toString().trim());
    }
    private BigDecimal decimalValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return HolidayNumberUtils.decimalValue(value);
    }
    private LocalDate dateValue(Object value) {
        if (value == null || value.toString().trim().isEmpty()) return null;
        return LocalDate.parse(value.toString().trim());
    }
    private String textValue(Object value) {
        return value == null ? "" : value.toString().trim();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
New file
@@ -0,0 +1,1083 @@
package com.trafficaudit.holiday.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayPassengerCoefficientMapper;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class HolidayWorkbookService {
    private static final Map<String, String> PASSENGER_SHEET_MODE = new LinkedHashMap<>();
    private static final int FIRST_DATA_ROW_INDEX = 2;
    private static final int DAY_ROW_SCAN_LIMIT = 30;
    private static final int[] REFERENCE_YEARS = {2024, 2025, 2026};
    static {
        PASSENGER_SHEET_MODE.put("铁路", HolidayConstants.MODE_RAIL);
        PASSENGER_SHEET_MODE.put("民航", HolidayConstants.MODE_CIVIL_AVIATION);
        PASSENGER_SHEET_MODE.put("道路", HolidayConstants.MODE_ROAD);
        PASSENGER_SHEET_MODE.put("æ°´è·¯", HolidayConstants.MODE_WATER);
    }
    @Resource
    private HolidayConfigService configService;
    @Resource
    private HolidayPassengerFlowMapper passengerFlowMapper;
    @Resource
    private HolidayVehicleFlowMapper vehicleFlowMapper;
    @Resource
    private HolidayPassengerCoefficientMapper coefficientMapper;
    @Value("${holiday.reference-path:docs/专项统计/重要节假日/2026年十一数据.xlsx}")
    private String referencePath;
    public File resolveReferenceFile() {
        List<File> candidates = new ArrayList<>();
        File configured = new File(referencePath);
        candidates.add(configured);
        if (!configured.isAbsolute()) {
            candidates.add(new File(System.getProperty("user.dir"), referencePath));
            candidates.add(new File(System.getProperty("user.dir"), "../" + referencePath));
        }
        for (File candidate : candidates) {
            try {
                if (candidate.exists() && candidate.isFile()) {
                    return candidate.getCanonicalFile();
                }
            } catch (Exception ignored) {
                // Keep checking the remaining candidate paths.
            }
        }
        return configured;
    }
    public String referenceFileName() {
        return resolveReferenceFile().getName();
    }
    @Transactional
    public ImportSummary importWorkbook(MultipartFile file, int year, String holidayType, boolean allowHistoricalOverwrite) {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("请选择要导入的 Excel æ–‡ä»¶");
        }
        List<HolidayCalendar> holidayDays = configService.findHolidayDays(year, holidayType);
        if (holidayDays.isEmpty()) {
            throw new IllegalArgumentException("所选年份和节假日尚未配置日期,请先在系统配置中维护节假日");
        }
        Map<Integer, LocalDate> dateByDay = new LinkedHashMap<>();
        for (HolidayCalendar day : holidayDays) {
            dateByDay.put(day.getDayIndex(), day.getHolidayDate());
        }
        if (!allowHistoricalOverwrite) {
            ImportSummary historicalSummary = detectHistoricalChanges(file, year, dateByDay);
            if (historicalSummary.isConfirmationRequired()) {
                return historicalSummary;
            }
        }
        ImportSummary summary = new ImportSummary();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            importPassengerSheets(workbook, evaluator, year, holidayType, dateByDay, summary);
            importVehicleSheets(workbook, evaluator, year, dateByDay, summary);
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
        }
        return summary;
    }
    private ImportSummary detectHistoricalChanges(MultipartFile file, int year, Map<Integer, LocalDate> dateByDay) {
        ImportSummary summary = new ImportSummary();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            int latestDay = findLatestIncomingDay(workbook, evaluator, year, dateByDay);
            if (latestDay <= 1) {
                return summary;
            }
            Map<LocalDate, Map<String, BigDecimal>> existingPassenger = loadPassengerByDate(year);
            Map<LocalDate, Map<String, BigDecimal>> existingVehicle = loadVehicleByDate(year);
            detectPassengerHistory(workbook, evaluator, year, dateByDay, latestDay, existingPassenger, summary);
            detectVehicleHistory(workbook, evaluator, year, dateByDay, latestDay, existingVehicle, summary);
            if (!summary.getHistoricalChanges().isEmpty()) {
                summary.setConfirmationRequired(true);
            }
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
        }
        return summary;
    }
    private int findLatestIncomingDay(Workbook workbook, FormulaEvaluator evaluator, int year, Map<Integer, LocalDate> dateByDay) {
        int latest = 0;
        for (Integer dayIndex : dateByDay.keySet()) {
            if (hasPassengerValue(workbook, evaluator, year, dayIndex)
                    || hasVehicleValue(workbook, evaluator, year, dayIndex)) {
                latest = Math.max(latest, dayIndex);
            }
        }
        return latest;
    }
    private boolean hasPassengerValue(Workbook workbook, FormulaEvaluator evaluator, int year, int dayIndex) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            Row row = findDayRow(sheet, evaluator, dayIndex);
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) return true;
        }
        return false;
    }
    private boolean hasVehicleValue(Workbook workbook, FormulaEvaluator evaluator, int year, int dayIndex) {
        Sheet expressway = workbook.getSheet("高速公路");
        if (expressway != null) {
            Integer yearColumn = findYearColumn(expressway, evaluator, year, 2, 7);
            if (yearColumn != null) {
                Row row = findDayRow(expressway, evaluator, dayIndex);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
        Sheet nationalProvincial = workbook.getSheet("普通国省道");
        if (nationalProvincial != null) {
            Integer yearColumn = findYearColumn(nationalProvincial, evaluator, year, 3, 8);
            if (yearColumn != null) {
                Row row = findDayRow(nationalProvincial, evaluator, dayIndex);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
        return false;
    }
    private void detectPassengerHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                       Map<Integer, LocalDate> dateByDay, int latestDay,
                                       Map<LocalDate, Map<String, BigDecimal>> existing,
                                       ImportSummary summary) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                if (day.getKey() >= latestDay) continue;
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
                if (incoming == null) continue;
                Map<String, BigDecimal> existingDay = existing.get(day.getValue());
                BigDecimal oldValue = existingDay == null ? null : existingDay.get(entry.getValue());
                if (oldValue != null && !sameValue(oldValue, incoming)) {
                    summary.addHistoricalChange("第" + day.getKey() + "日 " + day.getValue() + " "
                            + entry.getKey() + ":原值 " + formatChangeValue(oldValue) + ",导入值 " + formatChangeValue(incoming));
                }
            }
        }
    }
    private void detectVehicleHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                      Map<Integer, LocalDate> dateByDay, int latestDay,
                                      Map<LocalDate, Map<String, BigDecimal>> existing,
                                      ImportSummary summary) {
        detectVehicleSheetHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary,
                "高速公路", HolidayConstants.ROAD_EXPRESSWAY, 2, 7);
        detectVehicleSheetHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary,
                "普通国省道", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, 3, 8);
        detectComparisonBaseHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary);
    }
    private void detectVehicleSheetHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                           Map<Integer, LocalDate> dateByDay, int latestDay,
                                           Map<LocalDate, Map<String, BigDecimal>> existing,
                                           ImportSummary summary, String sheetName, String roadCategory,
                                           int startColumn, int endColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            if (day.getKey() >= latestDay) continue;
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
            if (incoming == null) continue;
            Map<String, BigDecimal> existingDay = existing.get(day.getValue());
            BigDecimal oldValue = existingDay == null ? null : existingDay.get(roadCategory);
            if (oldValue != null && !sameValue(oldValue, incoming)) {
                summary.addHistoricalChange("第" + day.getKey() + "日 " + day.getValue() + " "
                        + sheetName + ":原值 " + formatChangeValue(oldValue) + ",导入值 " + formatChangeValue(incoming));
            }
        }
    }
    private void detectComparisonBaseHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
                                             Map<Integer, LocalDate> dateByDay, int latestDay,
                                             Map<LocalDate, Map<String, BigDecimal>> existingVehicle,
                                             ImportSummary summary) {
        if (!HolidayConstants.supportsComparisonBase(year)) return;
        Sheet sheet = workbook.getSheet("普通国省道");
        if (sheet == null) return;
        Map<LocalDate, BigDecimal> existingBase = loadComparisonBaseByDate(year);
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            if (day.getKey() >= latestDay) continue;
            Map<String, BigDecimal> existingDay = existingVehicle.get(day.getValue());
            if (existingDay == null || existingDay.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL) == null) continue;
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal incoming;
            try {
                incoming = numberValue(row.getCell(2), evaluator);
            } catch (Exception e) {
                summary.addError("普通国省道 ç¬¬" + (row.getRowNum() + 1) + "行环比基期:" + e.getMessage());
                continue;
            }
            BigDecimal oldValue = existingBase.get(day.getValue());
            if (!sameNullableValue(oldValue, incoming)) {
                summary.addHistoricalChange("第" + day.getKey() + "日 " + day.getValue() + " æ™®é€šå›½çœé“环比基期:原值 "
                        + nullableChangeValue(oldValue) + ",导入值 " + nullableChangeValue(incoming));
            }
        }
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
        for (HolidayPassengerFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getTransportMode(), row.getPassengerCount());
        }
        return result;
    }
    private Map<LocalDate, Map<String, BigDecimal>> loadVehicleByDate(int year) {
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>().eq(HolidayVehicleFlow::getYear, year));
        for (HolidayVehicleFlow row : rows) {
            result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
                    .put(row.getRoadCategory(), row.getVehicleCount());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadComparisonBaseByDate(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
        for (HolidayVehicleFlow row : rows) result.put(row.getFlowDate(), row.getComparisonBase());
        return result;
    }
    private boolean sameValue(BigDecimal left, BigDecimal right) {
        return HolidayNumberUtils.sameValue(left, right);
    }
    private boolean sameNullableValue(BigDecimal left, BigDecimal right) {
        if (left == null || right == null) return left == right;
        return sameValue(left, right);
    }
    private String nullableChangeValue(BigDecimal value) {
        return value == null ? "空" : formatChangeValue(value);
    }
    private String formatChangeValue(BigDecimal value) {
        return HolidayNumberUtils.changeText(value);
    }
    @Transactional
    public void seedReferenceDataIfNeeded() {
        File reference = resolveReferenceFile();
        if (!reference.exists() || !reference.isFile()) {
            log.warn("Holiday reference workbook not found: {}", reference.getAbsolutePath());
            return;
        }
        try (InputStream in = new FileInputStream(reference); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            seedNationalDayCalendar();
            ensurePreviousDayCalendarIfMissing();
            for (int year : REFERENCE_YEARS) {
                if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)) == 0) {
                    seedPassengerYear(workbook, evaluator, year);
                }
                if (vehicleFlowMapper.selectCount(new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)) == 0) {
                    seedVehicleYear(workbook, evaluator, year);
                }
                if (coefficientMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                        .eq(HolidayPassengerCoefficient::getYear, year)) == 0) {
                    seedCoefficientYear(workbook, evaluator, year);
                }
            }
            seedPreviousDayPassengerIfMissing(workbook, evaluator);
            seedRoad2019IfMissing(workbook, evaluator);
        } catch (Exception e) {
            log.error("Failed to seed holiday reference data", e);
        }
    }
    public byte[] exportWorkbook(int year, String holidayType) {
        File reference = resolveReferenceFile();
        if (!reference.exists() || !reference.isFile()) {
            throw new IllegalArgumentException("节假日模板文件不存在: " + reference.getAbsolutePath());
        }
        try (InputStream in = new FileInputStream(reference); XSSFWorkbook workbook = new XSSFWorkbook(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            fillPassengerSheets(workbook, evaluator, year, holidayType);
            fillVehicleSheets(workbook, evaluator, year, holidayType);
            fillCoefficients(workbook, evaluator, year, holidayType);
            workbook.setForceFormulaRecalculation(true);
            try {
                evaluator.evaluateAll();
            } catch (Exception e) {
                log.warn("Workbook formula recalculation did not complete; Excel will recalculate on open", e);
            }
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            workbook.write(out);
            return out.toByteArray();
        } catch (Exception e) {
            throw new IllegalArgumentException("节假日数据导出失败: " + e.getMessage(), e);
        }
    }
    private void importPassengerSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
                                       String holidayType, Map<Integer, LocalDate> dateByDay,
                                       ImportSummary summary) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) {
                summary.addError(entry.getKey() + ":找不到工作表");
                continue;
            }
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) {
                summary.addError(entry.getKey() + ":找不到 " + year + " å¹´æ•°æ®åˆ—");
                continue;
            }
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) {
                    summary.addError(entry.getKey() + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                    continue;
                }
                Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
                if (rowDay == null || !rowDay.equals(day.getKey())) {
                    summary.addError(entry.getKey() + " ç¬¬" + (row.getRowNum() + 1) + "行:第几日与节假日配置不一致");
                    continue;
                }
                BigDecimal value;
                try {
                    value = numberValue(row.getCell(yearColumn), evaluator);
                } catch (Exception e) {
                    summary.addError(entry.getKey() + " ç¬¬" + (row.getRowNum() + 1) + "行:" + e.getMessage());
                    continue;
                }
                if (value == null) {
                    summary.incrementSkipped();
                    continue;
                }
                upsertPassenger(year, day.getValue(), entry.getValue(), value);
                summary.incrementPassenger();
            }
        }
        importPreviousDayPassengerRows(workbook, evaluator, year, holidayType, dateByDay, summary);
    }
    private void importPreviousDayPassengerRows(Workbook workbook, FormulaEvaluator evaluator, int year,
                                                 String holidayType, Map<Integer, LocalDate> dateByDay,
                                                 ImportSummary summary) {
        LocalDate previousDate = previousDayDate(year, holidayType, dateByDay);
        if (previousDate == null) return;
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            if (!HolidayConstants.MODE_RAIL.equals(entry.getValue())
                    && !HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
                continue;
            }
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            Row row = findPreviousDayRow(sheet, evaluator);
            if (row == null) continue;
            BigDecimal value;
            try {
                value = numberValue(row.getCell(yearColumn), evaluator);
            } catch (Exception e) {
                summary.addError(entry.getKey() + " å‰ä¸€æ—¥è¡Œï¼š" + e.getMessage());
                continue;
            }
            if (value == null) {
                summary.incrementSkipped();
                continue;
            }
            upsertPassenger(year, previousDate, entry.getValue(), value);
            summary.incrementPassenger();
        }
    }
    private void importVehicleSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
                                     Map<Integer, LocalDate> dateByDay, ImportSummary summary) {
        importVehicleSheet(workbook, evaluator, year, dateByDay, summary,
                "高速公路", HolidayConstants.ROAD_EXPRESSWAY, HolidayConstants.VEHICLE_SMALL_PASSENGER, 2, 7, null);
        importVehicleSheet(workbook, evaluator, year, dateByDay, summary,
                "普通国省道", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER, 3, 8,
                HolidayConstants.supportsComparisonBase(year) ? 2 : null);
    }
    private void importVehicleSheet(Workbook workbook, FormulaEvaluator evaluator, int year,
                                    Map<Integer, LocalDate> dateByDay, ImportSummary summary,
                                    String sheetName, String roadCategory, String vehicleType,
                                    int startColumn, int endColumn, Integer comparisonBaseColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) {
            summary.addError(sheetName + ":找不到工作表");
            return;
        }
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) {
            summary.addError(sheetName + ":找不到 " + year + " å¹´æ•°æ®åˆ—");
            return;
        }
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) {
                summary.addError(sheetName + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                continue;
            }
            Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
            if (rowDay == null || !rowDay.equals(day.getKey())) {
                summary.addError(sheetName + " ç¬¬" + (row.getRowNum() + 1) + "行:第几日与节假日配置不一致");
                continue;
            }
            BigDecimal value;
            try {
                value = numberValue(row.getCell(yearColumn), evaluator);
            } catch (Exception e) {
                summary.addError(sheetName + " ç¬¬" + (row.getRowNum() + 1) + "行:" + e.getMessage());
                continue;
            }
            if (value == null) {
                summary.incrementSkipped();
                continue;
            }
            BigDecimal comparisonBase = null;
            if (comparisonBaseColumn != null) {
                try {
                    comparisonBase = numberValue(row.getCell(comparisonBaseColumn), evaluator);
                } catch (Exception e) {
                    summary.addError(sheetName + " ç¬¬" + (row.getRowNum() + 1) + "行环比基期:" + e.getMessage());
                    continue;
                }
            }
            upsertVehicle(year, day.getValue(), roadCategory, vehicleType, value, comparisonBase);
            summary.incrementVehicle();
        }
    }
    private void fillPassengerSheets(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            for (int year : REFERENCE_YEARS) {
                Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
                if (yearColumn == null) continue;
                Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
                Map<LocalDate, BigDecimal> values = loadPassengerValues(year, entry.getValue());
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!values.containsKey(day.getValue())) continue;
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
                }
                LocalDate previousDate = previousDayDate(year, holidayType, dayDates);
                Row previousRow = findPreviousDayRow(sheet, evaluator);
                if (previousDate != null && previousRow != null) {
                    BigDecimal previousValue = values.get(previousDate);
                    if (previousValue != null) {
                        setNumeric(sheet, previousRow.getRowNum(), yearColumn, previousValue);
                    }
                }
            }
        }
    }
    private void fillVehicleSheets(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
        fillVehicleSheet(workbook, evaluator, "高速公路", HolidayConstants.ROAD_EXPRESSWAY, 2, 7, holidayType, null);
        fillVehicleSheet(workbook, evaluator, "普通国省道", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, 3, 8, holidayType,
                HolidayConstants.supportsComparisonBase(selectedYear) ? 2 : null);
    }
    private void fillVehicleSheet(XSSFWorkbook workbook, FormulaEvaluator evaluator, String sheetName,
                                  String roadCategory, int startColumn, int endColumn, String holidayType,
                                  Integer comparisonBaseColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        for (int year : REFERENCE_YEARS) {
            Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
            if (yearColumn == null) continue;
            Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
            Map<LocalDate, BigDecimal> values = loadVehicleValues(year, roadCategory);
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                if (!values.containsKey(day.getValue())) continue;
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
            }
            if (comparisonBaseColumn != null && HolidayConstants.supportsComparisonBase(year)) {
                // Column C (comparison base) is written as an operator backup value only. The ring
                // ratio in column E always compares the current day with the previous day row of
                // the same year, so the base never changes the calculated percentage.
                Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), comparisonBaseColumn, bases.get(day.getValue()));
                }
                Integer previousRowIndex = null;
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) {
                        previousRowIndex = null;
                        continue;
                    }
                    if (previousRowIndex != null) {
                        setFormula(sheet, row.getRowNum(), 4,
                                ringRatioFormula(row.getRowNum() + 1, previousRowIndex + 1));
                    }
                    previousRowIndex = row.getRowNum();
                }
            }
        }
    }
    // Ring ratio = (current day value - previous day value) / previous day value, always taken
    // from the active year column (D). This intentionally ignores the comparison base column.
    static String ringRatioFormula(int excelRow, int previousExcelRow) {
        return "=(D" + excelRow + "-D" + previousExcelRow + ")/D" + previousExcelRow;
    }
    private void fillCoefficients(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
        fillCoefficientSheet(workbook, evaluator, "高速公路", 8, 10, holidayType);
        fillCoefficientSheet(workbook, evaluator, "普通国省道", 9, 11, holidayType);
    }
    private void fillCoefficientSheet(XSSFWorkbook workbook, FormulaEvaluator evaluator, String sheetName,
                                      int startColumn, int endColumn, String holidayType) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        for (int year : REFERENCE_YEARS) {
            Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
            if (yearColumn == null) continue;
            BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
            if (coefficient == null) continue;
            Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
            for (Integer dayIndex : dayDates.keySet()) {
                setNumeric(sheet, FIRST_DATA_ROW_INDEX + dayIndex - 1, yearColumn, coefficient);
            }
        }
    }
    private void seedNationalDayCalendar() {
        // Calendar is admin-managed after the first bootstrap. Re-adding missing day indexes on
        // every start would resurrect days the admin deleted, so only seed a fresh table.
        if (!configService.listHolidays(null, null).isEmpty()) {
            return;
        }
        int[] calendarYears = {2019, 2024, 2025, 2026};
        int[] lengths = {8, 7, 8, 8};
        for (int i = 0; i < calendarYears.length; i++) {
            int year = calendarYears[i];
            List<HolidayCalendar> existing = configService.findHolidayDays(year, HolidayConstants.TYPE_NATIONAL_DAY);
            Map<Integer, HolidayCalendar> byDay = new LinkedHashMap<>();
            for (HolidayCalendar row : existing) {
                byDay.put(row.getDayIndex(), row);
            }
            for (int day = 1; day <= lengths[i]; day++) {
                if (byDay.containsKey(day)) continue;
                HolidayCalendar calendar = new HolidayCalendar();
                calendar.setYear(year);
                calendar.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
                calendar.setHolidayDate(LocalDate.of(year, 10, day));
                calendar.setDayIndex(day);
                calendar.setCreatedAt(LocalDateTime.now());
                calendar.setUpdatedAt(LocalDateTime.now());
                configService.saveHoliday(toMap(calendar));
            }
            if (year == 2019) {
                continue;
            }
            HolidayCalendar previousDay = new HolidayCalendar();
            previousDay.setYear(year);
            previousDay.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            previousDay.setHolidayDate(LocalDate.of(year, 10, 1).minusDays(1));
            previousDay.setDayIndex(0);
            previousDay.setCreatedAt(LocalDateTime.now());
            previousDay.setUpdatedAt(LocalDateTime.now());
            configService.saveHoliday(toMap(previousDay));
        }
    }
    private void ensurePreviousDayCalendarIfMissing() {
        for (int year : REFERENCE_YEARS) {
            if (configService.findPreviousDay(year, HolidayConstants.TYPE_NATIONAL_DAY) != null) {
                continue;
            }
            LocalDate firstHolidayDate = firstDate(dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY));
            if (firstHolidayDate == null) {
                continue;
            }
            HolidayCalendar previousDay = new HolidayCalendar();
            previousDay.setYear(year);
            previousDay.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            previousDay.setHolidayDate(firstHolidayDate.minusDays(1));
            previousDay.setDayIndex(0);
            previousDay.setCreatedAt(LocalDateTime.now());
            previousDay.setUpdatedAt(LocalDateTime.now());
            configService.saveHoliday(toMap(previousDay));
        }
    }
    private void seedPreviousDayPassengerIfMissing(Workbook workbook, FormulaEvaluator evaluator) {
        for (int year : REFERENCE_YEARS) {
            Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
            LocalDate previousDate = previousDayDate(year, HolidayConstants.TYPE_NATIONAL_DAY, dayDates);
            if (previousDate == null) continue;
            for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
                if (!HolidayConstants.MODE_RAIL.equals(entry.getValue())
                        && !HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
                    continue;
                }
                if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)
                        .eq(HolidayPassengerFlow::getFlowDate, previousDate)
                        .eq(HolidayPassengerFlow::getTransportMode, entry.getValue())) > 0) {
                    continue;
                }
                Sheet sheet = workbook.getSheet(entry.getKey());
                if (sheet == null) continue;
                Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
                if (yearColumn == null) continue;
                Row row = findPreviousDayRow(sheet, evaluator);
                if (row == null) continue;
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null) upsertPassenger(year, previousDate, entry.getValue(), value);
            }
        }
    }
    private void seedRoad2019IfMissing(Workbook workbook, FormulaEvaluator evaluator) {
        if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
                .eq(HolidayPassengerFlow::getYear, 2019)
                .eq(HolidayPassengerFlow::getTransportMode, HolidayConstants.MODE_ROAD)) > 0) {
            return;
        }
        Sheet sheet = workbook.getSheet("道路");
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, 2019, 2, 20);
        if (yearColumn == null) return;
        for (int rowIndex = 0; rowIndex <= Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT); rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null) continue;
            Integer dayIndex = parseDayIndex(row.getCell(0), evaluator);
            if (dayIndex == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) {
                upsertPassenger(2019, LocalDate.of(2019, 10, dayIndex), HolidayConstants.MODE_ROAD, value);
            }
        }
    }
    private void seedPassengerYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
        Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
        if (dayDates.isEmpty()) return;
        for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
            Sheet sheet = workbook.getSheet(entry.getKey());
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null) {
                    upsertPassenger(year, day.getValue(), entry.getValue(), value);
                }
            }
            if (HolidayConstants.MODE_RAIL.equals(entry.getValue())
                    || HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
                Row previousRow = findPreviousDayRow(sheet, evaluator);
                LocalDate previousDate = previousDayDate(year, HolidayConstants.TYPE_NATIONAL_DAY, dayDates);
                if (previousRow != null && previousDate != null) {
                    BigDecimal value = numberValue(previousRow.getCell(yearColumn), evaluator);
                    if (value != null) {
                        upsertPassenger(year, previousDate, entry.getValue(), value);
                    }
                }
            }
        }
    }
    private void seedVehicleYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
        Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
        if (dayDates.isEmpty()) return;
        seedVehicleSheet(workbook, evaluator, year, dayDates, "高速公路",
                HolidayConstants.ROAD_EXPRESSWAY, HolidayConstants.VEHICLE_SMALL_PASSENGER, 2, 7);
        seedVehicleSheet(workbook, evaluator, year, dayDates, "普通国省道",
                HolidayConstants.ROAD_NATIONAL_PROVINCIAL, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER, 3, 8);
    }
    private void seedVehicleSheet(Workbook workbook, FormulaEvaluator evaluator, int year,
                                  Map<Integer, LocalDate> dayDates, String sheetName,
                                  String roadCategory, String vehicleType, int startColumn, int endColumn) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) {
                upsertVehicle(year, day.getValue(), roadCategory, vehicleType, value, null);
            }
        }
    }
    private void seedCoefficientYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
        List<BigDecimal> values = new ArrayList<>();
        collectCoefficientValues(workbook, evaluator, "高速公路", year, 8, 10, values);
        collectCoefficientValues(workbook, evaluator, "普通国省道", year, 9, 11, values);
        if (values.isEmpty()) return;
        BigDecimal sum = BigDecimal.ZERO;
        for (BigDecimal value : values) {
            sum = HolidayNumberUtils.normalize(sum.add(value));
        }
        BigDecimal coefficient = HolidayNumberUtils.divide(sum, BigDecimal.valueOf(values.size()));
        upsertCoefficient(year, HolidayConstants.CATEGORY_HOLIDAY, coefficient);
        upsertCoefficient(year, HolidayConstants.CATEGORY_NON_HOLIDAY, coefficient);
    }
    private void collectCoefficientValues(Workbook workbook, FormulaEvaluator evaluator, String sheetName,
                                          int year, int startColumn, int endColumn, List<BigDecimal> values) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) return;
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (int rowIndex = FIRST_DATA_ROW_INDEX; rowIndex < FIRST_DATA_ROW_INDEX + 10; rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null) continue;
            try {
                BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
                if (value != null && value.compareTo(BigDecimal.ZERO) > 0) values.add(value);
            } catch (Exception ignored) {
                // Skip cells that are not numeric coefficients.
            }
        }
    }
    private LocalDate previousDayDate(int year, String holidayType, Map<Integer, LocalDate> dateByDay) {
        HolidayCalendar configured = configService.findPreviousDay(year, holidayType);
        if (configured != null && configured.getHolidayDate() != null) {
            return configured.getHolidayDate();
        }
        LocalDate firstDate = firstDate(dateByDay);
        return firstDate == null ? null : firstDate.minusDays(1);
    }
    private LocalDate firstDate(Map<Integer, LocalDate> dateByDay) {
        LocalDate result = null;
        for (LocalDate date : dateByDay.values()) {
            if (date != null && (result == null || date.isBefore(result))) result = date;
        }
        return result;
    }
    private Row findDayRow(Sheet sheet, FormulaEvaluator evaluator, int dayIndex) {
        int lastRow = Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT);
        for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null) continue;
            Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
            if (rowDay != null && rowDay.intValue() == dayIndex) return row;
        }
        return null;
    }
    private Row findPreviousDayRow(Sheet sheet, FormulaEvaluator evaluator) {
        int lastRow = Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT);
        DataFormatter formatter = new DataFormatter();
        for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
            Row row = sheet.getRow(rowIndex);
            if (row == null || row.getCell(0) == null) continue;
            String text = formatter.formatCellValue(row.getCell(0), evaluator).trim();
            if (text.contains("前一日")) return row;
        }
        return null;
    }
    private Map<Integer, LocalDate> dayDates(int year, String holidayType) {
        Map<Integer, LocalDate> result = new LinkedHashMap<>();
        for (HolidayCalendar row : configService.findHolidayDays(year, holidayType)) {
            result.put(row.getDayIndex(), row.getHolidayDate());
        }
        return result;
    }
    private Map<LocalDate, BigDecimal> loadPassengerValues(int year, String transportMode) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayPassengerFlow>()
                        .eq(HolidayPassengerFlow::getYear, year)
                        .eq(HolidayPassengerFlow::getTransportMode, transportMode));
        for (HolidayPassengerFlow row : rows) result.put(row.getFlowDate(), row.getPassengerCount());
        return result;
    }
    private Map<LocalDate, BigDecimal> loadVehicleValues(int year, String roadCategory) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, roadCategory));
        for (HolidayVehicleFlow row : rows) result.put(row.getFlowDate(), row.getVehicleCount());
        return result;
    }
    private Map<LocalDate, BigDecimal> loadComparisonBaseValues(int year) {
        Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
        List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
                new LambdaQueryWrapper<HolidayVehicleFlow>()
                        .eq(HolidayVehicleFlow::getYear, year)
                        .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
        for (HolidayVehicleFlow row : rows) {
            if (row.getComparisonBase() != null) result.put(row.getFlowDate(), row.getComparisonBase());
        }
        return result;
    }
    private void upsertPassenger(int year, LocalDate date, String mode, BigDecimal value) {
        HolidayPassengerFlow existing = passengerFlowMapper.selectOne(new LambdaQueryWrapper<HolidayPassengerFlow>()
                .eq(HolidayPassengerFlow::getYear, year)
                .eq(HolidayPassengerFlow::getFlowDate, date)
                .eq(HolidayPassengerFlow::getTransportMode, mode)
                .last("limit 1"));
        if (existing == null) {
            existing = new HolidayPassengerFlow();
            existing.setYear(year);
            existing.setFlowDate(date);
            existing.setTransportMode(mode);
            existing.setPassengerCount(value);
            existing.setCreatedAt(LocalDateTime.now());
            existing.setUpdatedAt(LocalDateTime.now());
            passengerFlowMapper.insert(existing);
        } else {
            existing.setPassengerCount(value);
            existing.setUpdatedAt(LocalDateTime.now());
            passengerFlowMapper.updateById(existing);
        }
    }
    private void upsertVehicle(int year, LocalDate date, String roadCategory, String vehicleType,
                               BigDecimal value, BigDecimal comparisonBase) {
        HolidayVehicleFlow existing = vehicleFlowMapper.selectOne(new LambdaQueryWrapper<HolidayVehicleFlow>()
                .eq(HolidayVehicleFlow::getYear, year)
                .eq(HolidayVehicleFlow::getFlowDate, date)
                .eq(HolidayVehicleFlow::getRoadCategory, roadCategory)
                .eq(HolidayVehicleFlow::getVehicleType, vehicleType)
                .last("limit 1"));
        if (existing == null) {
            existing = new HolidayVehicleFlow();
            existing.setYear(year);
            existing.setFlowDate(date);
            existing.setRoadCategory(roadCategory);
            existing.setVehicleType(vehicleType);
            existing.setVehicleCount(value);
            existing.setComparisonBase(comparisonBase);
            existing.setCreatedAt(LocalDateTime.now());
            existing.setUpdatedAt(LocalDateTime.now());
            vehicleFlowMapper.insert(existing);
        } else {
            existing.setVehicleCount(value);
            existing.setComparisonBase(comparisonBase);
            existing.setUpdatedAt(LocalDateTime.now());
            vehicleFlowMapper.updateById(existing);
        }
    }
    private void upsertCoefficient(int year, String dateCategory, BigDecimal value) {
        HolidayPassengerCoefficient existing = coefficientMapper.selectOne(
                new LambdaQueryWrapper<HolidayPassengerCoefficient>()
                        .eq(HolidayPassengerCoefficient::getYear, year)
                        .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory)
                        .last("limit 1"));
        if (existing == null) {
            existing = new HolidayPassengerCoefficient();
            existing.setYear(year);
            existing.setDateCategory(dateCategory);
            existing.setCoefficient(value);
            existing.setCreatedAt(LocalDateTime.now());
            existing.setUpdatedAt(LocalDateTime.now());
            coefficientMapper.insert(existing);
        } else {
            existing.setCoefficient(value);
            existing.setUpdatedAt(LocalDateTime.now());
            coefficientMapper.updateById(existing);
        }
    }
    private Integer findYearColumn(Sheet sheet, FormulaEvaluator evaluator, int year, int startColumn, int endColumn) {
        Row header = sheet.getRow(1);
        if (header == null) return null;
        String wanted = year + "å¹´";
        DataFormatter formatter = new DataFormatter();
        for (int column = startColumn; column <= endColumn; column++) {
            Cell cell = header.getCell(column);
            if (cell == null) continue;
            String text = formatter.formatCellValue(cell, evaluator).replace("\n", "").trim();
            if (wanted.equals(text) || text.startsWith(wanted + "累计")) return column;
        }
        return null;
    }
    private Integer parseDayIndex(Cell cell, FormulaEvaluator evaluator) {
        if (cell == null) return null;
        DataFormatter formatter = new DataFormatter();
        String text = formatter.formatCellValue(cell, evaluator).trim();
        java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("第\\s*(\\d+)\\s*日").matcher(text);
        return matcher.find() ? Integer.valueOf(matcher.group(1)) : null;
    }
    private BigDecimal numberValue(Cell cell, FormulaEvaluator evaluator) {
        if (cell == null) return null;
        CellType type = cell.getCellType();
        if (type == CellType.FORMULA) {
            type = evaluator.evaluateFormulaCell(cell);
        }
        if (type == CellType.BLANK) return null;
        if (type == CellType.NUMERIC) return HolidayNumberUtils.excelValue(cell.getNumericCellValue());
        if (type == CellType.STRING) {
            String text = cell.getStringCellValue().trim().replace(",", "");
            if (text.isEmpty() || "-".equals(text) || "—".equals(text)) return null;
            try {
                return HolidayNumberUtils.decimalValue(text);
            } catch (NumberFormatException e) {
                throw new IllegalArgumentException("单元格不是有效数字: " + text);
            }
        }
        if (type == CellType.BOOLEAN) throw new IllegalArgumentException("单元格不能是布尔值");
        throw new IllegalArgumentException("单元格不是有效数字");
    }
    private void setNumeric(Sheet sheet, int rowIndex, int columnIndex, BigDecimal value) {
        if (value == null) return;
        Row row = sheet.getRow(rowIndex);
        if (row == null) row = sheet.createRow(rowIndex);
        Cell cell = row.getCell(columnIndex);
        if (cell == null) cell = row.createCell(columnIndex);
        cell.setCellValue(value.doubleValue());
    }
    private void setFormula(Sheet sheet, int rowIndex, int columnIndex, String formula) {
        Row row = sheet.getRow(rowIndex);
        if (row == null) row = sheet.createRow(rowIndex);
        Cell cell = row.getCell(columnIndex);
        if (cell == null) cell = row.createCell(columnIndex);
        // POI's setCellFormula rejects a leading '='; the helper accepts either form.
        String normalized = formula.startsWith("=") ? formula.substring(1) : formula;
        cell.setCellFormula(normalized);
    }
    private Map<String, Object> toMap(HolidayCalendar calendar) {
        Map<String, Object> params = new LinkedHashMap<>();
        params.put("year", calendar.getYear());
        params.put("holidayType", calendar.getHolidayType());
        params.put("holidayDate", calendar.getHolidayDate().toString());
        params.put("dayIndex", calendar.getDayIndex());
        return params;
    }
    @Data
    public static class ImportSummary {
        private int passengerRows;
        private int vehicleRows;
        private int skippedRows;
        private boolean confirmationRequired;
        private final List<String> errors = new ArrayList<>();
        private final List<String> historicalChanges = new ArrayList<>();
        public void incrementPassenger() {
            passengerRows++;
        }
        public void incrementVehicle() {
            vehicleRows++;
        }
        public void incrementSkipped() {
            skippedRows++;
        }
        public void addError(String message) {
            errors.add(message);
        }
        public void addHistoricalChange(String message) {
            if (historicalChanges.size() < 200) {
                historicalChanges.add(message);
            }
        }
        public int getSuccessRows() {
            return passengerRows + vehicleRows;
        }
        public int getFailRows() {
            return errors.size();
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java
New file
@@ -0,0 +1,65 @@
package com.trafficaudit.observation.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import com.trafficaudit.observation.service.ObservationStationImportService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
 * Observation station raw data. Collected and stored only; not linked to holiday flow data.
 */
@Slf4j
@RestController
@RequestMapping("/api/observation")
public class ObservationStationController {
    @Resource
    private ObservationStationImportService importService;
    @PostMapping("/import")
    public Result<Map<String, Object>> importWorkbook(@RequestParam("file") MultipartFile file) {
        try {
            return Result.ok(importService.importWorkbook(file));
        } catch (Exception e) {
            log.error("observation station import error", e);
            return Result.error(400, e.getMessage());
        }
    }
    @GetMapping("/dates")
    public Result<List<ObservationDateSummary>> dates() {
        return Result.ok(importService.listDateSummaries());
    }
    @GetMapping("/rows")
    public Result<List<ObservationStationFlow>> rows(@RequestParam(value = "date", required = false) String date,
                                                      @RequestParam(value = "limit", defaultValue = "2000") int limit) {
        try {
            LocalDate target = (date == null || date.trim().isEmpty()) ? null : LocalDate.parse(date);
            return Result.ok(importService.listRows(target, limit));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
    @PostMapping("/delete")
    public Result<Integer> delete(@RequestParam("date") String date) {
        try {
            return Result.ok(importService.deleteDate(LocalDate.parse(date)));
        } catch (Exception e) {
            return Result.error(400, e.getMessage());
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java
New file
@@ -0,0 +1,15 @@
package com.trafficaudit.observation.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/** Daily observation-station operation averages used by the holiday trend report. */
@Data
public class ObservationDailyOperation {
    private LocalDate obsDate;
    private BigDecimal averageSpeed;
    private BigDecimal averageCongestion;
    private Integer stationCount;
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java
New file
@@ -0,0 +1,18 @@
package com.trafficaudit.observation.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Per-date rollup shown on the import page. */
@Data
public class ObservationDateSummary {
    private LocalDate obsDate;
    private Integer stationCount;
    private BigDecimal smallMediumPassengerTotal;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime importedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java
New file
@@ -0,0 +1,51 @@
package com.trafficaudit.observation.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * Observation station daily survey data.
 * Stored raw for later analysis; it is deliberately NOT linked to the holiday flow tables,
 * even though ä¸­å°å®¢æµé‡ is one of the sources of the ordinary national/provincial road flow.
 */
@Data
@TableName("observation_station_flow")
public class ObservationStationFlow {
    @TableId(type = IdType.AUTO)
    private Long id;
    private LocalDate obsDate;
    private String stationCode;
    private String stationName;
    private BigDecimal mileage;
    private String cityName;
    private String countyName;
    private String direction;
    private BigDecimal smallMediumPassenger;
    private BigDecimal largePassenger;
    private BigDecimal smallTruck;
    private BigDecimal mediumTruck;
    private BigDecimal largeTruck;
    private BigDecimal extraLargeTruck;
    private BigDecimal containerTruck;
    private BigDecimal motorcycle;
    private BigDecimal tractor;
    private BigDecimal passengerTotal;
    private BigDecimal truckTotal;
    private BigDecimal carTotal;
    private BigDecimal motorTotal;
    private BigDecimal passengerEquivalent;
    private BigDecimal truckEquivalent;
    private BigDecimal carEquivalent;
    private BigDecimal motorEquivalent;
    private BigDecimal vehicleSpeed;
    private BigDecimal congestionDegree;
    private String sourceFile;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
New file
@@ -0,0 +1,31 @@
package com.trafficaudit.observation.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.dto.ObservationDailyOperation;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.time.LocalDate;
@Mapper
public interface ObservationStationFlowMapper extends BaseMapper<ObservationStationFlow> {
    @Select("SELECT obs_date, COUNT(*) AS station_count, "
            + "SUM(small_medium_passenger) AS small_medium_passenger_total, "
            + "MAX(updated_at) AS imported_at "
            + "FROM observation_station_flow GROUP BY obs_date ORDER BY obs_date ASC")
    List<ObservationDateSummary> selectDateSummary();
    @Select("SELECT obs_date, COUNT(*) AS station_count, "
            + "AVG(vehicle_speed) AS average_speed, "
            + "AVG(congestion_degree) AS average_congestion "
            + "FROM observation_station_flow "
            + "WHERE obs_date BETWEEN #{startDate} AND #{endDate} "
            + "GROUP BY obs_date ORDER BY obs_date ASC")
    List<ObservationDailyOperation> selectDailyOperations(@Param("startDate") LocalDate startDate,
                                                          @Param("endDate") LocalDate endDate);
}
traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java
New file
@@ -0,0 +1,350 @@
package com.trafficaudit.observation.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import com.trafficaudit.observation.mapper.ObservationStationFlowMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * Imports the "observation station daily survey" workbook (.xls/.xlsx).
 * Header names are matched by text, so column order may change between releases.
 * Re-importing the same date replaces that date's rows.
 */
@Slf4j
@Service
public class ObservationStationImportService {
    private static final String H_DATE = "观测日期";
    private static final String H_CODE = "观测站编号";
    private static final String H_NAME = "观测站名称";
    private static final String H_MILEAGE = "观测里程";
    private static final String H_CITY = "地市名称";
    private static final String H_COUNTY = "区县名称";
    private static final String H_DIRECTION = "行驶方向";
    private static final String H_SMALL_MEDIUM_PASSENGER = "中小客流量";
    private static final String H_LARGE_PASSENGER = "大客车流量";
    private static final String H_SMALL_TRUCK = "小货车流量";
    private static final String H_MEDIUM_TRUCK = "中货车流量";
    private static final String H_LARGE_TRUCK = "大货车流量";
    private static final String H_EXTRA_LARGE_TRUCK = "特大货流量";
    private static final String H_CONTAINER = "集装箱流量";
    private static final String H_MOTORCYCLE = "摩托车流量";
    private static final String H_TRACTOR = "拖拉机流量";
    private static final String H_PASSENGER_TOTAL = "客车流量";
    private static final String H_TRUCK_TOTAL = "货车流量";
    private static final String H_CAR_TOTAL = "汽车流量";
    private static final String H_MOTOR_TOTAL = "机动车流量";
    private static final String H_PASSENGER_EQUIVALENT = "客车当量";
    private static final String H_TRUCK_EQUIVALENT = "货车当量";
    private static final String H_CAR_EQUIVALENT = "汽车当量";
    private static final String H_MOTOR_EQUIVALENT = "机动车当量";
    private static final String H_SPEED = "机动车速度";
    private static final String H_CONGESTION = "拥挤度";
    private static final int HEADER_SCAN_ROWS = 10;
    private static final int MAX_REPORTED_ERRORS = 50;
    private static final DateTimeFormatter[] DATE_FORMATS = {
            DateTimeFormatter.ofPattern("yyyy-M-d"),
            DateTimeFormatter.ofPattern("yyyy/M/d"),
            DateTimeFormatter.ofPattern("yyyy.M.d"),
            DateTimeFormatter.ofPattern("yyyyMMdd")
    };
    @Resource
    private ObservationStationFlowMapper stationFlowMapper;
    @Transactional
    public Map<String, Object> importWorkbook(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            throw new IllegalArgumentException("请选择要导入的 Excel æ–‡ä»¶");
        }
        String sourceFile = file.getOriginalFilename();
        List<ObservationStationFlow> rows = new ArrayList<>();
        Set<LocalDate> dates = new LinkedHashSet<>();
        Set<String> stationCodes = new LinkedHashSet<>();
        List<String> errors = new ArrayList<>();
        int skipped = 0;
        DataFormatter formatter = new DataFormatter();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            Sheet sheet = workbook.getNumberOfSheets() > 0 ? workbook.getSheetAt(0) : null;
            if (sheet == null) {
                throw new IllegalArgumentException("工作簿中没有可读取的工作表");
            }
            int headerRowIndex = findHeaderRow(sheet, formatter);
            if (headerRowIndex < 0) {
                throw new IllegalArgumentException("未找到表头,请确认文件包含\"观测日期\"和\"观测站编号\"两列");
            }
            Map<String, Integer> headerIndex = buildHeaderIndex(sheet.getRow(headerRowIndex), formatter);
            for (int r = headerRowIndex + 1; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) {
                    skipped++;
                    continue;
                }
                String code = stringCell(row, headerIndex, H_CODE, formatter);
                LocalDate obsDate = dateCell(row, headerIndex, H_DATE, formatter);
                if (isBlank(code) && obsDate == null) {
                    skipped++;
                    continue;
                }
                int rowNo = r + 1;
                if (obsDate == null) {
                    addError(errors, "第" + rowNo + "行:观测日期无法识别");
                    continue;
                }
                if (isBlank(code)) {
                    addError(errors, "第" + rowNo + "行:观测站编号为空");
                    continue;
                }
                ObservationStationFlow entity = new ObservationStationFlow();
                entity.setObsDate(obsDate);
                entity.setStationCode(code);
                entity.setStationName(stringCell(row, headerIndex, H_NAME, formatter));
                entity.setMileage(decimalCell(row, headerIndex, H_MILEAGE, formatter));
                entity.setCityName(stringCell(row, headerIndex, H_CITY, formatter));
                entity.setCountyName(stringCell(row, headerIndex, H_COUNTY, formatter));
                String direction = stringCell(row, headerIndex, H_DIRECTION, formatter);
                entity.setDirection(direction == null ? "" : direction);
                entity.setSmallMediumPassenger(decimalCell(row, headerIndex, H_SMALL_MEDIUM_PASSENGER, formatter));
                entity.setLargePassenger(decimalCell(row, headerIndex, H_LARGE_PASSENGER, formatter));
                entity.setSmallTruck(decimalCell(row, headerIndex, H_SMALL_TRUCK, formatter));
                entity.setMediumTruck(decimalCell(row, headerIndex, H_MEDIUM_TRUCK, formatter));
                entity.setLargeTruck(decimalCell(row, headerIndex, H_LARGE_TRUCK, formatter));
                entity.setExtraLargeTruck(decimalCell(row, headerIndex, H_EXTRA_LARGE_TRUCK, formatter));
                entity.setContainerTruck(decimalCell(row, headerIndex, H_CONTAINER, formatter));
                entity.setMotorcycle(decimalCell(row, headerIndex, H_MOTORCYCLE, formatter));
                entity.setTractor(decimalCell(row, headerIndex, H_TRACTOR, formatter));
                entity.setPassengerTotal(decimalCell(row, headerIndex, H_PASSENGER_TOTAL, formatter));
                entity.setTruckTotal(decimalCell(row, headerIndex, H_TRUCK_TOTAL, formatter));
                entity.setCarTotal(decimalCell(row, headerIndex, H_CAR_TOTAL, formatter));
                entity.setMotorTotal(decimalCell(row, headerIndex, H_MOTOR_TOTAL, formatter));
                entity.setPassengerEquivalent(decimalCell(row, headerIndex, H_PASSENGER_EQUIVALENT, formatter));
                entity.setTruckEquivalent(decimalCell(row, headerIndex, H_TRUCK_EQUIVALENT, formatter));
                entity.setCarEquivalent(decimalCell(row, headerIndex, H_CAR_EQUIVALENT, formatter));
                entity.setMotorEquivalent(decimalCell(row, headerIndex, H_MOTOR_EQUIVALENT, formatter));
                entity.setVehicleSpeed(decimalCell(row, headerIndex, H_SPEED, formatter));
                entity.setCongestionDegree(decimalCell(row, headerIndex, H_CONGESTION, formatter));
                entity.setSourceFile(sourceFile);
                rows.add(entity);
                dates.add(obsDate);
                stationCodes.add(code);
            }
        } catch (IllegalArgumentException e) {
            throw e;
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
        }
        if (!rows.isEmpty()) {
            stationFlowMapper.delete(new LambdaQueryWrapper<ObservationStationFlow>()
                    .in(ObservationStationFlow::getObsDate, dates));
            for (ObservationStationFlow row : rows) {
                stationFlowMapper.insert(row);
            }
        }
        List<String> dateTexts = new ArrayList<>();
        for (LocalDate date : dates) {
            dateTexts.add(date.toString());
        }
        BigDecimal smallMediumTotal = BigDecimal.ZERO;
        for (ObservationStationFlow row : rows) {
            if (row.getSmallMediumPassenger() != null) {
                smallMediumTotal = smallMediumTotal.add(row.getSmallMediumPassenger());
            }
        }
        Map<String, Object> summary = new LinkedHashMap<>();
        summary.put("totalRows", rows.size());
        summary.put("stationCount", stationCodes.size());
        summary.put("dates", dateTexts);
        summary.put("skippedRows", skipped);
        summary.put("smallMediumPassengerTotal", smallMediumTotal);
        summary.put("errors", errors);
        log.info("Observation station import done: file={} rows={} dates={} skipped={} failed={}",
                sourceFile, rows.size(), dateTexts, skipped, errors.size());
        return summary;
    }
    public List<ObservationDateSummary> listDateSummaries() {
        return stationFlowMapper.selectDateSummary();
    }
    public List<ObservationStationFlow> listRows(LocalDate date, int limit) {
        int effectiveLimit = limit <= 0 ? 2000 : Math.min(limit, 5000);
        return stationFlowMapper.selectList(new LambdaQueryWrapper<ObservationStationFlow>()
                .eq(date != null, ObservationStationFlow::getObsDate, date)
                .orderByAsc(ObservationStationFlow::getId)
                .last("LIMIT " + effectiveLimit));
    }
    public int deleteDate(LocalDate date) {
        if (date == null) {
            throw new IllegalArgumentException("请选择要删除的观测日期");
        }
        return stationFlowMapper.delete(new LambdaQueryWrapper<ObservationStationFlow>()
                .eq(ObservationStationFlow::getObsDate, date));
    }
    private void addError(List<String> errors, String message) {
        if (errors.size() < MAX_REPORTED_ERRORS) {
            errors.add(message);
        }
    }
    private int findHeaderRow(Sheet sheet, DataFormatter formatter) {
        int lastRow = Math.min(sheet.getLastRowNum(), HEADER_SCAN_ROWS);
        for (int r = 0; r <= lastRow; r++) {
            Row row = sheet.getRow(r);
            if (row == null) {
                continue;
            }
            Map<String, Integer> index = buildHeaderIndex(row, formatter);
            if (index.containsKey(normalize(H_DATE)) && index.containsKey(normalize(H_CODE))) {
                return r;
            }
        }
        return -1;
    }
    private Map<String, Integer> buildHeaderIndex(Row row, DataFormatter formatter) {
        Map<String, Integer> index = new LinkedHashMap<>();
        if (row == null) {
            return index;
        }
        for (int c = row.getFirstCellNum(); c < row.getLastCellNum(); c++) {
            Cell cell = row.getCell(c);
            String header = normalize(formatter.formatCellValue(cell));
            if (!header.isEmpty() && !index.containsKey(header)) {
                index.put(header, c);
            }
        }
        return index;
    }
    private String normalize(String value) {
        if (value == null) {
            return "";
        }
        StringBuilder builder = new StringBuilder(value.length());
        for (int i = 0; i < value.length(); i++) {
            char ch = value.charAt(i);
            if (ch == 0xFEFF || Character.isWhitespace(ch)) {
                continue;
            }
            builder.append(ch);
        }
        return builder.toString();
    }
    private String stringCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
        Integer column = headerIndex.get(normalize(header));
        if (column == null) {
            return null;
        }
        Cell cell = row.getCell(column);
        if (cell == null) {
            return null;
        }
        String value = formatter.formatCellValue(cell).trim();
        return value.isEmpty() ? null : value;
    }
    private LocalDate dateCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
        Integer column = headerIndex.get(normalize(header));
        if (column == null) {
            return null;
        }
        return readDate(row.getCell(column), formatter);
    }
    private BigDecimal decimalCell(Row row, Map<String, Integer> headerIndex, String header, DataFormatter formatter) {
        Integer column = headerIndex.get(normalize(header));
        if (column == null) {
            return null;
        }
        return readDecimal(row.getCell(column), formatter);
    }
    private LocalDate readDate(Cell cell, DataFormatter formatter) {
        if (cell == null) {
            return null;
        }
        CellType type = cell.getCellType();
        if (type == CellType.FORMULA) {
            type = cell.getCachedFormulaResultType();
        }
        if (type == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
            return cell.getLocalDateTimeCellValue().toLocalDate();
        }
        String text = formatter.formatCellValue(cell).trim();
        if (text.isEmpty()) {
            return null;
        }
        text = text.replace("å¹´", "-").replace("月", "-").replace("日", "").trim();
        for (DateTimeFormatter format : DATE_FORMATS) {
            try {
                return LocalDate.parse(text, format);
            } catch (Exception ignored) {
                // Try the next supported pattern.
            }
        }
        return null;
    }
    private BigDecimal readDecimal(Cell cell, DataFormatter formatter) {
        if (cell == null) {
            return null;
        }
        CellType type = cell.getCellType();
        if (type == CellType.FORMULA) {
            type = cell.getCachedFormulaResultType();
        }
        if (type == CellType.NUMERIC) {
            return BigDecimal.valueOf(cell.getNumericCellValue());
        }
        if (type == CellType.BLANK) {
            return null;
        }
        String text = formatter.formatCellValue(cell).trim().replace(",", "").replace(" ", "");
        if (text.isEmpty() || "-".equals(text) || "—".equals(text) || "/".equals(text)) {
            return null;
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException e) {
            return null;
        }
    }
    private boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java
@@ -35,7 +35,7 @@
            .authorizeRequests()
            // ç™»å½•前必须可用的接口
            .antMatchers("/api/auth/login", "/api/auth/captcha", "/api/auth/captcha/verify",
                         "/api/auth/captcha-config").permitAll()
                         "/api/auth/captcha-config", "/api/version").permitAll()
            // å‰ç«¯é™æ€èµ„源
            .antMatchers("/", "/index.html", "/favicon.ico", "/js/**", "/css/**", "/fonts/**", "/img/**", "/static/**").permitAll()
            .anyRequest().permitAll()
@@ -44,4 +44,4 @@
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
}
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java
@@ -27,7 +27,8 @@
            "/api/auth/login",
            "/api/auth/captcha",
            "/api/auth/captcha/verify",
            "/api/auth/captcha-config"
            "/api/auth/captcha-config",
            "/api/version"
    );
    @Resource
@@ -61,4 +62,4 @@
        request.setAttribute("loginUser", username);
        chain.doFilter(request, response);
    }
}
}
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java
New file
@@ -0,0 +1,83 @@
package com.trafficaudit.system.controller;
import com.trafficaudit.common.Result;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
/**
 * Exposes the product version so the UI (and operators) can confirm which build
 * is actually running on a server. Kept public on purpose: the login page shows
 * the version before any token exists.
 */
@RestController
@RequestMapping("/api")
public class VersionController {
    private static final String VERSION_FILE = "app-version.properties";
    @GetMapping("/version")
    public Result<Map<String, Object>> version() {
        Map<String, Object> info = new LinkedHashMap<>();
        info.put("version", readVersion());
        info.put("buildTime", resolveBuildTime());
        return Result.ok(info);
    }
    private String readVersion() {
        try (InputStream in = new ClassPathResource(VERSION_FILE).getInputStream()) {
            Properties props = new Properties();
            props.load(in);
            String value = props.getProperty("version");
            return value == null || value.trim().isEmpty() ? "unknown" : value.trim();
        } catch (Exception e) {
            return "unknown";
        }
    }
    /**
     * Build time of the running artifact. In a packaged run this is the jar file's
     * last-modified time; when running from exploded classes (mvn spring-boot:run)
     * there is no jar, so the value is null.
     */
    private String resolveBuildTime() {
        try {
            URI location = VersionController.class.getProtectionDomain()
                    .getCodeSource().getLocation().toURI();
            Path path = resolveArtifactPath(location);
            if (path != null && Files.isRegularFile(path)) {
                return Files.getLastModifiedTime(path).toInstant().toString();
            }
        } catch (Exception ignored) {
            // Fall through: build time is optional information.
        }
        return null;
    }
    /**
     * Spring Boot executable jars expose a nested classpath such as
     * "jar:file:/app.jar!/BOOT-INF/classes!/". Strip the nested part before
     * asking the filesystem for the jar timestamp.
     */
    private Path resolveArtifactPath(URI location) {
        String value = location.toString();
        int nested = value.indexOf("!/");
        if (nested >= 0) {
            value = value.substring(0, nested);
        }
        if (value.startsWith("jar:")) {
            value = value.substring(4);
        }
        return Paths.get(URI.create(value));
    }
}
traffic-audit-server/src/main/resources/app-version.properties
New file
@@ -0,0 +1,3 @@
# Supplied from pom.xml during Maven resource filtering.
# Bump <version> in pom.xml on every release; do not hard-code a version here.
version=@project.version@
traffic-audit-server/src/main/resources/application.yml.example
@@ -15,8 +15,8 @@
spring:
  datasource:
    url: jdbc:mysql://localhost:3305/traffic_audit?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
    username: root
    password: ${MYSQL_PASSWORD:root}
    username: ${TRAFFIC_DB_USER:root}
    password: ${TRAFFIC_DB_PASSWORD:root}
    driver-class-name: com.mysql.cj.jdbc.Driver
  servlet:
    multipart:
@@ -41,7 +41,8 @@
      logic-not-delete-value: 0
jwt:
  secret: ${JWT_SECRET:change-me-jwt-secret}
  # å¿…须是「Base64 åˆæ³•字符 + é•¿åº¦æ˜¯ 4 çš„倍数」;下面这串 32 ä½åå…­è¿›åˆ¶å¯ç›´æŽ¥ç”¨
  secret: ${TRAFFIC_JWT_SECRET:8f2c1d64a7b34e09c5d81f6a2b0e7c93}
  expiration: 86400000
docs:
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java
New file
@@ -0,0 +1,141 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class HolidayBulletinServiceTest {
    @Test
    void formatsTableRatiosAsSignedPercentages() throws Exception {
        HolidayBulletinService service = new HolidayBulletinService(null);
        assertEquals("-4.7%", ratio(service, "95.3", "100", 1));
        assertEquals("-4.4%", ratio(service, "95.551398", "100", 1));
        assertEquals("-4.5%", ratio(service, "95.551398", "100", 2));
        assertEquals("-0.1%", ratio(service, "99.955", "100", 2));
        assertEquals("0%", ratio(service, "99.955", "100", 1));
        assertEquals("6.7%", ratio(service, "106.649", "100", 2));
        assertEquals("6.6%", ratio(service, "106.649", "100", 1));
        assertEquals("1.0%", ratio(service, "101", "100", 1));
        assertEquals("0%", ratio(service, "100", "100", 1));
        assertEquals("0%", ratio(service, "10004", "10000", 1));
        assertEquals("—", ratio(service, "100", null, 1));
        assertEquals("—", ratio(service, "100", "0", 1));
    }
    @Test
    void comparesCivilAviationOnlyThroughCoveredDay() throws Exception {
        HolidayStatService stat = mock(HolidayStatService.class);
        HolidayBulletinService service = new HolidayBulletinService(stat);
        DailyMetrics current = new DailyMetrics();
        current.setYear(2026);
        current.setHolidayType("NATIONAL_DAY");
        current.setDayIndex(2);
        DailyMetrics coveredDay = new DailyMetrics();
        coveredDay.setCivilAviation(new BigDecimal("6.7482"));
        DailyMetrics missingDay = new DailyMetrics();
        when(stat.dailyMetrics(2026, "NATIONAL_DAY", 2)).thenReturn(missingDay);
        when(stat.dailyMetrics(2026, "NATIONAL_DAY", 1)).thenReturn(coveredDay);
        DailyMetrics previousYear = new DailyMetrics();
        previousYear.setYear(2025);
        DailyMetrics previousYearThroughDay1 = new DailyMetrics();
        previousYearThroughDay1.setCivilAviation(new BigDecimal("7.5003"));
        when(stat.cumulativeMetrics(2025, "NATIONAL_DAY", 1)).thenReturn(previousYearThroughDay1);
        Object result = comparableCumulative(service, current, previousYear, DailyMetrics::getCivilAviation);
        assertSame(previousYearThroughDay1, result);
        verify(stat).cumulativeMetrics(2025, "NATIONAL_DAY", 1);
        verify(stat, never()).cumulativeMetrics(2025, "NATIONAL_DAY", 2);
    }
    @Test
    void comparableCumulativeIsNullWhenCurrentYearHasNoMetricData() throws Exception {
        HolidayStatService stat = mock(HolidayStatService.class);
        HolidayBulletinService service = new HolidayBulletinService(stat);
        DailyMetrics current = new DailyMetrics();
        current.setYear(2026);
        current.setHolidayType("NATIONAL_DAY");
        current.setDayIndex(2);
        when(stat.dailyMetrics(2026, "NATIONAL_DAY", 2)).thenReturn(new DailyMetrics());
        when(stat.dailyMetrics(2026, "NATIONAL_DAY", 1)).thenReturn(new DailyMetrics());
        Object result = comparableCumulative(service, current, new DailyMetrics(), DailyMetrics::getCivilAviation);
        assertNull(result);
        verify(stat, never()).cumulativeMetrics(0, "NATIONAL_DAY", 1);
    }
    @Test
    void cumulativeParagraphUsesExcelDisplayThenBulletinRounding() throws Exception {
        HolidayBulletinService service = new HolidayBulletinService(null);
        DailyMetrics current = new DailyMetrics();
        current.setTotalPersonFlow(new BigDecimal("3088.62958"));
        DailyMetrics previousYear = new DailyMetrics();
        previousYear.setTotalPersonFlow(new BigDecimal("3232.42742"));
        DailyMetrics twoYearsAgo = new DailyMetrics();
        twoYearsAgo.setTotalPersonFlow(new BigDecimal("3137.80860"));
        Method method = HolidayBulletinService.class.getDeclaredMethod(
                "cumulativeParagraph", DailyMetrics.class, DailyMetrics.class, DailyMetrics.class);
        method.setAccessible(true);
        String text = (String) method.invoke(service, current, previousYear, twoYearsAgo);
        assertEquals(
                "假期以来,全省全社会跨区域人员流动量累计3088.63万人次,比2025年同期下降4.5%,比2024年同期下降1.6%。",
                text);
    }
    @Test
    void cumulativeAmountsUseExcelYiDisplayBeforeWanRounding() throws Exception {
        HolidayBulletinService service = new HolidayBulletinService(null);
        assertEquals("3961.77", cumulativeNumber(service, "3961.76472"));
        assertEquals("4189.32", cumulativeNumber(service, "4189.31472"));
        assertEquals("4486.63", cumulativeNumber(service, "4486.62652"));
    }
    private String cumulativeNumber(HolidayBulletinService service, String value) throws Exception {
        Method method = HolidayBulletinService.class.getDeclaredMethod("numberCumulative", BigDecimal.class);
        method.setAccessible(true);
        BigDecimal number = value == null ? null : new BigDecimal(value);
        return (String) method.invoke(service, number);
    }
    private Object comparableCumulative(HolidayBulletinService service, DailyMetrics current,
                                        DailyMetrics comparisonYear,
                                        Function<DailyMetrics, BigDecimal> selector) throws Exception {
        Method method = HolidayBulletinService.class.getDeclaredMethod(
                "comparableCumulative", DailyMetrics.class, DailyMetrics.class, Function.class);
        method.setAccessible(true);
        return method.invoke(service, current, comparisonYear, selector);
    }
    private String ratio(HolidayBulletinService service, String current, String base,
                         int excelPercentScale) throws Exception {
        Method method = HolidayBulletinService.class.getDeclaredMethod(
                "ratio", BigDecimal.class, BigDecimal.class, int.class);
        method.setAccessible(true);
        BigDecimal currentValue = current == null ? null : new BigDecimal(current);
        BigDecimal baseValue = base == null ? null : new BigDecimal(base);
        return (String) method.invoke(service, currentValue, baseValue, excelPercentScale);
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java
New file
@@ -0,0 +1,181 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.HolidayNumberUtils;
import com.trafficaudit.holiday.entity.HolidayCalendar;
import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
 * Ring-ratio denominators are built one day at a time. The Excel comparison base (column C) is
 * consumed on the source day and inherited by the next day's denominator, while historical
 * adjustment records change only that historical day's same-day value.
 */
class HolidayComparisonBaseTest {
    @Test
    void comparisonBaseChangesOnlyTheNextDayRingRatioDenominator() throws Exception {
        HolidayStatService service = serviceWithData(
                2026,
                passengerRows(2026, "50", "10", "6", "3"),
                vehicleRows(2026),
                Collections.<HolidayVehicleAdjustment>emptyList());
        List<Map<String, Object>> rows = service.buildOverviewRows(
                2026, HolidayConstants.TYPE_NATIONAL_DAY, calendar(2026));
        assertEquals(new BigDecimal("323.7837"), rows.get(2).get("nationalProvincial"));
        assertEquals(new BigDecimal("320.2418"), rows.get(2).get("comparisonBase"));
        assertEquals(new BigDecimal("322.7695"), rows.get(3).get("nationalProvincial"));
        BigDecimal nationalRatio = (BigDecimal) rows.get(3).get("nationalProvincialRingRatio");
        assertEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("320.2418")), nationalRatio);
        assertNotEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("323.7837")), nationalRatio);
        HolidayStatService.DailyMetrics base = service.ringRatioBaseMetrics(
                2026, HolidayConstants.TYPE_NATIONAL_DAY, 4);
        assertNotNull(base);
        assertEquals(new BigDecimal("320.2418"), base.getNationalProvincial());
        assertDecimalEquals("1008.58032", base.getNonBusinessPersonFlow());
        assertDecimalEquals("1058.58032", base.getRoadPersonFlow());
        assertDecimalEquals("1077.58032", base.getTotalPersonFlow());
    }
    @Test
    void historicalAdjustmentChangesTheHistoricalSameDayValue() throws Exception {
        HolidayVehicleAdjustment adjustment = new HolidayVehicleAdjustment();
        adjustment.setYear(2025);
        adjustment.setFlowDate(LocalDate.of(2025, 10, 4));
        adjustment.setAdjustedValue(new BigDecimal("337.5009"));
        HolidayStatService service = serviceWithData(
                2025,
                passengerRows(2025, "50", "10", "6", "3"),
                Arrays.asList(
                        vehicle("2025-10-03", HolidayConstants.ROAD_EXPRESSWAY, "100", null),
                        vehicle("2025-10-03", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "362.0035", null),
                        vehicle("2025-10-04", HolidayConstants.ROAD_EXPRESSWAY, "110", null),
                        vehicle("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "350.0000", null)),
                Collections.singletonList(adjustment));
        List<Map<String, Object>> rows = service.buildOverviewRows(
                2025, HolidayConstants.TYPE_NATIONAL_DAY, calendar(2025));
        assertEquals(new BigDecimal("337.5009"), rows.get(3).get("nationalProvincial"));
    }
    private static HolidayStatService serviceWithData(int year,
                                                      List<HolidayPassengerFlow> passengerRows,
                                                      List<HolidayVehicleFlow> vehicleRows,
                                                      List<HolidayVehicleAdjustment> adjustments) throws Exception {
        HolidayConfigService configService = mock(HolidayConfigService.class);
        HolidayPassengerFlowMapper passengerMapper = mock(HolidayPassengerFlowMapper.class);
        HolidayVehicleFlowMapper vehicleMapper = mock(HolidayVehicleFlowMapper.class);
        HolidayVehicleAdjustmentMapper adjustmentMapper = mock(HolidayVehicleAdjustmentMapper.class);
        when(configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY)).thenReturn(new BigDecimal("2.4"));
        when(configService.findHolidayDays(year, HolidayConstants.TYPE_NATIONAL_DAY)).thenReturn(calendar(year));
        when(passengerMapper.selectList(any())).thenReturn(passengerRows);
        when(vehicleMapper.selectList(any())).thenReturn(vehicleRows);
        when(adjustmentMapper.selectList(any())).thenReturn(adjustments);
        HolidayStatService service = new HolidayStatService();
        inject(service, "configService", configService);
        inject(service, "passengerFlowMapper", passengerMapper);
        inject(service, "vehicleFlowMapper", vehicleMapper);
        inject(service, "vehicleAdjustmentMapper", adjustmentMapper);
        return service;
    }
    private static BigDecimal ratio(BigDecimal current, BigDecimal base) {
        return HolidayNumberUtils.normalize(HolidayNumberUtils.divide(current, base).subtract(BigDecimal.ONE));
    }
    private static void assertDecimalEquals(String expected, BigDecimal actual) {
        assertNotNull(actual);
        assertEquals(0, new BigDecimal(expected).compareTo(actual));
    }
    private static List<HolidayVehicleFlow> vehicleRows(int year) {
        List<HolidayVehicleFlow> rows = new ArrayList<>();
        rows.add(vehicle(year + "-10-03", HolidayConstants.ROAD_EXPRESSWAY, "100", null));
        rows.add(vehicle(year + "-10-03", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "323.7837", "320.2418"));
        rows.add(vehicle(year + "-10-04", HolidayConstants.ROAD_EXPRESSWAY, "110", null));
        rows.add(vehicle(year + "-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "322.7695", null));
        return rows;
    }
    private static HolidayVehicleFlow vehicle(String date, String roadCategory, String value, String base) {
        HolidayVehicleFlow row = new HolidayVehicleFlow();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setRoadCategory(roadCategory);
        row.setVehicleType(HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER);
        row.setVehicleCount(new BigDecimal(value));
        row.setComparisonBase(base == null ? null : new BigDecimal(base));
        return row;
    }
    private static List<HolidayPassengerFlow> passengerRows(int year, String road, String rail,
                                                            String civilAviation, String water) {
        List<HolidayPassengerFlow> rows = new ArrayList<>();
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_ROAD, road));
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_RAIL, rail));
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_CIVIL_AVIATION, civilAviation));
        rows.add(passenger(year + "-10-03", HolidayConstants.MODE_WATER, water));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_ROAD, road));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_RAIL, rail));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_CIVIL_AVIATION, civilAviation));
        rows.add(passenger(year + "-10-04", HolidayConstants.MODE_WATER, water));
        return rows;
    }
    private static HolidayPassengerFlow passenger(String date, String mode, String value) {
        HolidayPassengerFlow row = new HolidayPassengerFlow();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setTransportMode(mode);
        row.setPassengerCount(new BigDecimal(value));
        return row;
    }
    private static List<HolidayCalendar> calendar(int year) {
        List<HolidayCalendar> days = new ArrayList<>();
        for (int i = 1; i <= 4; i++) {
            HolidayCalendar day = new HolidayCalendar();
            day.setYear(year);
            day.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
            day.setDayIndex(i);
            day.setHolidayDate(LocalDate.of(year, 10, i));
            days.add(day);
        }
        return days;
    }
    private static void inject(Object target, String field, Object value) throws Exception {
        Field declared = target.getClass().getDeclaredField(field);
        declared.setAccessible(true);
        declared.set(target, value);
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
New file
@@ -0,0 +1,136 @@
package com.trafficaudit.holiday.service;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HolidayTrendChartServiceTest {
    @Test
    void rendersLinePieAndStackedChartsAsPng() throws Exception {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries();
        first.setName("2026å¹´");
        first.setValues(java.util.Arrays.asList(
                java.math.BigDecimal.ONE, java.math.BigDecimal.TEN, null));
        HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries();
        second.setName("2025å¹´");
        second.setValues(java.util.Arrays.asList(
                java.math.BigDecimal.valueOf(2), java.math.BigDecimal.valueOf(8),
                java.math.BigDecimal.valueOf(4)));
        byte[] line = service.lineChart("万人次", java.util.Arrays.asList(first, second), false);
        assertPng(line);
        assertNoLargeLightGrayBackground(line);
        HolidayTrendChartService.ChartCategory category = new HolidayTrendChartService.ChartCategory();
        category.setName("铁路");
        category.setValue(java.math.BigDecimal.TEN);
        byte[] pie = service.pieChart("人次", java.util.Collections.singletonList(category), 400, 300);
        assertPng(pie);
        byte[] stacked = service.stackedBarChart("占比", java.util.Arrays.asList("10月1日", "10月2日"),
                java.util.Arrays.asList(first, second), true, 500, 320);
        assertPng(stacked);
        assertNoLargeLightGrayBackground(stacked);
    }
    @Test
    void roundsLineDataToTheSamePrecisionAsLabels() {
        assertEquals(new BigDecimal("0.49"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.4904"), false, 2));
        assertEquals(new BigDecimal("0.49"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 2));
        assertEquals(new BigDecimal("0.491"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 3));
        assertEquals(new BigDecimal("0.123"),
                HolidayTrendChartService.displayValue(new BigDecimal("0.1234"), true, 2));
    }
    @Test
    void rendersSameGeometryWhenValuesRoundToSameLabel() {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries();
        first.setName("平均拥挤度");
        first.setValues(java.util.Arrays.asList(
                new BigDecimal("0.4904"), new BigDecimal("0.4923"), new BigDecimal("0.5100")));
        HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries();
        second.setName("平均拥挤度");
        second.setValues(java.util.Arrays.asList(
                new BigDecimal("0.4911"), new BigDecimal("0.4934"), new BigDecimal("0.5100")));
        byte[] firstChart = service.lineChart("拥挤度",
                java.util.Collections.singletonList(first), false, 2, 1100, 590);
        byte[] secondChart = service.lineChart("拥挤度",
                java.util.Collections.singletonList(second), false, 2, 1100, 590);
        assertPng(firstChart);
        assertPng(secondChart);
        assertArrayEquals(firstChart, secondChart);
    }
    @Test
    void scalesLineChartHeightForBetterVerticalSeparation() throws Exception {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries series = new HolidayTrendChartService.ChartSeries();
        series.setName("测试");
        series.setValues(java.util.Arrays.asList(
                new BigDecimal("1.00"), new BigDecimal("2.00"), new BigDecimal("3.00")));
        byte[] png = service.lineChart("万人次",
                java.util.Collections.singletonList(series), false, 1100, 590);
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(png));
        assertNotNull(image);
        assertEquals(HolidayTrendChartService.scaleLineChartHeight(590), image.getHeight());
    }
    @Test
    void rendersHigherPrecisionLineLabelsWhenRequested() {
        HolidayTrendChartService service = new HolidayTrendChartService();
        HolidayTrendChartService.ChartSeries congestion = new HolidayTrendChartService.ChartSeries();
        congestion.setName("平均拥挤度");
        congestion.setValues(java.util.Arrays.asList(
                new java.math.BigDecimal("0.467029"),
                new java.math.BigDecimal("0.490890"),
                new java.math.BigDecimal("0.492414")));
        byte[] twoDecimals = service.lineChart("拥挤度",
                java.util.Collections.singletonList(congestion), false, 2, 1100, 590);
        byte[] threeDecimals = service.lineChart("拥挤度",
                java.util.Collections.singletonList(congestion), false, 3, 1100, 590);
        assertPng(twoDecimals);
        assertPng(threeDecimals);
        assertFalse(java.util.Arrays.equals(twoDecimals, threeDecimals));
    }
    private void assertNoLargeLightGrayBackground(byte[] data) throws Exception {
        BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
        int matches = 0;
        for (int y = 0; y < image.getHeight(); y++) {
            for (int x = 0; x < image.getWidth(); x++) {
                int rgb = image.getRGB(x, y) & 0xFFFFFF;
                if (rgb == 0xF3F4F3) matches++;
            }
        }
        assertTrue(matches < image.getWidth() * image.getHeight() / 20,
                "chart still contains a large light-gray background area");
    }
    private void assertPng(byte[] data) {
        assertNotNull(data);
        assertTrue(data.length > 100);
        assertTrue(data[0] == (byte) 0x89 && data[1] == 0x50
                && data[2] == 0x4e && data[3] == 0x47);
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java
New file
@@ -0,0 +1,143 @@
package com.trafficaudit.holiday.service;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HolidayTrendWordServiceTest {
    @Test
    void rebuildsTemplateWithSectionsChartsAndAppendix() throws Exception {
        Path template = resolveTemplate();
        byte[] templateBytes = Files.readAllBytes(template);
        HolidayTrendWordService service = new HolidayTrendWordService();
        List<HolidayTrendWordService.DocumentBlock> blocks = new ArrayList<>();
        blocks.add(new HolidayTrendWordService.SectionBlock("一、总体情况"));
        blocks.add(new HolidayTrendWordService.ChartBlock("客流总量", png(1),
                new HolidayTrendWordService.ImageSize(5000000L, 3000000L)));
        blocks.add(new HolidayTrendWordService.ChartBlock("客流占比", png(2), true,
                new HolidayTrendWordService.ImageSize(4000000L, 2500000L)));
        blocks.add(new HolidayTrendWordService.PageBreakBlock());
        blocks.add(new HolidayTrendWordService.SectionBlock("附录一 æ¯æ—¥å®¢æµæž„成变化"));
        blocks.add(new HolidayTrendWordService.ChartBlock("每日构成", png(3), true));
        byte[] result = service.generate(templateBytes, 2026, blocks);
        List<String> entries = entries(result);
        assertTrue(entries.contains("word/media/image1.png"));
        assertTrue(entries.contains("word/media/image2.png"));
        assertTrue(entries.contains("word/media/image3.png"));
        assertEquals(3, entries.stream().filter(name -> name.endsWith(".png")).count());
        String documentXml = readEntry(result, "word/document.xml");
        assertTrue(documentXml.contains("一、总体情况"));
        assertTrue(documentXml.contains("附录一 æ¯æ—¥å®¢æµæž„成变化"));
        assertTrue(documentXml.contains("客流总量"));
        assertTrue(documentXml.contains("每日构成"));
        assertTrue(!paragraphHasNumbering(documentXml, "一、总体情况"));
        assertTrue(!paragraphHasNumbering(documentXml, "附录一 æ¯æ—¥å®¢æµæž„成变化"));
        assertTrue(documentXml.contains("cx=\"5000000\" cy=\"3000000\""));
        assertTrue(documentXml.contains("cx=\"4000000\" cy=\"2500000\""));
        assertEquals(2, pageBreakCount(documentXml));
        String rels = readEntry(result, "word/_rels/document.xml.rels");
        assertTrue(rels.contains("rId100"));
        assertTrue(rels.contains("rId102"));
    }
    private Path resolveTemplate() {
        Path direct = Paths.get("docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx");
        if (Files.exists(direct)) return direct;
        return Paths.get("..", "docs", "专项统计", "重要节假日", "2026年十一假期全省综合运输客流量走势图.docx");
    }
    private boolean paragraphHasNumbering(String documentXml, String text) throws Exception {
        javax.xml.parsers.DocumentBuilderFactory factory =
                javax.xml.parsers.DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        org.w3c.dom.Document document = factory.newDocumentBuilder().parse(
                new java.io.ByteArrayInputStream(documentXml.getBytes("UTF-8")));
        org.w3c.dom.NodeList paragraphs = document.getElementsByTagNameNS(
                "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "p");
        for (int i = 0; i < paragraphs.getLength(); i++) {
            org.w3c.dom.Element paragraph = (org.w3c.dom.Element) paragraphs.item(i);
            StringBuilder builder = new StringBuilder();
            org.w3c.dom.NodeList texts = paragraph.getElementsByTagNameNS(
                    "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "t");
            for (int j = 0; j < texts.getLength(); j++) {
                builder.append(texts.item(j).getTextContent());
            }
            if (text.equals(builder.toString())) {
                return paragraph.getElementsByTagNameNS(
                        "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "numPr")
                        .getLength() > 0;
            }
        }
        throw new AssertionError("Paragraph not found: " + text);
    }
    private int pageBreakCount(String documentXml) throws Exception {
        javax.xml.parsers.DocumentBuilderFactory factory =
                javax.xml.parsers.DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);
        org.w3c.dom.Document document = factory.newDocumentBuilder().parse(
                new java.io.ByteArrayInputStream(documentXml.getBytes("UTF-8")));
        org.w3c.dom.NodeList breaks = document.getElementsByTagNameNS(
                "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "br");
        int count = 0;
        for (int i = 0; i < breaks.getLength(); i++) {
            org.w3c.dom.Element br = (org.w3c.dom.Element) breaks.item(i);
            if ("page".equals(br.getAttributeNS(
                    "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "type"))) {
                count++;
            }
        }
        return count;
    }
    private byte[] png(int seed) {
        byte[] bytes = new byte[256];
        Arrays.fill(bytes, (byte) seed);
        bytes[0] = (byte) 0x89;
        bytes[1] = 0x50;
        bytes[2] = 0x4e;
        bytes[3] = 0x47;
        return bytes;
    }
    private List<String> entries(byte[] zipBytes) throws Exception {
        List<String> result = new ArrayList<>();
        try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
            ZipEntry entry;
            while ((entry = input.getNextEntry()) != null) result.add(entry.getName());
        }
        return result;
    }
    private String readEntry(byte[] zipBytes, String name) throws Exception {
        try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
            ZipEntry entry;
            while ((entry = input.getNextEntry()) != null) {
                if (name.equals(entry.getName())) {
                    java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
                    byte[] buffer = new byte[1024];
                    int length;
                    while ((length = input.read(buffer)) > 0) out.write(buffer, 0, length);
                    return out.toString("UTF-8");
                }
            }
        }
        return "";
    }
}
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java
New file
@@ -0,0 +1,85 @@
package com.trafficaudit.holiday.service;
import com.trafficaudit.holiday.HolidayConstants;
import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HolidayVehicleAdjustmentServiceTest {
    @Test
    void listAddsOriginalNationalProvincialValueAndLeavesMissingSourceBlank() throws Exception {
        HolidayVehicleAdjustment adjusted = adjustment("2025-10-04", "337.5009");
        HolidayVehicleAdjustment missingSource = adjustment("2025-10-05", "330.0000");
        HolidayVehicleAdjustmentMapper adjustmentMapper = mock(HolidayVehicleAdjustmentMapper.class);
        HolidayVehicleFlowMapper vehicleFlowMapper = mock(HolidayVehicleFlowMapper.class);
        when(adjustmentMapper.selectList(any())).thenReturn(Arrays.asList(adjusted, missingSource));
        when(vehicleFlowMapper.selectList(any())).thenReturn(Collections.singletonList(
                vehicleFlow("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "350.0000")));
        HolidayVehicleAdjustmentService service = new HolidayVehicleAdjustmentService();
        inject(service, "adjustmentMapper", adjustmentMapper);
        inject(service, "vehicleFlowMapper", vehicleFlowMapper);
        List<HolidayVehicleAdjustment> rows = service.list(2025);
        assertEquals(0, new BigDecimal("350.0000").compareTo(rows.get(0).getOriginalValue()));
        assertNull(rows.get(1).getOriginalValue());
    }
    @Test
    void findOriginalValueReturnsMatchedValueAndNullWhenMissing() throws Exception {
        HolidayVehicleFlowMapper vehicleFlowMapper = mock(HolidayVehicleFlowMapper.class);
        when(vehicleFlowMapper.selectOne(any()))
                .thenReturn(vehicleFlow("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "337.9501"))
                .thenReturn(null);
        HolidayVehicleAdjustmentService service = new HolidayVehicleAdjustmentService();
        inject(service, "vehicleFlowMapper", vehicleFlowMapper);
        assertEquals(0, new BigDecimal("337.9501")
                .compareTo(service.findOriginalValue(2025, LocalDate.of(2025, 10, 4))));
        assertNull(service.findOriginalValue(2025, LocalDate.of(2025, 10, 5)));
        assertThrows(IllegalArgumentException.class,
                () -> service.findOriginalValue(2025, LocalDate.of(2024, 10, 4)));
    }
    private static HolidayVehicleAdjustment adjustment(String date, String adjustedValue) {
        HolidayVehicleAdjustment row = new HolidayVehicleAdjustment();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setAdjustedValue(new BigDecimal(adjustedValue));
        return row;
    }
    private static HolidayVehicleFlow vehicleFlow(String date, String roadCategory, String value) {
        HolidayVehicleFlow row = new HolidayVehicleFlow();
        row.setYear(LocalDate.parse(date).getYear());
        row.setFlowDate(LocalDate.parse(date));
        row.setRoadCategory(roadCategory);
        row.setVehicleCount(new BigDecimal(value));
        return row;
    }
    private static void inject(Object target, String fieldName, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target, value);
    }
}
traffic-audit-web/src/components/AppHeader.vue
@@ -1,6 +1,9 @@
<template>
  <el-header class="app-header">
    <span class="header-title">{{ title }}</span>
    <span class="header-title">
      {{ systemName }}<span class="header-version" v-if="version">V{{ version }}</span>
      <template v-if="pageName"> - {{ pageName }}</template>
    </span>
    <div class="header-right">
      <span class="header-user">
        <i class="el-icon-user"></i> {{ username }}
@@ -43,6 +46,7 @@
  },
  data() {
    return {
      version: '',
      pwdDialogVisible: false,
      pwdSaving: false,
      pwdForm: { oldPassword: '', newPassword: '', confirmPassword: '' },
@@ -65,7 +69,22 @@
  computed: {
    username() {
      return sessionStorage.getItem('username') || 'admin'
    },
    // The system name always comes first so the version can sit right after it.
    systemName() {
      return this.title.split(' - ')[0]
    },
    pageName() {
      const parts = this.title.split(' - ')
      return parts.length > 1 ? parts.slice(1).join(' - ') : ''
    }
  },
  created() {
    api.get('/version')
      .then(res => {
        if (res && res.data && res.data.version) this.version = res.data.version
      })
      .catch(() => {})
  },
  methods: {
    openChangePwd() {
@@ -134,6 +153,14 @@
  line-height: normal;
}
.header-user { opacity: 0.92; }
.header-version {
  font-size: 12px;
  line-height: 1.6;
  margin-left: 8px;
  padding: 2px 8px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.18);
}
.header-link { color: #fff; font-size: 14px; padding: 0; }
.header-link:hover { color: #ffd04b; }
.header-logout { color: #fff; font-size: 14px; padding: 0; }
traffic-audit-web/src/main.js
@@ -11,6 +11,11 @@
// å…¨å±€ç™»å‡ºï¼šæ¸…除登录态并回登录页(各页面侧边栏「退出登录」菜单共用)
Vue.mixin({
  computed: {
    operatorIsAdmin() {
      return sessionStorage.getItem('username') === 'admin'
    }
  },
  methods: {
    // el-menu åŒè·¯ç”±å¯¼èˆªï¼ˆNavigationDuplicated)时 Element UI ä¼šæŠŠé«˜äº®å›žæ»šä¸ºç‚¹å‡»å‰æ—§å€¼ï¼›
    // ç‚¹å‡»åŽå»¶è¿Ÿä¸€ä¸ªå®ä»»åŠ¡å¼ºåˆ¶çº æ­£ï¼ˆå®ä»»åŠ¡æ™šäºŽ push çš„微任务回调,保证最终高亮正确)。
traffic-audit-web/src/router/index.js
@@ -14,7 +14,9 @@
    { path: '/audit', name: 'AuditResult', component: () => import('@/views/AuditResult.vue') },
    { path: '/report', name: 'ReportExport', component: () => import('@/views/ReportExport.vue') },
    { path: '/rules', name: 'RuleManage', component: () => import('@/views/RuleManage.vue') },
    { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') }
    { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') },
    { path: '/holiday-stat', name: 'HolidayStat', component: () => import('@/views/HolidayStat.vue') },
    { path: '/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfig.vue'), meta: { requireAdmin: true } }
  ]
})
@@ -22,8 +24,12 @@
  const token = sessionStorage.getItem('token')
  if (to.path === '/login') {
    next(token ? '/dashboard' : undefined)
  } else if (!token) {
    next('/login')
  } else if (to.meta && to.meta.requireAdmin && sessionStorage.getItem('username') !== 'admin') {
    next('/dashboard')
  } else {
    next(token ? undefined : '/login')
    next()
  }
})
traffic-audit-web/src/views/AuditResult.vue
@@ -10,9 +10,14 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
</el-menu>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main :class="{ 'main-focus': focusMode }" style="display:flex;flex-direction:column;min-height:0">
        <h3 v-show="!focusMode" style="flex:none;margin:0 0 10px">审核结果</h3>
traffic-audit-web/src/views/Dashboard.vue
@@ -10,8 +10,14 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item></el-menu>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
traffic-audit-web/src/views/DataImport.vue
@@ -10,9 +10,14 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
</el-menu>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
traffic-audit-web/src/views/DataView.vue
@@ -10,9 +10,14 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
</el-menu>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <h3>导入数据查看</h3>
traffic-audit-web/src/views/ExplainReview.vue
@@ -10,9 +10,14 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
</el-menu>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <h3>企业解释</h3>
traffic-audit-web/src/views/HolidayStat.vue
New file
@@ -0,0 +1,690 @@
<template>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - ä¸“项统计"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
          <h3>专项统计 - èŠ‚å‡æ—¥</h3>
          <div class="toolbar">
            <el-select v-model="year" style="width:120px" @change="loadOverview">
              <el-option v-for="item in years" :key="item" :label="item + 'å¹´'" :value="item"></el-option>
            </el-select>
            <el-select v-model="holidayType" style="width:150px" @change="loadOverview">
              <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
            </el-select>
            <el-button icon="el-icon-refresh" @click="loadOverview">刷新</el-button>
            <el-button icon="el-icon-download" @click="downloadTemplate">下载模板</el-button>
          </div>
          <el-card shadow="never" class="operation-card">
            <div class="operation-row">
              <div class="operation-item">
                <span class="label">导入数据</span>
                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
                           accept=".xlsx,.xls" :on-change="onFileChange" :on-remove="onFileRemove" :file-list="fileList">
                  <el-button type="primary" plain icon="el-icon-folder-opened">选择文件</el-button>
                </el-upload>
                <el-button type="primary" icon="el-icon-upload2" :loading="importing" :disabled="!selectedFile" @click="importData">导入</el-button>
              </div>
              <div class="operation-item">
                <span class="label">导出数据</span>
                <el-button type="success" icon="el-icon-download" :loading="exporting" @click="exportData">导出 Excel</el-button>
              </div>
              <div class="operation-item">
                <span class="label">客流快报</span>
                <el-date-picker v-model="bulletinDate" type="date" value-format="yyyy-MM-dd" style="width:160px" placeholder="选择日期"></el-date-picker>
                <el-button type="warning" icon="el-icon-document" :loading="bulletinLoading" @click="downloadBulletin">下载快报</el-button>
              </div>
              <div class="operation-item">
                <span class="label">走势图</span>
                <el-button type="primary" icon="el-icon-picture-outline" :loading="trendLoading" @click="downloadTrend">下载走势图</el-button>
              </div>
            </div>
          </el-card>
          <div v-if="importMessage" class="import-message">
            <pre>{{ importMessage }}</pre>
          </div>
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">{{ year }}å¹´{{ holidayTypeLabel }}节假日数据</div>
            <el-table :data="rows" border stripe size="small" v-loading="loading" style="width:100%">
              <el-table-column prop="dayIndex" label="第几日" width="80" align="center"></el-table-column>
              <el-table-column prop="date" label="日期" width="120" align="center"></el-table-column>
              <el-table-column label="铁路(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.rail) }}</template></el-table-column>
              <el-table-column label="民航(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.civilAviation) }}</template></el-table-column>
              <el-table-column label="道路(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.road) }}</template></el-table-column>
              <el-table-column label="水路(万人次)" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.water) }}</template></el-table-column>
              <el-table-column label="高速车流(万辆次)" width="150" align="right"><template slot-scope="s">{{ formatNumber(s.row.expressway) }}</template></el-table-column>
              <el-table-column label="国省道车流(万辆次)" width="160" align="right"><template slot-scope="s">{{ formatNumber(s.row.nationalProvincial) }}</template></el-table-column>
              <el-table-column label="国省道环比基期(万辆次)" width="190" align="right"><template slot-scope="s">{{ formatNumber(s.row.comparisonBase) }}</template></el-table-column>
              <el-table-column label="国省道车流环比" width="140" align="right"><template slot-scope="s">{{ formatRatio(s.row.nationalProvincialRingRatio) }}</template></el-table-column>
              <el-table-column label="客运系数" width="100" align="right"><template slot-scope="s">{{ formatCoefficient(s.row.coefficient) }}</template></el-table-column>
              <el-table-column label="非营业性人员流动量(万人次)" min-width="200" align="right"><template slot-scope="s">{{ formatNumber(s.row.nonBusinessPersonFlow) }}</template></el-table-column>
              <el-table-column label="总人员流动量(万人次)" min-width="180" align="right"><template slot-scope="s">{{ formatNumber(s.row.totalPersonFlow) }}</template></el-table-column>
            </el-table>
          </el-card>
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">
              <span>往年国省道数据调整</span>
            </div>
            <div class="operation-row">
              <div class="operation-item">
                <span class="label">年份</span>
                <el-select v-model="adjustmentYear" style="width:120px" @change="loadAdjustments">
                  <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + 'å¹´'" :value="item"></el-option>
                </el-select>
              </div>
              <div v-if="operatorIsAdmin" class="operation-item">
                <el-button type="primary" icon="el-icon-plus" @click="openAdjustmentDialog()">新增调整</el-button>
              </div>
            </div>
            <el-table :data="adjustments" border stripe size="small" style="margin-top:12px" v-loading="adjustmentLoading">
              <el-table-column prop="year" label="年份" width="90" align="center"></el-table-column>
              <el-table-column prop="flowDate" label="日期" width="130" align="center"></el-table-column>
              <el-table-column label="调整前国省道车流量(万辆次)" width="240" align="right">
                <template slot-scope="s">{{ formatOriginalAdjustmentValue(s.row.originalValue) }}</template>
              </el-table-column>
              <el-table-column label="调整后国省道车流量(万辆次)" width="240" align="right">
                <template slot-scope="s">{{ formatAdjustmentValue(s.row.adjustedValue) }}</template>
              </el-table-column>
              <el-table-column prop="reason" label="调整原因" min-width="280" show-overflow-tooltip></el-table-column>
              <el-table-column label="更新时间" min-width="170"><template slot-scope="s">{{ formatDateTime(s.row.updatedAt) }}</template></el-table-column>
              <el-table-column v-if="operatorIsAdmin" label="操作" width="130" align="center">
                <template slot-scope="s">
                  <el-button type="text" @click="openAdjustmentDialog(s.row)">编辑</el-button>
                  <el-button type="text" class="danger-text" @click="deleteAdjustment(s.row)">删除</el-button>
                </template>
              </el-table-column>
            </el-table>
            <el-dialog :title="adjustmentForm.id ? '编辑调整' : '新增调整'" :visible.sync="adjustmentDialogVisible" width="560px" v-dialog-drag>
              <el-form label-width="220px">
                <el-form-item label="年份">
                  <el-select v-model="adjustmentForm.year" style="width:100%" @change="onAdjustmentYearChange">
                    <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + 'å¹´'" :value="item"></el-option>
                  </el-select>
                </el-form-item>
                <el-form-item label="日期">
                  <el-date-picker v-model="adjustmentForm.flowDate" type="date" value-format="yyyy-MM-dd"
                                  style="width:100%" :picker-options="adjustmentDatePickerOptions" placeholder="选择日期"
                                  @change="onAdjustmentDateChange"></el-date-picker>
                </el-form-item>
                <el-form-item label="调整前国省道车流量(万辆次)">
                  <el-input :value="formatOriginalAdjustmentValue(adjustmentForm.originalValue)" readonly
                            :placeholder="adjustmentOriginalLoading ? '查询中...' : ''"></el-input>
                </el-form-item>
                <el-form-item label="调整后车流量(万辆次)">
                  <el-input-number v-model="adjustmentForm.adjustedValue" :min="0" :precision="4" :step="0.0001" controls-position="right" style="width:100%"></el-input-number>
                </el-form-item>
                <el-form-item label="调整原因">
                  <el-input v-model="adjustmentForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit></el-input>
                </el-form-item>
              </el-form>
              <div slot="footer">
                <el-button @click="adjustmentDialogVisible = false">取消</el-button>
                <el-button type="primary" :loading="adjustmentSaving" @click="saveAdjustment">保存</el-button>
              </div>
            </el-dialog>
          </el-card>
          <el-card shadow="never" style="margin-top:14px">
            <div slot="header">
              <span>观测站分日调查数据</span>
              <span class="hint">用于原始数据留存,不与上方节假日统计数据关联</span>
            </div>
            <div class="operation-row">
              <div class="operation-item">
                <span class="label">导入数据</span>
                <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
                           accept=".xls,.xlsx" :on-change="onObservationFileChange" :on-remove="onObservationFileRemove"
                           :file-list="observationFileList">
                  <el-button type="primary" plain icon="el-icon-folder-opened">选择文件</el-button>
                </el-upload>
                <el-button type="primary" icon="el-icon-upload2" :loading="observationImporting"
                           :disabled="!observationFile" @click="importObservation">导入</el-button>
              </div>
            </div>
            <div v-if="observationMessage" class="import-message">
              <pre>{{ observationMessage }}</pre>
            </div>
            <el-table :data="observationDates" border stripe size="small" style="margin-top:12px" v-loading="observationLoading">
              <el-table-column prop="obsDate" label="观测日期" width="130" align="center"></el-table-column>
              <el-table-column prop="stationCount" label="观测站数" width="110" align="right"></el-table-column>
              <el-table-column label="中小客流量合计(万辆次)" width="210" align="right">
                <template slot-scope="s">{{ formatWanVehicles(s.row.smallMediumPassengerTotal) }}</template>
              </el-table-column>
              <el-table-column prop="importedAt" label="最近导入时间" min-width="180"></el-table-column>
              <el-table-column label="操作" width="150" align="center">
                <template slot-scope="s">
                  <el-button type="text" @click="viewObservation(s.row.obsDate)">查看</el-button>
                  <el-button type="text" class="danger-text" @click="deleteObservation(s.row.obsDate)">删除</el-button>
                </template>
              </el-table-column>
            </el-table>
          </el-card>
          <el-dialog :title="'观测站明细 - ' + observationDialogDate" :visible.sync="observationDialogVisible" width="92%" top="5vh">
            <el-table :data="observationRows" border stripe size="mini" height="540" v-loading="observationRowsLoading">
              <el-table-column prop="obsDate" label="观测日期" width="110" fixed="left" align="center"></el-table-column>
              <el-table-column prop="stationCode" label="观测站编号" width="130" fixed="left"></el-table-column>
              <el-table-column prop="stationName" label="观测站名称" width="120"></el-table-column>
              <el-table-column label="观测里程" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.mileage) }}</template></el-table-column>
              <el-table-column prop="cityName" label="地市名称" width="140"></el-table-column>
              <el-table-column prop="countyName" label="区县名称" width="110"></el-table-column>
              <el-table-column prop="direction" label="行驶方向" width="90"></el-table-column>
              <el-table-column label="中小客流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallMediumPassenger) }}</template></el-table-column>
              <el-table-column label="大客车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largePassenger) }}</template></el-table-column>
              <el-table-column label="小货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallTruck) }}</template></el-table-column>
              <el-table-column label="中货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.mediumTruck) }}</template></el-table-column>
              <el-table-column label="大货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largeTruck) }}</template></el-table-column>
              <el-table-column label="特大货流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.extraLargeTruck) }}</template></el-table-column>
              <el-table-column label="集装箱流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.containerTruck) }}</template></el-table-column>
              <el-table-column label="摩托车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorcycle) }}</template></el-table-column>
              <el-table-column label="拖拉机流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.tractor) }}</template></el-table-column>
              <el-table-column label="客车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerTotal) }}</template></el-table-column>
              <el-table-column label="货车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckTotal) }}</template></el-table-column>
              <el-table-column label="汽车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carTotal) }}</template></el-table-column>
              <el-table-column label="机动车流量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorTotal) }}</template></el-table-column>
              <el-table-column label="客车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerEquivalent) }}</template></el-table-column>
              <el-table-column label="货车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckEquivalent) }}</template></el-table-column>
              <el-table-column label="汽车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carEquivalent) }}</template></el-table-column>
              <el-table-column label="机动车当量" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorEquivalent) }}</template></el-table-column>
              <el-table-column label="机动车速度" width="110" align="right"><template slot-scope="s">{{ formatPlain(s.row.vehicleSpeed) }}</template></el-table-column>
              <el-table-column label="拥挤度" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.congestionDegree) }}</template></el-table-column>
              <el-table-column prop="sourceFile" label="来源文件名" min-width="220" show-overflow-tooltip></el-table-column>
            </el-table>
          </el-dialog>
        </div>
      </el-main>
    </el-container>
  </el-container>
</template>
<script>
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
export default {
  components: { AppHeader },
  name: 'HolidayStat',
  data() {
    return {
      years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
      year: 2026,
      holidayType: 'NATIONAL_DAY',
      holidayTypeOptions: [],
      rows: [],
      loading: false,
      importing: false,
      exporting: false,
      bulletinLoading: false,
      trendLoading: false,
      selectedFile: null,
      fileList: [],
      importMessage: '',
      allowHistoricalOverwrite: false,
      bulletinDate: this.previousDateString(),
      observationFile: null,
      observationFileList: [],
      observationImporting: false,
      observationLoading: false,
      observationMessage: '',
      observationDates: [],
      observationDialogVisible: false,
      observationDialogDate: '',
      observationRows: [],
      observationRowsLoading: false,
      adjustmentYear: new Date().getFullYear() - 1,
      adjustments: [],
      adjustmentLoading: false,
      adjustmentDialogVisible: false,
      adjustmentSaving: false,
      adjustmentOriginalLoading: false,
      adjustmentOriginalRequestId: 0,
      adjustmentForm: {
        id: null,
        year: new Date().getFullYear() - 1,
        flowDate: '',
        originalValue: null,
        adjustedValue: null,
        reason: ''
      }
    }
  },
  computed: {
    holidayTypeLabel() {
      const item = this.holidayTypeOptions.find(x => x.value === this.holidayType)
      return item ? item.label : this.holidayType
    },
    availableAdjustmentYears() {
      const currentYear = new Date().getFullYear()
      return this.years.filter(item => item < currentYear)
    },
    adjustmentDatePickerOptions() {
      return {
        disabledDate: date => date.getFullYear() !== Number(this.adjustmentForm.year)
      }
    }
  },
  mounted() {
    this.loadOptions()
    this.loadOverview()
    this.loadObservationDates()
    this.loadAdjustments()
  },
  methods: {
    previousDateString() {
      const date = new Date()
      date.setDate(date.getDate() - 1)
      const month = String(date.getMonth() + 1).padStart(2, '0')
      const day = String(date.getDate()).padStart(2, '0')
      return date.getFullYear() + '-' + month + '-' + day
    },
    loadOptions() {
      api.get('/holiday/config/options').then(res => {
        const map = (res.data && res.data.holidayTypes) || {}
        this.holidayTypeOptions = Object.keys(map).map(key => ({ value: key, label: map[key] }))
      })
    },
    loadOverview() {
      this.loading = true
      api.get('/holiday/overview', { params: { year: this.year, holidayType: this.holidayType } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.rows = (res.data && res.data.rows) || []
        })
        .catch(err => this.showError(err, '节假日数据加载失败'))
        .finally(() => { this.loading = false })
    },
    loadAdjustments() {
      this.adjustmentLoading = true
      api.get('/holiday/adjustments', { params: { year: this.adjustmentYear } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.adjustments = res.data || []
        })
        .catch(err => this.showError(err, '往年国省道数据调整加载失败'))
        .finally(() => { this.adjustmentLoading = false })
    },
    openAdjustmentDialog(row) {
      const source = row || {}
      this.adjustmentForm = {
        id: source.id || null,
        year: source.year || this.adjustmentYear,
        flowDate: source.flowDate || '',
        originalValue: source.originalValue === undefined || source.originalValue === null
          ? null : Number(source.originalValue),
        adjustedValue: source.adjustedValue === undefined || source.adjustedValue === null
          ? null : Number(source.adjustedValue),
        reason: source.reason || ''
      }
      this.adjustmentDialogVisible = true
      this.loadAdjustmentOriginalValue()
    },
    onAdjustmentYearChange() {
      this.adjustmentOriginalRequestId += 1
      this.adjustmentOriginalLoading = false
      this.adjustmentForm.flowDate = ''
      this.adjustmentForm.originalValue = null
    },
    onAdjustmentDateChange() {
      this.loadAdjustmentOriginalValue()
    },
    loadAdjustmentOriginalValue() {
      const year = this.adjustmentForm.year
      const flowDate = this.adjustmentForm.flowDate
      const requestId = ++this.adjustmentOriginalRequestId
      this.adjustmentForm.originalValue = null
      if (!year || !flowDate) {
        this.adjustmentOriginalLoading = false
        return
      }
      this.adjustmentOriginalLoading = true
      api.get('/holiday/adjustments/source-value', { params: { year: year, flowDate: flowDate } })
        .then(res => {
          if (requestId !== this.adjustmentOriginalRequestId) return
          if (res.code !== 200) throw new Error(res.message || '调整前值加载失败')
          this.adjustmentForm.originalValue = res.data === null || res.data === undefined || res.data === ''
            ? null : Number(res.data)
        })
        .catch(err => {
          if (requestId !== this.adjustmentOriginalRequestId) return
          this.adjustmentForm.originalValue = null
          this.showError(err, '调整前值加载失败')
        })
        .finally(() => {
          if (requestId === this.adjustmentOriginalRequestId) this.adjustmentOriginalLoading = false
        })
    },
    saveAdjustment() {
      const form = this.adjustmentForm
      if (!form.year || !form.flowDate) {
        this.$message.warning('请选择年份和日期')
        return
      }
      if (form.adjustedValue === null || form.adjustedValue === undefined || Number(form.adjustedValue) < 0) {
        this.$message.warning('请输入不低于 0 çš„调整后车流量')
        return
      }
      if (!form.reason || !form.reason.trim()) {
        this.$message.warning('请填写调整原因')
        return
      }
      this.adjustmentSaving = true
      api.post('/holiday/adjustments/save', {
        id: form.id,
        year: form.year,
        flowDate: form.flowDate,
        adjustedValue: form.adjustedValue,
        reason: form.reason
      })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '保存失败')
          this.adjustmentDialogVisible = false
          this.adjustmentYear = form.year
          this.loadAdjustments()
          this.loadOverview()
          this.$message.success('调整已保存')
        })
        .catch(err => this.showError(err, '保存失败'))
        .finally(() => { this.adjustmentSaving = false })
    },
    deleteAdjustment(row) {
      this.$confirm('确定删除 ' + row.flowDate + ' çš„国省道数据调整吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/holiday/adjustments/delete', null, { params: { id: row.id } }))
        .then(res => {
          if (res && res.code !== 200) throw new Error(res.message || '删除失败')
          this.loadAdjustments()
          this.loadOverview()
          this.$message.success('调整已删除')
        })
        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '删除失败') })
    },
    onFileChange(file) {
      this.selectedFile = file.raw
      this.fileList = [file]
      this.importMessage = ''
    },
    onFileRemove() {
      this.selectedFile = null
      this.fileList = []
    },
    importData(allowHistoricalOverwrite) {
      if (!this.selectedFile) return
      const allowOverwrite = allowHistoricalOverwrite === true || this.allowHistoricalOverwrite === true
      const form = new FormData()
      form.append('file', this.selectedFile)
      form.append('year', this.year)
      form.append('holidayType', this.holidayType)
      form.append('allowHistoricalOverwrite', allowOverwrite ? 'true' : 'false')
      this.importing = true
      this.importMessage = ''
      api.post('/holiday/import', form, { timeout: 120000, headers: { 'Content-Type': 'multipart/form-data' } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '导入失败')
          const data = res.data || {}
          if (data.confirmationRequired) {
            const changes = data.historicalChanges || []
            this.importMessage = ['检测到历史日期的数据发生变化,尚未导入。请确认是否允许覆盖:'].concat(changes).join('\n')
            this.$message.warning('检测到历史数据发生变化,请确认是否允许导入')
            this.confirmHistoricalImport(changes)
            return
          }
          const lines = [
            '导入完成:客流 ' + (data.passengerRows || 0) + ' æ¡ï¼Œè½¦æµ ' + (data.vehicleRows || 0) + ' æ¡ï¼Œè·³è¿‡ç©ºç™½ ' + (data.skippedRows || 0) + ' æ¡ã€‚'
          ]
          if (data.errors && data.errors.length) {
            lines.push('失败明细:')
            data.errors.forEach(item => lines.push(item))
          }
          this.importMessage = lines.join('\n')
          this.allowHistoricalOverwrite = false
          this.$message.success('导入完成')
          this.loadOverview()
        })
        .catch(err => {
          this.allowHistoricalOverwrite = false
          this.showError(err, '导入失败')
        })
        .finally(() => { this.importing = false })
    },
    confirmHistoricalImport(changes) {
      const rows = (changes && changes.length ? changes : ['历史数据发生变化']).map(this.escapeHtml)
      const html = '<div>检测到已发布日期的数据发生变化。允许导入将覆盖这些历史数据。</div>'
        + '<pre style="max-height:320px;overflow:auto;white-space:pre-wrap;margin-top:10px;background:#f7f7f7;padding:8px;border:1px solid #e4e7ed;">'
        + rows.join('\n') + '</pre>'
      this.$confirm(html, '历史数据发生变化', {
        dangerouslyUseHTMLString: true,
        confirmButtonText: '允许导入',
        cancelButtonText: '不允许',
        type: 'warning',
        distinguishCancelAndClose: true
      }).then(() => {
        this.allowHistoricalOverwrite = true
        this.importData(true)
      }).catch(() => {
        this.allowHistoricalOverwrite = false
      })
    },
    escapeHtml(value) {
      return String(value === null || value === undefined ? '' : value)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;')
    },
    exportData() {
      this.exporting = true
      api.get('/holiday/export', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 120000 })
        .then(res => { this.downloadBlob(res, this.year + 'å¹´' + this.holidayTypeLabel + '数据.xlsx'); this.$message.success('导出成功') })
        .catch(err => this.handleBlobError(err, '导出失败'))
        .finally(() => { this.exporting = false })
    },
    downloadTemplate() {
      const templatePath = '专项统计/重要节假日/2026年十一数据.xlsx'
      const encodedPath = templatePath.split('/').map(encodeURIComponent).join('/')
      api.get('/templates/' + encodedPath, { responseType: 'blob' })
        .then(res => { this.downloadBlob(res, '2026年十一数据.xlsx'); this.$message.success('模板下载成功') })
        .catch(err => this.handleBlobError(err, '模板下载失败'))
    },
    downloadBulletin() {
      this.bulletinLoading = true
      api.get('/holiday/bulletin', { params: { date: this.bulletinDate }, responseType: 'blob', timeout: 120000 })
        .then(res => {
          const day = Number((this.bulletinDate || '').slice(-2))
          this.downloadBlob(res, '湖北省国庆假期综合运输客流快报(10月' + day + '日).docx')
          this.$message.success('快报下载成功')
        })
        .catch(err => this.handleBlobError(err, '快报生成失败'))
        .finally(() => { this.bulletinLoading = false })
    },
    downloadTrend() {
      this.trendLoading = true
      api.get('/holiday/trend', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 180000 })
        .then(res => {
          this.downloadBlob(res, this.year + 'å¹´' + this.holidayTypeLabel + '假期全省综合运输客流量走势图.docx')
          this.$message.success('走势图下载成功')
        })
        .catch(err => this.handleBlobError(err, '走势图生成失败'))
        .finally(() => { this.trendLoading = false })
    },
    downloadBlob(data, fileName) {
      const blob = data instanceof Blob ? data : new Blob([data])
      const url = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = url
      link.download = fileName
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(url)
    },
    handleBlobError(err, fallback) {
      if (err && err.response && err.response.data instanceof Blob) {
        err.response.data.text().then(text => {
          try {
            const parsed = JSON.parse(text)
            this.$message.error(parsed.message || fallback)
          } catch (e) {
            this.$message.error(fallback)
          }
        })
        return
      }
      this.showError(err, fallback)
    },
    showError(err, fallback) {
      if (err && err._toast) return
      const data = err && err.response && err.response.data
      this.$message.error((data && data.message) || (err && err.message) || fallback)
    },
    onObservationFileChange(file) {
      this.observationFile = file.raw
      this.observationFileList = [file]
      this.observationMessage = ''
    },
    onObservationFileRemove() {
      this.observationFile = null
      this.observationFileList = []
    },
    loadObservationDates() {
      this.observationLoading = true
      api.get('/observation/dates')
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.observationDates = res.data || []
        })
        .catch(err => this.showError(err, '观测站数据加载失败'))
        .finally(() => { this.observationLoading = false })
    },
    importObservation() {
      if (!this.observationFile) return
      const form = new FormData()
      form.append('file', this.observationFile)
      this.observationImporting = true
      this.observationMessage = ''
      api.post('/observation/import', form, { timeout: 300000, headers: { 'Content-Type': 'multipart/form-data' } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '导入失败')
          const data = res.data || {}
          const dates = (data.dates && data.dates.length) ? data.dates.join('、') : '—'
          const lines = ['导入完成:' + (data.totalRows || 0) + ' æ¡ï¼Œè§‚测站 ' + (data.stationCount || 0)
            + ' ä¸ªï¼Œæ—¥æœŸ ' + dates + ',跳过空白 ' + (data.skippedRows || 0) + ' æ¡ã€‚']
          if (data.errors && data.errors.length) {
            lines.push('失败明细:')
            data.errors.forEach(item => lines.push(item))
          }
          this.observationMessage = lines.join('\n')
          this.$message.success('观测站数据导入完成')
          this.observationFile = null
          this.observationFileList = []
          this.loadObservationDates()
        })
        .catch(err => this.showError(err, '观测站数据导入失败'))
        .finally(() => { this.observationImporting = false })
    },
    viewObservation(date) {
      this.observationDialogDate = date
      this.observationDialogVisible = true
      this.observationRowsLoading = true
      this.observationRows = []
      api.get('/observation/rows', { params: { date: date, limit: 2000 } })
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '加载失败')
          this.observationRows = res.data || []
        })
        .catch(err => this.showError(err, '观测站明细加载失败'))
        .finally(() => { this.observationRowsLoading = false })
    },
    deleteObservation(date) {
      this.$confirm('确定删除 ' + date + ' çš„观测站数据吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/observation/delete', null, { params: { date: date } }))
        .then(res => {
          if (res && res.code !== 200) throw new Error(res.message || '删除失败')
          this.$message.success('已删除')
          this.loadObservationDates()
        })
        .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '删除失败') })
    },
    formatAdjustmentValue(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return number.toFixed(4)
    },
    formatOriginalAdjustmentValue(value) {
      if (value === null || value === undefined || value === '') return ''
      return this.formatAdjustmentValue(value)
    },
    formatDateTime(value) {
      if (!value) return '—'
      return String(value).replace('T', ' ').slice(0, 19)
    },
    formatNumber(value) {
      if (value === null || value === undefined || value === '') return '—'
      return Number(value).toFixed(2)
    },
    formatWanVehicles(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return (number / 10000).toFixed(4)
    },
    formatInteger(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return String(Math.round(number))
    },
    formatPlain(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return String(number)
    },
    formatCoefficient(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
    },
    formatRatio(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return (number * 100).toFixed(2) + '%'
    }
  }
}
</script>
<style scoped>
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.operation-card { margin-top: 12px; }
.operation-row { display: flex; gap: 36px; align-items: flex-start; flex-wrap: wrap; }
.operation-item { display: flex; align-items: center; gap: 8px; }
.operation-item .label { color: #606266; font-size: 14px; }
.import-message { margin-top: 12px; padding: 10px 12px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
.import-message pre { margin: 0; white-space: pre-wrap; font-family: inherit; color: #606266; }
.hint { margin-left: 10px; color: #909399; font-size: 12px; }
.danger-text { color: #f56c6c; }
</style>
traffic-audit-web/src/views/Login.vue
@@ -1,7 +1,7 @@
<template>
  <div class="login-container">
    <el-card class="login-card">
      <h2>交通统计报表审核系统</h2>
      <h2>交通统计报表审核系统<span class="login-version" v-if="version">V{{ version }}</span></h2>
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item prop="username">
          <el-input v-model="form.username" placeholder="用户名" @keyup.enter.native="login"></el-input>
@@ -40,10 +40,17 @@
      captchaId: '',
      clickIds: [],
      loggingIn: false,
      captchaRequired: false
      captchaRequired: false,
      version: ''
    }
  },
  created() {
    axios.get('/api/version', { timeout: 10000 })
      .then(res => {
        const d = res.data
        if (d && d.data && d.data.version) this.version = d.data.version
      })
      .catch(() => {})
    // éªŒè¯ç æ˜¯å¦å¯ç”¨ä¸€å¾‹ä»¥åŽç«¯ /api/auth/captcha-config ä¸ºå‡†ï¼š
    // é»˜è®¤ä¸æ˜¾ç¤ºï¼Œåªæœ‰åŽç«¯æ˜Žç¡®è¿”回 required=true æ‰æ¸²æŸ“验证码组件。
    // ä¿®å¤ï¼šæ­¤å‰é»˜è®¤ true ä¸”请求失败时静默忽略,切换网络后该配置读不到,
@@ -114,5 +121,6 @@
.captcha-skip-tip { font-size: 12px; color: #909399; line-height: 1.6; padding: 8px 10px; background: #F5F7FA; border-radius: 4px; }
.login-card { width: 400px; }
.login-card h2 { text-align: center; margin-bottom: 18px; }
.login-version { font-size: 13px; font-weight: normal; color: #909399; margin-left: 8px; vertical-align: 3px; }
.login-card .el-form-item { margin-bottom: 16px; }
</style>
traffic-audit-web/src/views/ReportExport.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/RuleManage.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
traffic-audit-web/src/views/SystemConfig.vue
New file
@@ -0,0 +1,304 @@
<template>
  <el-container direction="vertical">
    <app-header title="交通统计报表审核系统 - ç³»ç»Ÿé…ç½®"></app-header>
    <el-container>
      <el-aside width="200px">
        <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
          <el-menu-item index="/dashboard">工作台</el-menu-item>
          <el-menu-item index="/import">数据导入</el-menu-item>
          <el-menu-item index="/data">数据查看</el-menu-item>
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>
        <div class="page-scroll">
          <h3>系统配置</h3>
          <el-tabs v-model="activeTab" @tab-click="loadActiveTab">
            <el-tab-pane label="节假日管理" name="holidays">
              <div class="toolbar">
                <el-select v-model="holidayQuery.year" clearable placeholder="年份" style="width:130px" @change="loadHolidays">
                  <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
                </el-select>
                <el-select v-model="holidayQuery.holidayType" clearable placeholder="节假日分类" style="width:150px" @change="loadHolidays">
                  <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
                </el-select>
                <el-button type="primary" icon="el-icon-search" @click="loadHolidays">查询</el-button>
                <el-button type="success" icon="el-icon-plus" @click="openHolidayDialog()">新增节假日</el-button>
              </div>
              <el-table :data="holidays" border stripe size="small" v-loading="loadingHolidays" style="width:100%">
                <el-table-column prop="year" label="年份" width="100" align="center"></el-table-column>
                <el-table-column label="节假日分类" width="140">
                  <template slot-scope="scope">{{ labelOf(holidayTypeOptions, scope.row.holidayType) }}</template>
                </el-table-column>
                <el-table-column prop="holidayDate" label="日期" width="150" align="center"></el-table-column>
                <el-table-column label="日期类型" width="120" align="center">
                  <template slot-scope="scope">{{ dayIndexLabel(scope.row.dayIndex) }}</template>
                </el-table-column>
                <el-table-column label="操作" width="150" align="center">
                  <template slot-scope="scope">
                    <el-button type="text" size="mini" @click="openHolidayDialog(scope.row)">编辑</el-button>
                    <el-button type="text" size="mini" class="danger-text" @click="removeHoliday(scope.row)">删除</el-button>
                  </template>
                </el-table-column>
              </el-table>
            </el-tab-pane>
            <el-tab-pane label="客运系数管理" name="coefficients">
              <div class="toolbar">
                <el-select v-model="coefficientQuery.year" clearable placeholder="年份" style="width:130px" @change="loadCoefficients">
                  <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
                </el-select>
                <el-button type="primary" icon="el-icon-search" @click="loadCoefficients">查询</el-button>
                <el-button type="success" icon="el-icon-plus" @click="openCoefficientDialog()">新增客运系数</el-button>
              </div>
              <el-table :data="coefficients" border stripe size="small" v-loading="loadingCoefficients" style="width:100%">
                <el-table-column prop="year" label="年份" width="120" align="center"></el-table-column>
                <el-table-column label="日期分类" width="160">
                  <template slot-scope="scope">{{ labelOf(dateCategoryOptions, scope.row.dateCategory) }}</template>
                </el-table-column>
                <el-table-column label="客运系数" width="160" align="center">
                  <template slot-scope="scope">{{ formatCoefficient(scope.row.coefficient) }}</template>
                </el-table-column>
                <el-table-column label="操作" width="150" align="center">
                  <template slot-scope="scope">
                    <el-button type="text" size="mini" @click="openCoefficientDialog(scope.row)">编辑</el-button>
                    <el-button type="text" size="mini" class="danger-text" @click="removeCoefficient(scope.row)">删除</el-button>
                  </template>
                </el-table-column>
              </el-table>
            </el-tab-pane>
          </el-tabs>
        </div>
        <el-dialog :title="holidayForm.id ? '编辑节假日' : '新增节假日'" :visible.sync="holidayDialog" width="520px" v-dialog-drag>
          <el-form ref="holidayForm" :model="holidayForm" :rules="holidayRules" label-width="110px">
            <el-form-item label="年份" prop="year">
              <el-select v-model="holidayForm.year" style="width:100%" @change="onHolidayYearChange">
                <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="节假日分类" prop="holidayType">
              <el-select v-model="holidayForm.holidayType" style="width:100%">
                <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="日期" prop="holidayDate">
              <el-date-picker v-model="holidayForm.holidayDate" type="date" value-format="yyyy-MM-dd"
                              style="width:100%" placeholder="选择日期"></el-date-picker>
            </el-form-item>
            <el-form-item label="日期类型" prop="dayIndex">
              <el-select v-model="holidayForm.dayIndex" style="width:100%">
                <el-option label="前一日" :value="0"></el-option>
                <el-option v-for="day in 31" :key="day" :label="'第' + day + '日'" :value="day"></el-option>
              </el-select>
            </el-form-item>
          </el-form>
          <span slot="footer">
            <el-button @click="holidayDialog = false">取消</el-button>
            <el-button type="primary" :loading="saving" @click="saveHoliday">保存</el-button>
          </span>
        </el-dialog>
        <el-dialog :title="coefficientForm.id ? '编辑客运系数' : '新增客运系数'" :visible.sync="coefficientDialog" width="520px" v-dialog-drag>
          <el-form ref="coefficientForm" :model="coefficientForm" :rules="coefficientRules" label-width="110px">
            <el-form-item label="年份" prop="year">
              <el-select v-model="coefficientForm.year" style="width:100%">
                <el-option v-for="year in years" :key="year" :label="year + 'å¹´'" :value="year"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="日期分类" prop="dateCategory">
              <el-select v-model="coefficientForm.dateCategory" style="width:100%">
                <el-option v-for="item in dateCategoryOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
              </el-select>
            </el-form-item>
            <el-form-item label="客运系数" prop="coefficient">
              <el-input-number v-model="coefficientForm.coefficient" :min="0.01" :precision="4" :step="0.1" style="width:100%"></el-input-number>
            </el-form-item>
          </el-form>
          <span slot="footer">
            <el-button @click="coefficientDialog = false">取消</el-button>
            <el-button type="primary" :loading="saving" @click="saveCoefficient">保存</el-button>
          </span>
        </el-dialog>
      </el-main>
    </el-container>
  </el-container>
</template>
<script>
import api from '@/api'
import AppHeader from '@/components/AppHeader.vue'
export default {
  components: { AppHeader },
  name: 'SystemConfig',
  data() {
    return {
      activeTab: 'holidays',
      years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
      holidayTypeOptions: [],
      dateCategoryOptions: [],
      holidayQuery: { year: 2026, holidayType: 'NATIONAL_DAY' },
      coefficientQuery: { year: 2026 },
      holidays: [],
      coefficients: [],
      loadingHolidays: false,
      loadingCoefficients: false,
      saving: false,
      holidayDialog: false,
      coefficientDialog: false,
      holidayForm: { id: null, year: 2026, holidayType: 'NATIONAL_DAY', holidayDate: '', dayIndex: 1 },
      coefficientForm: { id: null, year: 2026, dateCategory: 'HOLIDAY', coefficient: 2.4 },
      holidayRules: {
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
        holidayType: [{ required: true, message: '请选择节假日分类', trigger: 'change' }],
        holidayDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
        dayIndex: [{ required: true, message: '请选择日期类型', trigger: 'change' }]
      },
      coefficientRules: {
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
        dateCategory: [{ required: true, message: '请选择日期分类', trigger: 'change' }],
        coefficient: [{ required: true, message: '请输入客运系数', trigger: 'change' }]
      }
    }
  },
  mounted() {
    this.loadOptions()
    this.loadHolidays()
    this.loadCoefficients()
  },
  methods: {
    dayIndexLabel(value) {
      if (Number(value) === 0) return '前一日'
      return value === null || value === undefined || value === '' ? '—' : '第' + value + '日'
    },
    loadOptions() {
      api.get('/holiday/config/options').then(res => {
        const data = res.data || {}
        this.holidayTypeOptions = this.mapOptions(data.holidayTypes)
        this.dateCategoryOptions = this.mapOptions(data.dateCategories)
      })
    },
    mapOptions(map) {
      if (!map) return []
      return Object.keys(map).map(key => ({ value: key, label: map[key] }))
    },
    labelOf(options, value) {
      const item = (options || []).find(x => x.value === value)
      return item ? item.label : value
    },
    loadActiveTab() {
      if (this.activeTab === 'holidays') this.loadHolidays()
      else this.loadCoefficients()
    },
    loadHolidays() {
      this.loadingHolidays = true
      api.get('/holiday/config/holidays', { params: this.holidayQuery })
        .then(res => { this.holidays = res.data || [] })
        .catch(e => { if (!e || !e._toast) this.$message.error('节假日加载失败') })
        .finally(() => { this.loadingHolidays = false })
    },
    loadCoefficients() {
      this.loadingCoefficients = true
      api.get('/holiday/config/coefficients', { params: this.coefficientQuery })
        .then(res => { this.coefficients = res.data || [] })
        .catch(e => { if (!e || !e._toast) this.$message.error('客运系数加载失败') })
        .finally(() => { this.loadingCoefficients = false })
    },
    openHolidayDialog(row) {
      this.holidayDialog = true
      this.$nextTick(() => {
        this.holidayForm = row ? Object.assign({}, row) : { id: null, year: 2026, holidayType: 'NATIONAL_DAY', holidayDate: '', dayIndex: 1 }
        if (this.$refs.holidayForm) this.$refs.holidayForm.clearValidate()
      })
    },
    onHolidayYearChange(year) {
      if (this.holidayForm.holidayDate && Number(this.holidayForm.holidayDate.slice(0, 4)) !== year) {
        this.holidayForm.holidayDate = ''
      }
    },
    saveHoliday() {
      this.$refs.holidayForm.validate(valid => {
        if (!valid) return
        this.saving = true
        api.post('/holiday/config/holidays/save', this.holidayForm)
          .then(res => {
            if (res.code !== 200) throw new Error(res.message || '保存失败')
            this.$message.success('保存成功')
            this.holidayDialog = false
            this.loadHolidays()
          })
          .catch(err => this.showError(err, '保存失败'))
          .finally(() => { this.saving = false })
      })
    },
    removeHoliday(row) {
      this.$confirm('确定删除该节假日日期吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/holiday/config/holidays/delete', null, { params: { id: row.id } }))
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '删除失败')
          this.$message.success('已删除')
          this.loadHolidays()
        })
        .catch(err => { if (err !== 'cancel') this.showError(err, '删除失败') })
    },
    openCoefficientDialog(row) {
      this.coefficientDialog = true
      this.$nextTick(() => {
        this.coefficientForm = row ? Object.assign({}, row) : { id: null, year: 2026, dateCategory: 'HOLIDAY', coefficient: 2.4 }
        if (this.$refs.coefficientForm) this.$refs.coefficientForm.clearValidate()
      })
    },
    saveCoefficient() {
      this.$refs.coefficientForm.validate(valid => {
        if (!valid) return
        this.saving = true
        api.post('/holiday/config/coefficients/save', this.coefficientForm)
          .then(res => {
            if (res.code !== 200) throw new Error(res.message || '保存失败')
            this.$message.success('保存成功')
            this.coefficientDialog = false
            this.loadCoefficients()
          })
          .catch(err => this.showError(err, '保存失败'))
          .finally(() => { this.saving = false })
      })
    },
    removeCoefficient(row) {
      this.$confirm('确定删除该客运系数吗?', '删除确认', { type: 'warning' })
        .then(() => api.post('/holiday/config/coefficients/delete', null, { params: { id: row.id } }))
        .then(res => {
          if (res.code !== 200) throw new Error(res.message || '删除失败')
          this.$message.success('已删除')
          this.loadCoefficients()
        })
        .catch(err => { if (err !== 'cancel') this.showError(err, '删除失败') })
    },
    formatCoefficient(value) {
      if (value === null || value === undefined || value === '') return '—'
      const number = Number(value)
      if (!Number.isFinite(number)) return value
      return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
    },
    showError(err, fallback) {
      if (err && err._toast) return
      const data = err && err.response && err.response.data
      this.$message.error((data && data.message) || (err && err.message) || fallback)
    }
  }
}
</script>
<style scoped>
.toolbar { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
.danger-text { color: #f56c6c; }
</style>
traffic-audit-web/src/views/UserManage.vue
@@ -10,8 +10,13 @@
          <el-menu-item index="/explain">企业解释</el-menu-item>
          <el-menu-item index="/audit">审核结果</el-menu-item>
          <el-menu-item index="/report">报表生成</el-menu-item>
          <el-submenu index="/special-stats">
            <template slot="title">专项统计</template>
            <el-menu-item index="/holiday-stat">节假日</el-menu-item>
          </el-submenu>
          <el-menu-item index="/rules">规则管理</el-menu-item>
          <el-menu-item index="/users">用户管理</el-menu-item>
          <el-menu-item v-if="operatorIsAdmin" index="/system-config">系统配置</el-menu-item>
        </el-menu>
      </el-aside>
      <el-main>