37f52097847f9025589c79034be2454432c55433..f329f504040f3608478c687a99aa36a3cf5720b7
4 天以前 xyc
feat(observation): 新增观测站分日调查数据导入与明细查看
f329f5 对比 | 目录
4 天以前 xyc
fix(holiday): 快报累计正文单位由亿人次改为万人次
1b997c 对比 | 目录
4 天以前 xyc
fix(holiday): 快报民航累计同比只与往年已覆盖日期比较
8b8971 对比 | 目录
4 天以前 xyc
fix(holiday): 快报表格改用带符号百分比并保留模板样式
520bdc 对比 | 目录
4 天以前 xyc
feat(holiday): expose 2019 holiday configuration
cdccc9 对比 | 目录
4 天以前 xyc
feat(holiday): support 2019 and previous-day data in new templates
67cd94 对比 | 目录
4 天以前 xyc
docs(交接): 记录服务器重启后开机自启实测通过
b997ab 对比 | 目录
4 天以前 xyc
docs(交接): 记录服务已成功安装到 Windows 服务器及待确认项
c83480 对比 | 目录
4 天以前 xyc
fix(部署): 修复 install-service.ps1 密码解析崩溃(Trim 参数类型错误)并加 trap 友好报错
010571 对比 | 目录
4 天以前 xyc
docs(交接): 记录本地发布包清理结果(仅保留 deploy 20261003-4)
a19fa6 对比 | 目录
4 天以前 xyc
feat(部署): 增加双击安装服务入口(install-service.cmd)并自动验证启动结果
a83d7b 对比 | 目录
4 天以前 xyc
feat(部署): 新增 Windows 服务方式开机自启(WinSW)并随包分发
02c811 对比 | 目录
4 天以前 xyc
fix(部署): pack-deploy 补齐外置配置/启动脚本/docs/web 并修正模板端口(8090)
89495b 对比 | 目录
4 天以前 xyc
docs(交接): 记录发布构建产物与打包工具待修点
80cfff 对比 | 目录
16个文件已添加
23个文件已修改
2510 ■■■■■ 已修改文件
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/README-部署说明.txt 97 ●●●●● 补丁 | 查看 | 原始文档 | 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 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/start-backend.sh 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/backend/trafficAudit.xml 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_holiday_previous_day_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/db/sql_observation_station.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
deploy-templates/jar-application.yml 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/database.md 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/init.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_holiday_previous_day_migration.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql_observation_station.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/备份/2026年十一数据.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx 补丁 | 查看 | 原始文档 | blame | 历史
docs/打包部署说明.md 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pack-deploy.ps1 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java 227 ●●●●● 补丁 | 查看 | 原始文档 | 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/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 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/resources/application.yml.example 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/HolidayStat.vue 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/SystemConfig.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | 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
HANDOFF.md
@@ -4,6 +4,113 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= æœ¬åœ° commit + æŽ¨é€åˆ°è¿œç«¯ git(origin main,Gitblit),不再只做本地 commit。
## æœ€æ–°ï¼ˆ2026-10-03)
### 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 ä½å°æ•°å£å¾„不变。
- **「暂缺民航」提示**:用户确认由人工在生成的快报稿上修改,系统不生成该提示行,**不做代码改动**。
- **验证**:`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 ä½å°æ•°ï¼‰è¾“出累计值;同比仍为「增长/下降 + ä¸€ä½å°æ•°ã€ï¼Œ**保持一次取整不变**(总计累计同比 2025 ç²¾ç¡® -4.4486% â†’ `-4.4%`;手工稿的 -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 å¿«æŠ¥ä¸‹è½½ã€‚
@@ -406,6 +513,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})
========================================================
【服务器上只要做这 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  åŽç«¯ç¨‹åºï¼ˆå«å…¨éƒ¨ä¾èµ–,**已内嵌前端**)
    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 é…ç½®ç¤ºä¾‹
@@ -18,6 +39,8 @@
    sql_holiday.sql                          ä¸“项统计-重要节假日表(旧库缺表时执行)
    sql_holiday_decimal_migration.sql        èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
    sql_observation_station.sql               è§‚测站分日调查数据表(新功能,旧库缺表时执行)
  README-部署说明.txt
【部署机环境要求】
@@ -26,13 +49,28 @@
  **不需要 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 åŠä»¥ä¸Šã€‚
【部署步骤】
  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 å·²å«èŠ‚å‡æ—¥ 4 å¼ è¡¨ï¼›è‹¥æ˜¯åœ¨æ—©æœŸç‰ˆæœ¬åº“上补节假日模块,再执行 db/sql_holiday.sql,并按需执行两个迁移脚本。
     æ³¨ï¼šå…¨æ–°åº“çš„ init.sql å·²å«èŠ‚å‡æ—¥ 4 å¼ è¡¨ï¼›è‹¥æ˜¯åœ¨æ—©æœŸç‰ˆæœ¬åº“上补节假日模块,再执行 db/sql_holiday.sql,并按需执行三个迁移脚本(数值精度、环比基期、前一日配置)。前一日迁移脚本可重复执行。
     é¢„置账号:admin / admin123(首次登录后请改密码)
  2. æ”¹é…ç½® backend/application.yml
@@ -41,10 +79,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  ï¼ˆå‰ç«¯å·²å†…嵌,页面与接口同源)
@@ -58,6 +133,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》」:
@@ -65,7 +144,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-1.0.0-SNAPSHOT.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-<version>.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-1.0.0-SNAPSHOT.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,6 +7,16 @@
  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)
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
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-1.0.0-SNAPSHOT.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
@@ -632,7 +632,7 @@
  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 '第几日,从 1 å¼€å§‹',
  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),
deploy-templates/db/sql_holiday.sql
@@ -4,6 +4,7 @@
  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,
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_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/database.md
@@ -491,7 +491,7 @@
## ä¹ã€ä¸“项统计-重要节假日模块
> æ•°æ®æ¥æºä¸Žæ¨¡æ¿ï¼š`docs/专项统计/重要节假日/2026年十一数据.xlsx`;快报模板:`docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx`。首次启动会从工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµæ•°æ®ï¼Œå¹¶åˆå§‹åŒ– 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
> æ•°æ®æ¥æºä¸Žæ¨¡æ¿ï¼š`docs/专项统计/重要节假日/2026年十一数据.xlsx`;快报模板:`docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx`。首次启动会从工作簿初始化 2024/2025 å¹´åä¸€å®¢æµã€è½¦æµæ•°æ®ã€2024-2026 å¹´é“è·¯/民航前一日数据、2019 å¹´é“路数据,并初始化 2024-2026 å¹´å®¢è¿ç³»æ•°ã€‚
### holiday_calendar èŠ‚å‡æ—¥æ—¥æœŸé…ç½®
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
@@ -500,10 +500,10 @@
| year | int | å¹´ä»½ |
| holiday_type | varchar(32) | æ˜¥èŠ‚ SPRING_FESTIVAL / äº”一 LABOR_DAY / åä¸€ NATIONAL_DAY |
| holiday_date | date | èŠ‚å‡æ—¥å…·ä½“æ—¥æœŸ |
| day_index | int | ç¬¬å‡ æ—¥ï¼Œä»Ž 1 å¼€å§‹ |
| day_index | int | 0=前一日,1..N=假期第 N æ—¥ |
| created_at / updated_at | datetime | åˆ›å»º/更新时间 |
唯一键:`(year, holiday_type, day_index)`、`(year, holiday_type, holiday_date)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护。
唯一键:`(year, holiday_type, day_index)`、`(year, holiday_type, holiday_date)`。仅 admin å¯é€šè¿‡ç³»ç»Ÿé…ç½®èœå•维护;`day_index=0` ç”¨äºŽé“è·¯ã€æ°‘航的前一日环比基数。
### holiday_passenger_flow èŠ‚å‡æ—¥ç»¼åˆè¿è¾“è¥ä¸šæ€§å®¢æµ
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
@@ -547,6 +547,8 @@
  - 2026 å¹´æ™®é€šå›½çœé“如填写环比基期,则只影响普通国省道车流量自身环比:填写时按“当日 / çŽ¯æ¯”åŸºæœŸ - 1”,未填写时按“当日 / å‰ä¸€æ—¥ - 1”;折算人员流动量、总人员流动量等继续按 Excel åŽŸå…¬å¼è®¡ç®—ã€‚2024、2025 åŽ†å²æ•°æ®ä¸ä½¿ç”¨è¯¥å­—æ®µã€‚
  - å…¬è·¯äººå‘˜æµåŠ¨é‡ = é“路营业性客运量 + éžè¥ä¸šæ€§äººå‘˜æµåŠ¨é‡ã€‚
- æ€»äººå‘˜æµåŠ¨é‡ = å…¬è·¯ + é“è·¯ + æ°‘航 + æ°´è·¯ã€‚
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;日常只读取所选年份的当年列,历史年份仅首次初始化。
- å¯¼å…¥æŒ‰â€œå¹´ä»½ + èŠ‚å‡æ—¥åˆ†ç±»â€è¯»å– `holiday_calendar` ä¸­ `day_index>=1` çš„æ—¥æœŸï¼Œå°†å·¥ä½œç°¿â€œç¬¬N日”行覆盖写入当年客流/车流;同时读取当前年份铁路、民航的“前一日”行,写入 `day_index=0` å¯¹åº”的日期。
- æ—¥å¸¸å¯¼å…¥åªè¯»å–所选年份的当年列(道路表的 2019/2024/2025 åˆ—均不读取);2019 å¹´é“路数据和 2024/2025/2026 å¹´å‰ä¸€æ—¥é“è·¯ã€æ°‘航数据由启动初始化直接写入数据库。2019 å¹´ 10 æœˆ 1-8 æ—¥çš„ `holiday_calendar` é…ç½®ç”¨äºŽåœ¨ç³»ç»Ÿé…ç½®å’ŒèŠ‚å‡æ—¥ç»Ÿè®¡é¡µæŸ¥çœ‹è¯¥åŽ†å²åŸºçº¿ã€‚
- å¯¼å…¥å‰æ£€æŸ¥æ¯”本次最新导入日更早的历史日期是否发生变化;如有变化先不写库,返回差异清单,用户确认后才允许覆盖。
- å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºåº•稿,回填库内数据与客运系数;快报按日期生成 Word å¹¶æ›¿æ¢æ¨¡æ¿ä¸­çš„当日、累计、环比和同比内容。
- è®°å½•“前一日”的铁路、民航数据后,假期第 1 æ—¥çš„铁路、民航环比按该数据计算;公路、水路、总人员流动量在第 1 æ—¥ä»ä¸è®¡ç®—环比。
- å¯¼å‡ºä»¥æºå·¥ä½œç°¿ä¸ºåº•稿,回填库内数据与客运系数;快报按日期生成 Word,同比固定使用 2025、2024 ä¸¤å¹´ï¼Œä¸ä½¿ç”¨ 2019 æ•°æ®ã€‚
docs/init.sql
@@ -632,7 +632,7 @@
  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 '第几日,从 1 å¼€å§‹',
  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),
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_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ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/±¸·Ý/2026ÄêʮһÊý¾Ý.xlsx
Binary files differ
docs/רÏîͳ¼Æ/ÖØÒª½Ú¼ÙÈÕ/ºþ±±Ê¡¹úÇì¼ÙÆÚ×ÛºÏÔËÊä¿ÍÁ÷¿ì±¨Ä£°å.docx
Binary files differ
docs/´ò°ü²¿Êð˵Ã÷.md
@@ -55,7 +55,14 @@
│  â”œâ”€ traffic-audit-server-1.0.0-SNAPSHOT.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 é…ç½®ç¤ºä¾‹
@@ -66,7 +73,8 @@
│  â”œâ”€ sql_wyc.sql                               ç½‘约车全省总量/订单分市州表(必须执行)
│  â”œâ”€ sql_holiday.sql                           ä¸“项统计-重要节假日表(旧库补建时执行)
│  â”œâ”€ sql_holiday_decimal_migration.sql         èŠ‚å‡æ—¥æ•°å€¼åˆ—ç²¾åº¦è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â””─ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â”œâ”€ sql_holiday_comparison_base_migration.sql èŠ‚å‡æ—¥çŽ¯æ¯”åŸºæœŸå­—æ®µè¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
│  â””─ sql_holiday_previous_day_migration.sql    èŠ‚å‡æ—¥å‰ä¸€æ—¥é…ç½®è¿ç§»ï¼ˆæ—§åº“å‡çº§ï¼‰
└─ README-部署说明.txt
```
@@ -87,7 +95,7 @@
```
> âš ï¸ **`init.sql` æ˜¯ç ´åæ€§çš„**:里面的基础表都带 `DROP TABLE IF EXISTS`。**已有数据的库上千万不要再跑 init.sql**,否则表数据会被清空重建。
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日 4 å¼ è¡¨ï¼Œæ‰§è¡Œ `sql_holiday.sql`;若已有节假日表但缺精度或环比基期字段,按需执行两个迁移脚本。
> æ—©æœŸç‰ˆæœ¬åº“若没有节假日 4 å¼ è¡¨ï¼Œæ‰§è¡Œ `sql_holiday.sql`;若已有节假日表但缺精度、环比基期字段或前一日配置,按需执行 `sql_holiday_decimal_migration.sql`、`sql_holiday_comparison_base_migration.sql`、`sql_holiday_previous_day_migration.sql`。
> 4 ä¸ªè„šæœ¬å…±è¦†ç›– 33 å¼ è¡¨ï¼ˆinit.sql 28 å¼ ï¼Œå…¶ä¸­å«èŠ‚å‡æ—¥ 4 å¼  + åŸŽå¸‚客运 3 å¼  + ç½‘约车 2 å¼ ï¼‰ã€‚
#### è·¯çº¿ B:把现网/开发机的数据整套搬过去(推荐,不用跑脚本)
@@ -163,7 +171,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`。
@@ -209,7 +253,12 @@
| ç™»å½•接口 500 | `jwt.secret` å¡«äº†éžæ³•字符(如中文);改成 32 ä½åå…­è¿›åˆ¶ä¸² |
| é¡µé¢èƒ½å¼€ä½†æŽ¥å£ 404 | èµ° nginx æ–¹æ¡ˆæ—¶æ²¡é… `/api` åä»£ï¼›æ”¹ç”¨ jar å†…嵌访问 8090 |
| æ•°æ®åº“连接失败 | æ£€æŸ¥ `application.yml`;确认 5 ä¸ª SQL è„šæœ¬å·²æ‰§è¡Œ |
| å¯åŠ¨æŠ¥ `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` æ›¿æ¢ |
## åã€å¼€å‘环境日常启动
@@ -221,6 +270,7 @@
## åä¸€ã€å˜æ›´è®°å½•
- **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 æ–¹æ¡ˆï¼‰ã€‚
@@ -268,12 +318,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>`;
   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。
### è½®æ¢å¯†é’¥åŽåŒæ­¥æœ¬æœº
pack-deploy.ps1
@@ -1,34 +1,78 @@
<#
  ä¸€é”®æ‰“包部署包(含"去密自检")—— 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
  åšäº”件事:
    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 / 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]$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
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-1.0.0-SNAPSHOT.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)
@@ -40,29 +84,77 @@
  $sr.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 å†…置配置全部为占位符,无明文密钥/口令。'
# ---------- [3/5] å‰ç«¯æž„建 ----------
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)
@@ -74,9 +166,21 @@
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 '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
}
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 å†…容一致
Copy-Item $webDist (Join-Path $dest 'backend\web') -Recurse -Force
# docs:运行时模板 + ç”¨æˆ·æ–‡æ¡£ï¼›æŽ’除开发过程文档与缓存/备份(减小体积、避免把内部清单带出去)
$docsDest = Join-Path $dest 'backend\docs'
@@ -84,15 +188,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'), (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') $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_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)
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)再启动。'
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java
@@ -110,7 +110,7 @@
        options.put("transportModes", TRANSPORT_MODE_LABELS);
        options.put("roadCategories", ROAD_CATEGORY_LABELS);
        options.put("vehicleTypes", VEHICLE_TYPE_LABELS);
        options.put("years", Arrays.asList(2024, 2025, 2026));
        options.put("years", Arrays.asList(2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030));
        return options;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
@@ -1,6 +1,7 @@
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;
@@ -10,6 +11,7 @@
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;
@@ -22,12 +24,14 @@
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;
@@ -43,6 +47,8 @@
    private String bulletinTemplatePath;
    private final HolidayStatService statService;
    @Resource
    private HolidayConfigService configService;
    private static final ThreadLocal<Document> CURRENT_DOCUMENT = new ThreadLocal<>();
    public HolidayBulletinService(HolidayStatService statService) {
@@ -56,27 +62,30 @@
        }
        DailyMetrics previousDay = current.getDayIndex() > 1
                ? statService.dailyMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex() - 1) : 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 cumulativeTotalYi = formatNumber(currentCumulative.getTotalPersonFlow() == null
                ? null : HolidayNumberUtils.divide(currentCumulative.getTotalPersonFlow(), BigDecimal.valueOf(10000)));
        String ringRatioClause = previousDay == null ? "" : ",环比" + ratioPhrase(
                current.getTotalPersonFlow(), previousDay.getTotalPersonFlow());
        String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s。",
        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()));
        String paragraph2 = String.format("假期以来,全省全社会跨区域人员流动量累计%s亿人次,比2025年同期%s。",
                cumulativeTotalYi,
                ratioPhrase(currentCumulative.getTotalPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
                ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow()),
                ratioPhrase(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow()));
        String paragraph2 = cumulativeParagraph(currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
        File template = resolveTemplateFile();
        if (!template.exists() || !template.isFile()) {
@@ -94,7 +103,8 @@
            try {
                replaceParagraph(document, text -> text.startsWith("10月") && text.contains("假期第"), paragraph1);
                replaceParagraph(document, text -> text.startsWith("假期以来"), paragraph2);
                fillTable(document, current, previousDay, previousYear, currentCumulative, previousYearCumulative);
                fillTable(document, current, previousDay, previousCalendarDay, previousYear, twoYearsAgo,
                        currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
                byte[] replacedXml = serializeDocument(document);
                return replaceZipEntry(templateBytes, "word/document.xml", replacedXml);
            } finally {
@@ -106,64 +116,91 @@
    }
    private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay,
                           DailyMetrics previousYear, DailyMetrics currentCumulative,
                           DailyMetrics previousYearCumulative) {
                           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()),
                ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow()),
                "—",
                ratio(current.getNonBusinessPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getNonBusinessPersonFlow()),
                number(currentCumulative.getNonBusinessPersonFlow()),
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow()),
                "—"
                ratio(currentCumulative.getNonBusinessPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getNonBusinessPersonFlow())
        });
        writeTableRow(table, 3, new String[]{
                number(current.getRoad()),
                ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad()),
                ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad()),
                "—",
                ratio(current.getRoad(), twoYearsAgo == null ? null : twoYearsAgo.getRoad()),
                number(currentCumulative.getRoad()),
                ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad()),
                "—"
                ratio(currentCumulative.getRoad(), twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoad())
        });
        writeTableRow(table, 4, new String[]{
                number(current.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow()),
                ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow()),
                "—",
                ratio(current.getRoadPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getRoadPersonFlow()),
                number(currentCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
                "—"
                ratio(currentCumulative.getRoadPersonFlow(),
                        previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()),
                ratio(currentCumulative.getRoadPersonFlow(),
                        twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoadPersonFlow())
        });
        writeTableRow(table, 5, transportRow(current.getRail(), previousDay == null ? null : previousDay.getRail(),
                previousYear == null ? null : previousYear.getRail(), currentCumulative.getRail(),
                previousYearCumulative == null ? null : previousYearCumulative.getRail()));
        writeTableRow(table, 6, transportRow(current.getCivilAviation(), previousDay == null ? null : previousDay.getCivilAviation(),
                previousYear == null ? null : previousYear.getCivilAviation(), currentCumulative.getCivilAviation(),
                previousYearCumulative == null ? null : previousYearCumulative.getCivilAviation()));
        writeTableRow(table, 7, transportRow(current.getWater(), previousDay == null ? null : previousDay.getWater(),
                previousYear == null ? null : previousYear.getWater(), currentCumulative.getWater(),
                previousYearCumulative == null ? null : previousYearCumulative.getWater()));
        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
                previousYear == null ? null : previousYear.getTotalPersonFlow(), currentCumulative.getTotalPersonFlow(),
                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()));
        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()));
        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()));
        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()));
        writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(),
                previousDay == null ? null : previousDay.getTotalPersonFlow(),
                previousYear == null ? null : previousYear.getTotalPersonFlow(),
                twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(),
                currentCumulative.getTotalPersonFlow(),
                previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
                twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow()));
    }
    private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
                                  BigDecimal currentCumulative, BigDecimal previousYearCumulative) {
                                  BigDecimal twoYearsAgo, BigDecimal currentCumulative,
                                  BigDecimal previousYearCumulative, BigDecimal twoYearsAgoCumulative) {
        return new String[]{
                number(current),
                ratio(current, previousDay),
                ratio(current, previousYear),
                "—",
                ratio(current, twoYearsAgo),
                number(currentCumulative),
                ratio(currentCumulative, previousYearCumulative),
                "—"
                ratio(currentCumulative, twoYearsAgoCumulative)
        };
    }
@@ -175,7 +212,7 @@
        int logicalColumn = 0;
        for (Element cell : cells) {
            int span = gridSpan(cell);
            if (logicalColumn >= 2 && logicalColumn < 9) {
            if (logicalColumn >= 2) {
                int valueIndex = logicalColumn - 2;
                if (valueIndex < values.length) {
                    setCellText(cell, values[valueIndex]);
@@ -212,7 +249,10 @@
            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);
@@ -290,9 +330,39 @@
        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(total),
                ratioPhrase(total, previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow()),
                ratioPhrase(total, twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow()));
    }
    private String ratio(BigDecimal current, BigDecimal base) {
        if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "—";
        return ratioPhrase(current, base);
        BigDecimal ratio = HolidayNumberUtils.divide(current, base);
        if (ratio == null) return "—";
        BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
        if (percent == null) return "—";
        BigDecimal rounded = percent.setScale(1, RoundingMode.HALF_UP);
        return rounded.signum() == 0 ? "0%" : rounded.toPlainString() + "%";
    }
    private String number(BigDecimal value) {
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java
@@ -39,7 +39,16 @@
        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) {
@@ -65,8 +74,8 @@
        if (holidayDate == null || holidayDate.getYear() != year) {
            throw new IllegalArgumentException("日期必须属于所选年份");
        }
        if (dayIndex == null || dayIndex < 1) {
            throw new IllegalArgumentException("第几日必须从 1 å¼€å§‹");
        if (dayIndex == null || dayIndex < 0) {
            throw new IllegalArgumentException("日期序号不能小于 0");
        }
        LambdaQueryWrapper<HolidayCalendar> duplicate = new LambdaQueryWrapper<HolidayCalendar>()
                .eq(HolidayCalendar::getYear, year)
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
@@ -138,16 +138,24 @@
            }
        }
        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);
        Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(target.getHolidayDate(), Collections.emptyMap());
        Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(target.getHolidayDate(), Collections.emptyMap());
        Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(date, Collections.emptyMap());
        Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(date, Collections.emptyMap());
        BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
        BigDecimal expressway = vehicle.get(HolidayConstants.ROAD_EXPRESSWAY);
        BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
        BigDecimal comparisonBase = comparisonBaseByDate.get(target.getHolidayDate());
        BigDecimal comparisonBase = comparisonBaseByDate.get(date);
        BigDecimal nonBusiness = null;
        if (coefficient != null && (expressway != null || nationalProvincial != null)) {
            nonBusiness = HolidayNumberUtils.multiply(
@@ -158,7 +166,7 @@
        metrics.setYear(year);
        metrics.setHolidayType(holidayType);
        metrics.setDayIndex(dayIndex);
        metrics.setDate(target.getHolidayDate());
        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));
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
@@ -46,6 +46,7 @@
    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 {
@@ -115,7 +116,7 @@
        ImportSummary summary = new ImportSummary();
        try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
            FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
            importPassengerSheets(workbook, evaluator, year, dateByDay, summary);
            importPassengerSheets(workbook, evaluator, year, holidayType, dateByDay, summary);
            importVehicleSheets(workbook, evaluator, year, dateByDay, summary);
        } catch (Exception e) {
            throw new IllegalArgumentException("工作簿解析失败: " + e.getMessage(), e);
@@ -161,7 +162,7 @@
            if (sheet == null) continue;
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
            Row row = findDayRow(sheet, evaluator, dayIndex);
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) return true;
@@ -174,7 +175,7 @@
        if (expressway != null) {
            Integer yearColumn = findYearColumn(expressway, evaluator, year, 2, 7);
            if (yearColumn != null) {
                Row row = expressway.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
                Row row = findDayRow(expressway, evaluator, dayIndex);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
@@ -182,7 +183,7 @@
        if (nationalProvincial != null) {
            Integer yearColumn = findYearColumn(nationalProvincial, evaluator, year, 3, 8);
            if (yearColumn != null) {
                Row row = nationalProvincial.getRow(FIRST_DATA_ROW_INDEX + dayIndex - 1);
                Row row = findDayRow(nationalProvincial, evaluator, dayIndex);
                if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
            }
        }
@@ -200,7 +201,7 @@
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                if (day.getKey() >= latestDay) continue;
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) continue;
                BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
                if (incoming == null) continue;
@@ -236,7 +237,7 @@
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            if (day.getKey() >= latestDay) continue;
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
            if (incoming == null) continue;
@@ -261,7 +262,7 @@
            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 = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal incoming;
            try {
@@ -278,8 +279,8 @@
        }
    }
        Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
    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) {
@@ -337,6 +338,7 @@
        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) {
@@ -351,6 +353,8 @@
                    seedCoefficientYear(workbook, evaluator, year);
                }
            }
            seedPreviousDayPassengerIfMissing(workbook, evaluator);
            seedRoad2019IfMissing(workbook, evaluator);
        } catch (Exception e) {
            log.error("Failed to seed holiday reference data", e);
        }
@@ -381,7 +385,8 @@
    }
    private void importPassengerSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
                                       Map<Integer, LocalDate> dateByDay, ImportSummary summary) {
                                       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) {
@@ -394,7 +399,7 @@
                continue;
            }
            for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                Row row = findDayRow(sheet, evaluator, day.getKey());
                if (row == null) {
                    summary.addError(entry.getKey() + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                    continue;
@@ -418,6 +423,39 @@
                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();
        }
    }
@@ -445,7 +483,7 @@
            return;
        }
        for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) {
                summary.addError(sheetName + " ç¬¬" + day.getKey() + "日:工作表缺少该行");
                continue;
@@ -491,7 +529,17 @@
                Map<LocalDate, BigDecimal> values = loadPassengerValues(year, entry.getValue());
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!values.containsKey(day.getValue())) continue;
                    setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, yearColumn, values.get(day.getValue()));
                    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);
                    }
                }
            }
        }
@@ -515,17 +563,23 @@
            Map<LocalDate, BigDecimal> values = loadVehicleValues(year, roadCategory);
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                if (!values.containsKey(day.getValue())) continue;
                setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, yearColumn, values.get(day.getValue()));
                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)) {
                Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    setNumeric(sheet, FIRST_DATA_ROW_INDEX + day.getKey() - 1, comparisonBaseColumn, bases.get(day.getValue()));
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    setNumeric(sheet, row.getRowNum(), comparisonBaseColumn, bases.get(day.getValue()));
                }
                for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                    if (!bases.containsKey(day.getValue())) continue;
                    int rowIndex = FIRST_DATA_ROW_INDEX + day.getKey() - 1;
                    Row row = findDayRow(sheet, evaluator, day.getKey());
                    if (row == null) continue;
                    int rowIndex = row.getRowNum();
                    int excelRow = rowIndex + 1;
                    setFormula(sheet, rowIndex, 4, "=(D" + excelRow + "-C" + excelRow + ")/C" + excelRow);
                }
@@ -560,9 +614,10 @@
        if (!configService.listHolidays(null, null).isEmpty()) {
            return;
        }
        int[] lengths = {7, 8, 8};
        for (int i = 0; i < REFERENCE_YEARS.length; i++) {
            int year = REFERENCE_YEARS[i];
        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) {
@@ -579,6 +634,87 @@
                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);
            }
        }
    }
@@ -591,11 +727,22 @@
            Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
            if (yearColumn == null) continue;
            for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
                Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
                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);
                    }
                }
            }
        }
@@ -618,7 +765,7 @@
        Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
        if (yearColumn == null) return;
        for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
            Row row = sheet.getRow(FIRST_DATA_ROW_INDEX + day.getKey() - 1);
            Row row = findDayRow(sheet, evaluator, day.getKey());
            if (row == null) continue;
            BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
            if (value != null) {
@@ -659,6 +806,46 @@
        }
    }
    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)) {
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/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,19 @@
package com.trafficaudit.observation.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.trafficaudit.observation.dto.ObservationDateSummary;
import com.trafficaudit.observation.entity.ObservationStationFlow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@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();
}
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/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,117 @@
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"));
        assertEquals("1.0%", ratio(service, "101", "100"));
        assertEquals("0%", ratio(service, "100", "100"));
        assertEquals("0%", ratio(service, "10004", "10000"));
        assertEquals("—", ratio(service, "100", null));
        assertEquals("—", ratio(service, "100", "0"));
    }
    @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 cumulativeParagraphUsesWanUnitAndSingleRounding() 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.4%,比2024年同期下降1.6%。",
                text);
    }
    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) throws Exception {
        Method method = HolidayBulletinService.class.getDeclaredMethod("ratio", BigDecimal.class, BigDecimal.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);
    }
}
traffic-audit-web/src/views/HolidayStat.vue
@@ -77,6 +77,74 @@
              <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>
              <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>
@@ -92,7 +160,7 @@
  name: 'HolidayStat',
  data() {
    return {
      years: [2024, 2025, 2026, 2027, 2028, 2029, 2030],
      years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
      year: 2026,
      holidayType: 'NATIONAL_DAY',
      holidayTypeOptions: [],
@@ -105,7 +173,17 @@
      fileList: [],
      importMessage: '',
      allowHistoricalOverwrite: false,
      bulletinDate: this.previousDateString()
      bulletinDate: this.previousDateString(),
      observationFile: null,
      observationFileList: [],
      observationImporting: false,
      observationLoading: false,
      observationMessage: '',
      observationDates: [],
      observationDialogVisible: false,
      observationDialogDate: '',
      observationRows: [],
      observationRowsLoading: false
    }
  },
  computed: {
@@ -117,6 +195,7 @@
  mounted() {
    this.loadOptions()
    this.loadOverview()
    this.loadObservationDates()
  },
  methods: {
    previousDateString() {
@@ -271,9 +350,95 @@
      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, '删除失败') })
    },
    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 '—'
@@ -299,4 +464,6 @@
.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/SystemConfig.vue
@@ -40,7 +40,9 @@
                  <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 prop="dayIndex" label="第几日" width="100" 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>
@@ -93,8 +95,11 @@
              <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-input-number v-model="holidayForm.dayIndex" :min="1" :max="31" style="width:100%"></el-input-number>
            <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">
@@ -139,7 +144,7 @@
  data() {
    return {
      activeTab: 'holidays',
      years: [2024, 2025, 2026, 2027, 2028, 2029, 2030],
      years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
      holidayTypeOptions: [],
      dateCategoryOptions: [],
      holidayQuery: { year: 2026, holidayType: 'NATIONAL_DAY' },
@@ -157,7 +162,7 @@
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
        holidayType: [{ required: true, message: '请选择节假日分类', trigger: 'change' }],
        holidayDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
        dayIndex: [{ required: true, message: '请输入第几日', trigger: 'change' }]
        dayIndex: [{ required: true, message: '请选择日期类型', trigger: 'change' }]
      },
      coefficientRules: {
        year: [{ required: true, message: '请选择年份', trigger: 'change' }],
@@ -172,6 +177,10 @@
    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 || {}