From bee9e18c3af284ff432a4472f1d409f5a6dc09a7 Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期二, 06 十月 2026 16:59:15 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/main'
---
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java | 83
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java | 85
docs/sql_holiday.sql | 66
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java | 22
traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java | 51
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java | 181 +
deploy-templates/backend/install-autostart.ps1 | 109
traffic-audit-web/src/components/AppHeader.vue | 29
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java | 369 ++
docs/Excel浮点数存储.txt | 152
deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql | 14
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java | 23
docs/database.md | 65
deploy-templates/backend/trafficAudit.xml | 45
deploy-templates/db/sql_holiday_comparison_base_migration.sql | 6
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java | 15
deploy-templates/db/sql_holiday.sql | 67
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java | 9
docs/sql_holiday_vehicle_adjustment_migration.sql | 14
pack-deploy.ps1 | 237 +
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java | 554 +++
traffic-audit-web/src/views/Dashboard.vue | 8
deploy-templates/db/sql_holiday_previous_day_migration.sql | 16
traffic-audit-web/src/router/index.js | 10
traffic-audit-web/src/views/DataImport.vue | 7
deploy-templates/db/init.sql | 83
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java | 1083 ++++++
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java | 9
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java | 141
docs/sql_holiday_decimal_migration.sql | 16
docs/sql_holiday_previous_day_migration.sql | 16
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java | 535 +++
docs/专项统计/重要节假日/2026年十一数据.xlsx | 0
docs/专项统计/重要节假日/备份/湖北省国庆假期综合运输客流快报模板.docx | 0
deploy-templates/backend/start-backend-service.cmd | 49
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java | 116
traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java | 350 ++
traffic-audit-web/src/views/RuleManage.vue | 5
traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java | 18
traffic-audit-web/src/views/Login.vue | 12
traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java | 70
AGENTS.md | 6
deploy-templates/backend/install-service.cmd | 49
docs/专项统计/重要节假日/备份/2026年十一数据.xlsx | 0
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java | 26
traffic-audit-web/src/views/DataView.vue | 7
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java | 71
traffic-audit-web/src/views/AuditResult.vue | 7
deploy-templates/db/sql_holiday_decimal_migration.sql | 16
docs/专项统计/重要节假日/2026年十一假期全省综合运输客流量走势图.docx | 0
traffic-audit-web/src/views/ReportExport.vue | 5
deploy-templates/backend/install-service.ps1 | 330 +
.gitignore | 3
deploy-templates/db/sql_observation_station.sql | 39
docs/sql_holiday_comparison_base_migration.sql | 6
deploy-templates/backend/start-backend.sh | 8
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java | 450 ++
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java | 28
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java | 95
deploy-templates/backend/start-backend.cmd | 12
deploy-templates/jar-application.yml | 94
HANDOFF.md | 377 ++
deploy-templates/README-部署说明.txt | 114
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java | 211 +
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java | 5
traffic-audit-web/src/views/HolidayStat.vue | 690 ++++
traffic-audit-web/src/main.js | 5
traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java | 31
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java | 21
traffic-audit-web/src/views/UserManage.vue | 5
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java | 4
deploy-templates/backend/application.yml | 8
traffic-audit-server/src/main/resources/application.yml.example | 7
traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java | 65
docs/init.sql | 81
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java | 143
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java | 1186 +++++++
docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx | 0
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java | 9
traffic-audit-web/src/views/SystemConfig.vue | 304 +
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java | 9
traffic-audit-server/src/main/resources/app-version.properties | 3
traffic-audit-web/src/views/ExplainReview.vue | 7
traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java | 98
docs/sql_observation_station.sql | 39
docs/打包部署说明.md | 132
traffic-audit-server/pom.xml | 36
traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java | 21
traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java | 9
package-deploy.py | 23
traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java | 136
traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java | 177 +
92 files changed, 9,855 insertions(+), 93 deletions(-)
diff --git a/.gitignore b/.gitignore
index 07483f0..0e06315 100644
--- a/.gitignore
+++ b/.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
diff --git a/AGENTS.md b/AGENTS.md
index b497aa7..eb42587 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,6 +33,12 @@
## 楠岃瘉娉ㄦ剰
- 娌欑鍐� `Get-NetTCPConnection` 浼氬洜鏉冮檺鈥滄嫆缁濊闂�濊�岄潤榛樺け璐ワ紝**涓嶈**鎹鍒ゅ畾绔彛鏈洃鍚紱鍒ゆ柇鏈嶅姟鐘舵�佷互 backend.log / frontend.log 涓哄噯銆�
- 鐢ㄦ埛瑕佹眰锛氫复鏃惰剼鏈�/鏂囦欢鏈粡鍚屾剰涓嶅垹锛涘鍏ュけ璐ヨ鍦ㄩ〉闈㈡樉绀鸿鍙凤紱AI 鍒嗘瀽缁撴灉瑕佺粰缁撴瀯鍖栨帓鐗� + 閫愬瓧杈撳嚭鍔ㄧ敾銆�
+- **鏁版嵁瀵煎叆鏉冮檺锛�2026-10-04 鐢ㄦ埛鏄庣‘锛�**锛氶櫎闈炵敤鎴锋槑纭姹傚姪鎵嬪鍏ワ紝鍚﹀垯涓嶈鎵ц浠讳綍鏁版嵁瀵煎叆銆佽鐩栨垨閲嶅鎿嶄綔锛涚敤鎴疯嚜琛屽鍏ョ殑鏁版嵁榛樿瑙嗕负宸插畬鎴愶紝鍔╂墜鍙仛鍙鏍稿銆�
+
+## 閲嶈鑺傚亣鏃ュ揩鎶ヤ笌鍒嗘瀽鎶ュ憡鍙e緞锛�2026-10-04 璧凤級
+- 蹇姤鐧惧垎姣斿繀椤诲厛鎸夊鍏ョ殑 `2026骞村崄涓�鏁版嵁.xlsx` 瀵瑰簲鍗曞厓鏍肩殑鏁板瓧鏍煎紡鐢熸垚鏄剧ず鍊硷紝鍐嶆寜蹇姤缁熶竴 1 浣嶅皬鏁板彇鏁淬�傚綋鍓嶆簮琛ㄤ笉鏄叏灞�鍚屼竴绮惧害锛氬鏁板崟鍏冩牸鏄� `0.0%`锛沗浜哄憳娴佸姩閲忔眹鎬籤 鐨勫綋鏃ョ幆姣� D/P/J銆佺疮璁″悓姣� V/W/Y/Z/AB/AC锛岄亾璺� `H`锛堝悓姣�2024骞达級锛屼互鍙婃按璺綋鏃ョ幆姣� D 鏄� `0.00%`銆�
+- `浜哄憳娴佸姩閲忔眹鎬籤 鐨勭疮璁″�� U/X/AA 瀛樼殑鏄嚎鍗曚綅锛屽崟鍏冩牸鏍煎紡涓� `0.0000000_`锛涘揩鎶ュ繀椤诲湪鎹㈢畻涓囦汉娆″墠鍏堟寜浜垮崟浣� 7 浣嶅皬鏁扮敓鎴愭樉绀哄�硷紝鍐嶄箻 10000 骞舵寜蹇姤 2 浣嶈緭鍑恒�傚搴旀�昏銆佸叕璺悎璁°�侀潪钀ヤ笟鎬у皬瀹㈣溅涓夐」銆�
+- 鏍稿棰嗗鍒嗘瀽鎶ュ憡鏃讹紝鎸夎鍒嗘瀽鎶ュ憡鑷韩鏄剧ず鐨勫皬鏁颁綅鏍稿锛屼笉濂楃敤蹇姤鐨� 1 浣嶅皬鏁板彛寰勶紱鐩爣鏄鐜颁汉宸ュ湪鏂囨。/Excel 涓婄湅鍒扮殑鍊笺��
## 宸ヤ綔瑙勮寖锛�2026-09-02 璧凤紱2026-09-27 鎸夊悓浜嬫彁浜ゆ柟寮忚皟鏁达級
diff --git a/HANDOFF.md b/HANDOFF.md
index 40fe235..09a51a1 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -4,6 +4,378 @@
> **姣忓ぉ缁撴潫鏃�**锛氭妸褰撳ぉ杩涘睍銆佽俯杩囩殑鍧戙�佹柊闇�姹傘�佹湭瀹屾垚浜嬮」鏇存柊鍒版湰鏂囦欢锛岀劧鍚庡彲浠ユ斁蹇冨紑鏂板璇濄��
> **鍗忎綔绾﹀畾锛�2026-08-31锛�**锛氳銆屾彁浜ゃ��= 鏈湴 commit + 鎺ㄩ�佸埌杩滅 git锛坥rigin main锛孏itblit锛夛紝涓嶅啀鍙仛鏈湴 commit銆�
+## 鏈�鏂帮紙2026-10-06锛�
+
+### 10-06锛氭姌绾垮浘绾靛悜闂磋窛鍔犻珮锛堥獙鏀堕�氳繃锛�
+
+- **鐢ㄦ埛鑳屾櫙**锛氬杩愰噺鍥剧嚎闂磋窛杈冨ソ銆佹爣绛炬憜鏀惧悎鐞嗭紱鍏朵粬澶氬簭鍒楁姌绾垮浘鍥犵嚎璺濆亸杩戯紝鏍囩浠嶅彲鑳藉帇绾裤�傜敤鎴峰悓鎰忛�傚綋澧炲姞鍥鹃珮锛學ord 鍙槸鍙栧浘鏉ユ簮锛屼笉瑕佹眰褰撳墠鍒嗛〉涓烘渶缁堢増锛屼絾鍥句笉鑳借繃楂樸��
+- **瀹炵幇**锛�
+ - `HolidayTrendChartService`锛氭姌绾跨旱杞寸暀鐧芥敹绱т负 `lowerMargin=0.04 / upperMargin=0.07`锛沗LINE_CHART_HEIGHT_SCALE=1.20`锛屾墍鏈夋姌绾� PNG 楂樺害鏀惧ぇ绾� 20%銆�
+ - `HolidayTrendService`锛�12 寮犳姌绾垮浘鐨� Word 鍐呭祵鍥剧墖楂樺害鍚屾鏀惧ぇ绾� 20%锛屼繚鎸� PNG 涓� Word 鍥剧墖妗嗘瘮渚嬩竴鑷达紱楗煎浘銆佸爢鍙犲浘鏈敼銆�
+ - `HolidayTrendChartServiceTest`锛氭柊澧炴姌绾垮浘楂樺害缂╂斁鏂█銆�
+- **楠屾敹**锛氱敤鎴蜂簬 2026-10-06 鏌ョ湅 `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堟姌绾垮姞楂�20瀹為獙锛�.docx` 鍚庨獙鏀堕�氳繃銆�
+- **楠岃瘉缁撴灉**锛�
+ 1. 15% 鍔犻珮鐗堝湪閾佽矾瀹㈣繍閲忓浘 `image5` 妫�鍑� OCR 鏍囩妗嗛噸鍙狅紱20% 鐗� 12 寮犳姌绾垮浘锛坄image1/2/4/5/6/7/8/9/10/11/14/15`锛夋湭妫�鍑烘爣绛炬閲嶅彔銆�
+ 2. PNG 瀹介珮姣斾笌 Word `wp:extent` 瀹介珮姣旀渶澶ц宸害 `0.162%`锛屾湭鍙戠敓鏄庢樉鎷変几銆�
+ 3. Word 浠嶄负 8 椤点�佹瘡椤� 2 寮犲浘锛堥椤� 1 寮狅級锛屼笌 15% 鍔犻珮瀹為獙鐩稿悓銆�
+ 4. 鍚庣鍏ㄩ噺 `mvn -q test -f traffic-audit-server\pom.xml` 閫氳繃锛沗HolidayTrendChartServiceTest` 鑱氱劍娴嬭瘯閫氳繃銆�
+ 5. 鏍囩浜插拰搴︽鏌ヨ褰曞埌 3 澶勬爣绛炬鏇撮潬杩戦偦杩戠嚎锛坄image1` 鐨� `1,518.02`銆乣image4` 鐨� `1,346.17`銆乣image9` 鐨� `248.65`锛夛紱鐧芥弿杈逛繚璇佸彲璇伙紝鐢ㄦ埛鏁翠綋楠屾敹閫氳繃骞朵繚鐣欒鎺掔増绛栫暐銆�
+ 6. `image13` 鍫嗗彔鍥炬湁 4 澶� OCR 鏍囩閲嶅彔锛屼絾璇ュ浘鏈弬涓庢湰瀹為獙涓斾唬鐮佹湭鏀广��
+- **鏁版嵁杈圭晫**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍涓氬姟鏁版嵁锛涗粎鏈湴鍙鐢熸垚璧板娍鍥惧仛瀹為獙銆�
+
+### 10-06锛氬钩鍧囨嫢鎸ゅ害鏍囩鎭㈠涓� 2 浣嶅皬鏁�
+
+- **璋冩暣**锛氭寜鐢ㄦ埛瑕佹眰锛屸�滄櫘閫氬浗鐪侀亾瑙傛祴绔欏钩鍧囨嫢鎸ゅ害鈥濅笉鍐嶅崟鐙娇鐢� 3 浣嶅皬鏁帮紝鎭㈠涓烘姌绾垮浘榛樿 2 浣嶃��
+- **涓�鑷存��**锛氱敱浜庢姌绾垮浘鐜板凡缁熶竴鎸夋樉绀烘爣绛剧簿搴︾粯鍒讹紝10-04銆�10-05 鐨勬嫢鎸ゅ害閮戒細鏄剧ず骞剁粯鍒朵负 `0.49`锛屼笉浼氬啀鍑虹幇鍚屾爣绛句笉鍚岄珮搴︺��
+- **楠岃瘉**锛氬悗绔叏閲� `mvn -q test -f traffic-audit-server\pom.xml` 閫氳繃銆�
+
+### 10-06锛氭姌绾垮浘缁熶竴鎸夋樉绀烘爣绛剧簿搴︾粯鍒�
+
+- **鍙e緞锛堢敤鎴� 2026-10-06 纭锛�**锛氬悓涓�寮犳姌绾垮浘涓紝鍚屼竴涓樉绀烘爣绛惧繀椤诲搴斿悓涓�涓偣浣嶉珮搴︼紱鍒嗘瀽鏂囧瓧涓嶅緱寮曠敤姣旇鍥炬樉绀虹簿搴︽洿缁嗙殑宸紓銆�
+- **瀹炵幇**锛歚HolidayTrendChartService.lineChart` 鍦ㄦ墍鏈夊師濮嬬粺璁¤绠楀畬鎴愬悗锛屼簬鏈�缁堝叆鍥捐竟鐣岃皟鐢� `displayValue`锛涙櫘閫氬浘鎸夎鍥� `valueDecimals` 鍋� `HALF_UP` 鑸嶅叆锛岀櫨鍒嗘瘮鐐规爣绛炬寜 1 浣嶇櫨鍒嗘瘮绮惧害锛堝嵆鍘熷灏忔暟 3 浣嶏級鑸嶅叆銆傛姌绾垮潗鏍囧拰鐐规爣绛惧叡鐢ㄥ悓涓�鑸嶅叆鍊硷紝绾佃酱鏍煎紡娌跨敤璇ュ浘绮惧害銆�
+- **杈圭晫**锛氬彧鍦ㄦ渶缁堝浘琛ㄦ樉绀哄眰鑸嶅叆锛屼笉鏀规暟鎹簱銆佷笉鏀圭疮璁°�佸揩鎶ャ�佸悓姣斻�佺幆姣旀垨璋冩暣鍙e緞锛涜瑙勫垯鐢ㄤ簬闈欐�佸垎鏋愭姤鍛婂浘锛屼笉鑳借鏂囧瓧寮曠敤鍥句笂鏃犳硶鍒嗚鲸鐨勫井灏忓樊寮傘��
+- **楠岃瘉**锛歚HolidayTrendChartServiceTest` 鑱氱劍娴嬭瘯閫氳繃锛涘悗绔叏閲� `mvn -q test -f traffic-audit-server\pom.xml` 閫氳繃銆傛柊澧炴祴璇曡鐩栤�滀袱涓笉鍚屽師鍊兼寜 2 浣嶆爣绛捐垗鍏ュ悗鐢熸垚鐩稿悓鍑犱綍鈥濆拰鐧惧垎姣旂簿搴︽崲绠椼�傛祴璇曟姤鍛婏細`docs/鍔熻兘娴嬭瘯鎶ュ憡/鍔熻兘娴嬭瘯鎶ュ憡_鎶樼嚎鍥炬寜鏍囩绮惧害缁樺埗_2026-10-06.md`锛堟湰鍦扮暀瀛橈紝涓嶅叆搴擄級銆�
+
+### 10-06锛氳娴嬬珯骞冲潎鎷ユ尋搴﹁蛋鍔垮浘鏀逛负 3 浣嶅皬鏁�
+
+- **闂**锛氱敤鎴峰鍏� 10-05 瑙傛祴绔欐暟鎹悗鍙嶉锛屾渶鍚庝竴寮犳嫢鎸ゅ害鍥句腑 10-04銆�10-05 鏍囩閮芥樉绀� `0.49`锛屼絾鐐逛綅楂樺害涓嶅悓銆�
+- **鏍瑰洜**锛氬钩鍧囨嫢鎸ゅ害鍘熷�煎垎鍒负 `0.490890014472` 鍜� `0.492414492754`锛屾姌绾挎寜鏈彇鏁村師鍊肩粯鍒讹紝鏍囩鍘熶负鏈�澶� 2 浣嶅皬鏁帮紝瀵艰嚧鏍囩绮惧害涓庡浘褰㈢簿搴︿笉涓�鑷达紝涓嶆槸鏃ユ眹鎬绘垨鐐逛綅璁$畻閿欒銆�
+- **淇**锛歚HolidayTrendChartService.lineChart` 鏂板鍙�� `valueDecimals`锛岄粯璁や粛涓� 2锛涗粎鈥滄櫘閫氬浗鐪侀亾瑙傛祴绔欏钩鍧囨嫢鎸ゅ害鈥濆浘浼� 3 浣嶃��10-04銆�10-05 鐜板湪鍒嗗埆鏄剧ず `0.491`銆乣0.492`锛屽叾浣欏浘鍙e緞涓嶅彉銆�
+- **楠岃瘉**锛氬悗绔叏閲� `mvn -q test -f traffic-audit-server\pom.xml` 閫氳繃锛涢噸鍚悗鍙鐢熸垚鏂� DOCX锛孫CR 鏍囩涓� `0.504/0.476/0.467/0.491/0.492`锛涗笌鐢ㄦ埛鎻愪緵鐨勬枃妗i�愬浘姣斿锛�15 寮犲浘涓粎鎷ユ尋搴﹀浘 `image15` 鍙樺寲锛屽叾浠� 14 寮� SHA-256 瀹屽叏涓�鑷淬�傛祴璇曟姤鍛婏細`docs/鍔熻兘娴嬭瘯鎶ュ憡/鍔熻兘娴嬭瘯鎶ュ憡_鎷ユ尋搴﹁蛋鍔垮浘绮惧害_2026-10-06.md`锛堟湰鍦扮暀瀛橈紝涓嶅叆搴擄級銆�
+- **鏁版嵁杈圭晫**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍涓氬姟鏁版嵁銆�
+
+### 10-06锛氬線骞村浗鐪侀亾璋冩暣鍒楄〃琛ュ厖璋冩暣鍓嶅��
+
+- **鏀瑰姩**锛氳皟鏁村垪琛ㄦ帴鍙e湪鍚屼竴骞翠唤銆佸悓涓�鏃ユ湡銆佹櫘閫氬浗鐪侀亾鍙e緞涓嬶紝浠� `holiday_vehicle_flow` 琛ヨ繑鍥� `originalValue`锛涙病鏈夊師濮嬭褰曟椂淇濇寔绌猴紝椤甸潰涓嶆樉绀哄崰浣嶇銆傝〃鏍兼柊澧炩�滆皟鏁村墠鍥界渷閬撹溅娴侀噺锛堜竾杈嗘锛夆�濓紝鍘熲�滆皟鏁村悗鍥界渷閬撹溅娴佲�濇敼涓衡�滆皟鏁村悗鍥界渷閬撹溅娴侀噺鈥濄��
+- **寮圭獥**锛氭柊澧�/缂栬緫寮圭獥鍚屾澧炲姞鍙鈥滆皟鏁村墠鍥界渷閬撹溅娴侀噺锛堜竾杈嗘锛夆�濓紱缂栬緫宸叉湁璁板綍鏃跺睍绀� `originalValue`锛屾棤鍘熷�兼垨鏂伴�夋嫨鏃ユ湡鏃朵笉鏄剧ず鍐呭銆傚垏鎹㈠勾浠芥垨鏃ユ湡浼氭竻绌烘棫鍘熷�硷紝閬垮厤閿欐棩灞曠ず銆�
+- **闅忔棩鏈熷埛鏂�**锛氭柊澧炴帴鍙� `GET /api/holiday/adjustments/source-value`锛屾寜鎵�閫夊勾浠藉拰鏃ユ湡瀹炴椂璇诲彇鏅�氬浗鐪侀亾鍘熷�硷紱鏂板/缂栬緫寮圭獥閫夋棩鏈熷悗鑷姩鍒锋柊锛屾湭鏌ュ埌婧愭暟鎹椂鏄剧ず涓虹┖銆傚墠绔敤璇锋眰搴忓彿涓㈠純杩熷埌鐨勬棫璇锋眰锛岄伩鍏嶅揩閫熸崲鏃ユ湡鏃朵覆鍊笺��
+- **杈圭晫**锛歚originalValue` 鍙槸鍒楄〃灞曠ず瀛楁锛屾爣璁颁负 `@TableField(exist = false)`锛屼笉鍐欏簱銆佷笉鍙備笌璋冩暣銆佺幆姣斿垎姣嶃�佺疮璁℃垨璧板娍鍥捐绠椼��
+- **楠岃瘉**锛歚mvn -o test` 11 涓祴璇曞叏閮ㄩ�氳繃锛沗npm run build` 閫氳繃锛涙湰鍦伴噸鍚悗鍙鏍稿 2025-10-03/04/05 鍘熷�煎垎鍒负 `362.0035/337.9501/334.2040`锛屾棤婧愭暟鎹殑 2025-10-09 杩斿洖绌恒�傛祴璇曟姤鍛婏細`docs/鍔熻兘娴嬭瘯鎶ュ憡/鍔熻兘娴嬭瘯鎶ュ憡_寰�骞村浗鐪侀亾璋冩暣鍘熷�煎睍绀篲2026-10-06.md`锛堟湰鍦扮暀瀛橈紝涓嶅叆搴擄級銆�
+
+### 10-06锛氭柟妗� D 宸插疄鐜帮紙寰�骞村浗鐪侀亾璋冩暣琛� + 鐜瘮鍩烘湡鍙綔鐢ㄤ簬鍓嶄竴鏃ュ垎姣嶏級
+
+- **鏈�缁堝彛寰�**锛歚HolidayStatService.loadVehicleByDate` 鍏堢敤 `holiday_vehicle_adjustment` 瑕嗙洊瀵瑰簲鍘嗗彶骞翠唤鐨勨�滃綋鏃ュ�尖�濓紝渚涜蛋鍔垮浘銆佸悓姣斻�佺疮璁″拰蹇姤浣跨敤锛涢殢鍚庢瀯閫犫�滃墠涓�鏃ョ幆姣斿垎姣嶁�濓紝鑻ュ墠涓�鏃ユ櫘閫氬浗鐪侀亾 C 鍒楁湁鍊硷紝鍒欑敤 C 鏇挎崲璇ユ棩鍓嶄竴鏃ュ浗鐪侀亾鍊硷紝骞舵嵁姝ら噸绠楅潪钀ヤ笟鎬т汉鍛樻祦鍔ㄩ噺銆佸叕璺汉鍛樻祦鍔ㄩ噺鍜屾�昏銆傛墍鏈夌幆姣旇鍙户鎵胯繖浠藉垎姣嶃��
+- **鍏抽敭杈圭晫**锛欳 鍙奖鍝嶁�滀笅涓�鏃ョ殑鐜瘮鍒嗘瘝鈥濓紝涓嶅奖鍝� C 鎵�鍦ㄦ棩鐨勫綋鏃ュ�笺�佺疮璁℃垨璧板娍鍥撅紱璋冩暣琛ㄥ彧缁存姢鍘嗗彶骞翠唤锛坄year < 褰撳墠骞翠唤`锛夛紝2026 骞磋皟鏁翠粛鐢卞鍏� Excel 鐨� D 鍒楄繘鍏ュ綋鏃ュ�笺��
+- **鏈疆瀹炵幇**锛氭柊澧� `holiday_vehicle_adjustment` 琛ㄥ強 mapper/service/controller锛涘墠绔湪涓撻」缁熻-鑺傚亣鏃ラ〉鐨勨�滆妭鍋囨棩鏁版嵁鈥濆拰鈥滆娴嬬珯鍒嗘棩璋冩煡鏁版嵁鈥濅箣闂存柊澧炩�滃線骞村浗鐪侀亾鏁版嵁璋冩暣鈥濆崱鐗囷紙鍚庣画鎸夌敤鎴锋彁璁繚鎸佽浣嶇疆锛夛紝鏀寔骞翠唤绛涢�夈�佹柊澧炪�佺紪杈戙�佸垹闄ゅ拰鍘熷洜鐣欑棔锛涢儴缃茶剼鏈笌 migration 娓呭崟宸插悓姝ャ��
+- **10-04 瀵煎叆棰勬锛堝彧璇伙紝涓嶅啓搴擄級**锛氱敤 `C:\Users\jcxiong\Downloads\2026骞村崄涓�鏁版嵁 (2).xlsx` 浠� `allowHistoricalOverwrite=false` 璋冨鍏ユ帴鍙o紝杩斿洖纭椤癸細
+ - `绗�4鏃� 2026-10-04 鏅�氬浗鐪侀亾锛氬師鍊� 323.6551锛屽鍏ュ�� 322.7695`
+ - `绗�3鏃� 2026-10-03 鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛氬師鍊� 绌猴紝瀵煎叆鍊� 320.2418`
+ 鍥犳 C5 鐢辩┖鍙樻湁鍊间笉浼氱‖鎷掔粷瀵煎叆锛屽彧浼氳姹傚巻鍙插彉鏇寸‘璁わ紱鐢ㄦ埛纭鍚庡嵆鍙殢璋冩暣鍚庣殑 Excel 閲嶅鍐欏叆銆�
+- **褰撳墠搴撶姸鎬�**锛氭湰鍦� `traffic_audit.holiday_vehicle_adjustment` 宸叉墽琛� DDL锛�0 琛岋級锛�2026-10-04 涓氬姟鍊间粛涓烘棫鍊� `323.6551`锛岀瓑寰呯敤鎴疯嚜琛岄噸瀵艰皟鏁村悗鐨� Excel銆傚姪鎵嬫湭鎵ц浠讳綍涓氬姟鏁版嵁瀵煎叆銆佽鐩栨垨閲嶅銆�
+- **楠岃瘉**锛氬悗绔� `mvn -o test` 9 涓祴璇曞叏缁匡紙鍚� C5 鍙敼 10-04 鐜瘮鍒嗘瘝銆佸巻鍙茶皟鏁磋繘鍏� 2025 褰撴棩鍊硷級銆佸墠绔� `npm run build` 閫氳繃锛涢〉闈㈠疄娴嬧�滃線骞村浗鐪侀亾鏁版嵁璋冩暣鈥濆崱鐗囦笌鏂板寮圭獥姝e父銆�
+- **鍙栨暣鍙e緞锛堢敤鎴� 2026-10-06 纭锛�**锛氫笉鍋氣�滀粠鍘熷鍊间竴娆″彇鏁粹�濈殑澶囬�夈�傚敮涓�瑕佹眰鏄笌 Excel 鏄剧ず鍊间竴鑷达細鍏堟寜婧� Excel 鍗曞厓鏍兼牸寮忕敓鎴愭樉绀哄�硷紝鍐嶆寜蹇姤缁熶竴 1 浣嶅皬鏁般��10-04 鎬昏鐜瘮鍘熷鍊肩害 `4.5481%`锛屾簮鏍兼樉绀� `4.55%`锛屽揩鎶ュ彇 1 浣嶅緱鍒� `4.6%`锛涜繖灞炰簬鏃㈠畾鍙e緞锛屼笉鏄渶瑕佷慨姝g殑杈圭晫闂銆�
+
+> 璇存槑锛氭湰鏂囦欢鏃╁墠鈥滅幆姣斿熀鏈熷彧浣滃鐢ㄣ�佷笉鍙備笌璁$畻鈥濈殑娈佃惤宸蹭綔搴熴�傝鐗堟湰閽堝鐨勬槸鏃у疄鐜拌鎶� C 鏀惧埌 C 鎵�鍦ㄦ棩鏈韩璁$畻鐨勯棶棰橈紱鑷柟妗� D 璧凤紝C 鐨勬纭涔夋槸**鍓嶄竴鏃ョ幆姣斿垎姣嶇殑璋冩暣鍊�**銆�
+
+### 10-06锛堝凡浣滃簾锛岃涓婃柟鏂规 D锛夛細鏅�氬浗鐪侀亾銆岀幆姣斿熀鏈熴�嶆敼涓轰汉宸ュ鐢ㄥ�硷紙涓嶅啀鍙備笌璁$畻锛�
+
+- **鑳屾櫙**锛�10-04 閯傚窞妯婂搴勭珯涓皬瀹㈡祦閲忔柇宕栦笅璺岋紙35419 -> 8856锛夛紝棰嗗瑕佹眰鍓旈櫎銆傛墜宸ョ蹇姤鐨勫仛娉曟槸锛氬厛鐢ㄥ墧绔欏悗鐨勬暟鍊肩畻鍑� 10-04 鐜瘮锛屾妸 10-03 鍥界渷閬撳�兼尓鍒般�岀幆姣斿熀鏈熴�岰 鍒楀鐢紝鍐嶆妸 10-03 鎭㈠鍘熷�间互鍏嶅奖鍝嶇疮璁°�傜敤鎴锋槑纭彛寰勶細**銆岀幆姣斿熀鏈熴�嶅彧鐢ㄤ簬鏁版嵁澶囩敤锛屼笉鍙備笌浠讳綍璁$畻**銆�
+- **鏃ц涓猴紙閿欙級**锛歚HolidayStatService.nationalProvincialRingRatio` 鍦� 2026 骞村彧瑕� `comparison_base` 鏈夊�煎氨鎸夈�屽綋鏃� / 鐜瘮鍩烘湡 - 1銆嶇畻鍥界渷閬撶幆姣旓紱瀵煎嚭鏃� E 鍒楀啓 `=(D{琛寎-C{琛寎)/C{琛寎`銆傛寜鎵嬪伐绋跨殑 C 鍒楁憜娉曪紙鍩烘湡鏀惧湪琚瘮鏃ラ偅琛岋級锛岃繖鏍风畻杩樹細閿欎綅锛�10-03 鐜瘮琚畻鎴� +1.11%锛夈��
+- **鏂拌涓猴紙鏈宸叉敼锛�**锛氬浗鐪侀亾鐜瘮鍥哄畾銆屽綋鏃� / 鍓嶄竴鏃� - 1銆嶏紝2024/2025/2026 涓�鑷达紱`comparison_base` 鍙湪瀵煎叆/瀵煎嚭/椤甸潰灞曠ず鏃跺師鏍蜂繚瀛樹笌鍥炲啓锛屼笉鍙備笌鐜瘮銆佹姌绠椼�佺疮璁°�傚鍑� E 鍒楁敼涓� `=(D{琛寎-D{涓婁竴鏃ヨ})/D{涓婁竴鏃ヨ}`锛孋 鍒楃収甯稿洖鍐欏鐢ㄥ�笺��
+- **鏀瑰姩**锛歚HolidayStatService.java`锛坄nationalProvincialRingRatio` 鍘绘帀 year/comparisonBase 褰㈠弬锛夈�乣HolidayWorkbookService.java`锛堟柊澧� `ringRatioFormula`锛屽鍑烘寜涓婁竴琛屽悓骞� D 鍒楋級銆乣docs/database.md`锛坄comparison_base` 涓庤绠楀彛寰勪袱澶勶級銆�
+- **楠岃瘉**锛氭柊澧� `HolidayComparisonBaseTest`锛氣憼 搴撳唴瀛� `comparison_base=320.2418` 鏃讹紝10-04 鍥界渷閬撶幆姣斾粛鎸夊墠涓�鏃� 323.7837 璁$畻锛屼笖涓嶇瓑浜庢寜鍩烘湡绠楀嚭鐨勫�硷紱鈶� 瀵煎嚭鍏紡 = `=(D6-D5)/D5`銆俙mvn -o test` 鍏ㄧ豢锛�4 涓祴璇曠被锛夈��
+
+### 10-06锛氬緟纭鈥斺��10-04 鍥界渷閬撳墧绔欙紙閯傚窞妯婂搴勶級鎬庝箞杩涚郴缁�
+
+- **鎵嬪伐鍙e緞宸查�愰」鏍稿锛屼笌搴撳唴 `observation_station_flow` 瀹屽叏瀵瑰緱涓�**锛�
+ - 10-04 鍥界渷閬撳�硷紙2026/2025/2024锛�= 鍘熷�� 鈭� 妯婂搴勫綋鏃ュ�� -> 322.7695 / 337.5009 / 322.5277 涓囪締娆★紙妯婂搴� 8856 / 4492 / 2328锛夈��
+ - 10-04 鐜瘮鍒嗘瘝 = 2026-10-03 鍘熷�� 鈭� 妯婂搴� 10-03 鍊� = 323.7837 鈭� 3.5419 = **320.2418** 涓囪締娆� -> 鐜瘮 = 322.7695/320.2418 鈭� 1 = **+0.79%**銆�
+ - 10-03 鏄剧ず鍊间笌绱浠嶇敤鍘熷�� 323.7837锛岃鍓旂殑 320.2418 鍙惤鍒� C 鍒楀鐢ㄣ��
+- **瓒嬪娍缁撹锛堥渶鍏堣窡棰嗗瀵归綈锛�**锛氬彧鍓旀▕瀹跺簞锛�10-04 鍥界渷閬撶幆姣斿彧鏈� **+0.79%**锛涘悓鏃ラ珮閫熷皬瀹㈣溅 **+9.78%**銆侀搧璺� +9.94%銆佹皯鑸� +11.69%銆侀亾璺� +4.99%銆佹按璺� +4.33%銆�**鍓斾竴绔欏~涓嶅钩绾� 9 涓櫨鍒嗙偣鐨勮秼鍔垮樊**锛屼笌銆岃鍥界渷閬撳拰楂橀�熻秼鍔夸竴鑷淬�嶇殑棰勬湡骞朵笉鐩哥锛屽缓璁悓鏃惰閯傚窞鏍稿疄鍘熷璁板綍銆�
+- **寰呭喅绛栫殑浠g爜鏂规**锛氬洜 `comparison_base` 宸插畾涓虹函澶囩敤锛岀郴缁熸棤娉曚粠銆屽綋鏃ュ�笺�嶅弽鎺ㄥ墧绔欏熀鏈燂紝闇�瑕佸彟璁炬満鍒躲�傛帹鑽愭柊澧炪�屽紓甯哥珯鍓旈櫎銆嶉厤缃紙绔欑偣缂栧彿 + 鐢熸晥鏃ユ湡 + 骞翠唤锛夛紝绠楃幆姣斿垎姣嶆椂鐢� `observation_station_flow` 鐨勩�岃鏃ョ珯鐐瑰悎璁� 鈭� 鍓旂珯鍊笺�嶄綔鍩烘湡锛涙樉绀哄�间笌绱浠嶇敤鍘熷�笺��
+- **鍐崇瓥鐐�**锛氣憼 鍋氭垚鍙厤缃殑閫氱敤銆屽紓甯哥珯鍓旈櫎銆嶏紙鎺ㄨ崘锛屽彲澶嶇敤浜庡叾浠栬妭鍋囨棩/绔欑偣锛夛紝杩樻槸鍙寜鏈 10-04 涓�娆℃�у鐞嗭紱鈶� 鍓旂珯鍙奖鍝嶅浗鐪侀亾鐜瘮鍒嗘瘝锛岃繕鏄篃瑕佷笅娌夊埌闈炶惀涓氭�у皬瀹㈣溅/鍏矾/鍏ㄧぞ浼氱殑鐜瘮锛堟墜宸ョ瀹為檯鎶� 4.17% / 4.55% 涔熶竴璧锋敼浜嗭級銆�
+
+## 鏈�鏂帮紙2026-10-05锛�
+
+### 10-05 涓婂崍锛氬叧鏈哄揩鐓э紙褰撳墠鐘舵�侊級
+
+- **宸插畬鎴�**锛歷1.0.0 姝e紡鍙戝竷鍖� `deploy/traffic-audit-deploy-20261005-v1.0.0/`锛坺ip **77.78 MB**锛夊凡鐢熸垚锛屽幓瀵嗚嚜妫�銆佺増鏈嚜妫�銆佺粨鏋勬牳瀵逛笌鐪熷疄鍚姩鍐掔儫鍧囬�氳繃锛屽緟涓婁紶鏈嶅姟鍣ㄣ�傜増鏈鐞嗕唬鐮佷笌鏂囨。鍧囧凡鎻愪氦鎺ㄩ�侊紙`main` = `93b36c8`锛夈��
+- **杩愯鐘舵��**锛氬叧鏈哄墠鏈満 dev 鏈嶅姟浠嶅湪杩愯锛堝悗绔� 8090 java PID 64112銆佸墠绔� 8080 node PID 46472锛夛紝闅忓叧鏈哄仠姝紝鏃犻渶澶勭悊锛涙墦鍖呭啋鐑熺敤鐨� 8095 瀹炰緥宸插仠姝€�佺鍙e凡閲婃斁銆�
+- **宸ヤ綔鍖�**锛氶櫎鑻ュ共鏈湴涓存椂鏂囦欢锛坄_tmp_*`锛変笌涓�浠戒笓椤圭粺璁� xls 澶栵紝git 宸ヤ綔鍖轰笌杩滅 `main` 涓�鑷达紱`docs/宸ヤ綔鏃ョ粨/`銆乣docs/鍔熻兘娴嬭瘯鎶ュ憡/` 绛夋寜绾﹀畾涓嶅叆搴撱��
+- **涓嬫鎺ユ墜**锛氬厛璇绘牴鐩綍 `AGENTS.md` + 鏈枃浠讹紱寰呭姙瑙佺涓冭妭锛堟暟鍊肩簿搴︾粺涓�鏀归�犮�佹�佸娍鎰熺煡鍥俱�丄I 鍔╂墜 RAG銆佺粡娴庢寚鏍囪瘯绠楃瓑锛夛紝棣栬浜嬮」鏄窡杩涙寮忓寘鍦ㄦ湇鍔″櫒鐨勯儴缃茬粨鏋溿��
+
+### 10-05 涓婂崍锛氭寮忓彂甯冨寘鎸夋柊鐗堟湰绠$悊閲嶆柊鐢熸垚锛坴1.0.0锛屽崟绔彛鐦﹀寘锛�
+
+- **鑳屾櫙**锛歚8b71049` 宸叉妸鎵撳寘鑴氭湰鏀逛负鍥哄畾 jar 鍚� + 鐗堟湰鑷锛坄traffic-audit-server.jar`銆佸寘鍚嶅甫 `v{version}`锛夛紝浣嗘寮忓彂甯冨寘杩樻病鎸夋柊鑴氭湰閲嶆柊鐢熸垚锛涚増鏈敼閫犲墠浜у嚭鐨� `deploy/traffic-audit-deploy-20261005-2/` 浠嶆槸鏃� jar 鍚� `traffic-audit-server-1.0.0-SNAPSHOT.jar`銆�
+- **閲嶆柊鐢熸垚**锛歚powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper`锛堝悗绔� clean package 閲嶅缓銆佸墠绔� `npm run build` 閲嶅缓锛夈��
+- **浜х墿**锛歚deploy/traffic-audit-deploy-20261005-v1.0.0/`锛�351 鏂囦欢 / 94.58 MB锛夛紝zip **77.78 MB**銆�
+- **鏍¢獙**锛氬幓瀵嗚嚜妫� + 鐗堟湰鑷閫氳繃锛坧om / `app-version.properties` / `MANIFEST.MF` 涓夎�呭潎 `1.0.0`锛夛紱鍖呭唴 `backend\traffic-audit-server.jar` 涓哄浐瀹氬悕锛涙棤 `frontend/`銆佹棤 `trafficAudit.exe/.xml/install-service*`锛沗backend\web` 44 鏂囦欢涓� `frontend\dist` 閫愭枃浠� SHA-256 涓�鑷达紱`README-閮ㄧ讲璇存槑.txt` 澶撮儴涓恒��20261005锛岀増鏈� v1.0.0銆嶅苟鍚棫鏈嶅姟鍣ㄤ竴娆℃�у崌绾ц鏄庛��
+- **鍚姩鍐掔儫锛堢湡瀹炲寘銆佸彧璇伙級**锛氱敤鏈湴寮�鍙戝簱閰嶇疆鍦� 8095 绔彛鍚姩鍖呭唴 jar锛宍GET /api/version` 杩斿洖 `{"version":"1.0.0",...}`锛宍GET /` 杩斿洖鐧诲綍椤� 200锛涢獙璇佸悗宸叉寜 PID 58652 绮剧‘鍋滄鍐掔儫杩涚▼銆�
+- **鐘舵��**锛氬緟鐢ㄦ埛涓婁紶鏈嶅姟鍣ㄣ�俙deploy/traffic-audit-deploy-20261005-2/`锛堝強鏇存棭鐨� `20261005/`锛変綔搴熷嬁鐢ㄣ�傛棫鏈嶅姟鍣ㄩ娆″崌绾ф寜 README锛氬厛鏀� `trafficAudit.xml` 鐨� `<arguments>` 涓� `-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"` 鍐嶆崲 jar锛屼互鍚庡彧鏇挎崲 jar銆�
+
+### 10-05锛氱増鏈鐞嗛噰鐢ㄦ柟妗� A锛堝浐瀹� jar 鍚嶏紝鐢ㄦ埛楠屾敹閫氳繃锛�
+
+- **鐢ㄦ埛鍐崇瓥**锛氶噰鐢ㄦ柟妗� A銆俙traffic-audit-server/pom.xml` 涓哄敮涓�鐗堟湰婧愶紝褰撳墠瀵瑰鐗堟湰 `1.0.0`銆�
+- **鏋勫缓鏀归��**锛歚<finalName>traffic-audit-server</finalName>`锛屼骇鐗╁浐瀹氫负 `target/traffic-audit-server.jar`锛汳aven 璧勬簮杩囨护鐢熸垚 `app-version.properties`锛圫pring Boot 鐖� POM 浣跨敤 `@project.version@` 鍒嗛殧绗︼級锛宍maven-jar-plugin` 鍐欏叆 `Implementation-Version`銆�
+- **椤甸潰鏄剧ず**锛氭柊澧炲厤鐧诲綍 `GET /api/version`锛涚櫥褰曢〉鍜岄〉澶存樉绀� `V1.0.0`銆傚悓鏃朵慨澶嶅彲鎵ц jar 涓� `buildTime` 涓� `null` 鐨勫祵濂� URI 瑙f瀽闂銆�
+- **鍙戝竷閾捐矾**锛氭湇鍔� XML銆佸惎鍔ㄨ剼鏈�佹墦鍖呰剼鏈叏閮ㄦ敼鐢ㄥ浐瀹� jar 鍚嶏紱`pack-deploy.ps1` 浼氭牎楠� pom / `app-version.properties` / `MANIFEST.MF` 涓夎�呯増鏈竴鑷达紝鍙戝竷鍖呭悕涓� `traffic-audit-deploy-YYYYMMDD-v1.0.0(.zip)`锛屽苟杈撳嚭甯� `{version}` 鐨勯儴缃� README銆�
+- **鏃ф湇鍔″櫒棣栨鍗囩骇锛堜粎涓�娆★級**锛氬仠姝㈡湇鍔″悗锛屾妸 `trafficAudit.xml` 鐨� `<arguments>` 鏀逛负 `-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"`锛屾浛鎹� jar 鍚庡惎鍔ㄣ�備互鍚庡彂鐗堝彧鏇挎崲 jar 骞堕噸鍚紝涓嶅啀鏀� XML銆�
+- **楠岃瘉**锛歚mvn -o test`銆乣mvn -o clean package -DskipTests`銆佸浐瀹氬悕 jar 瀹為檯鍚姩骞惰闂� `/api/version`銆佸墠绔� `npm run build`銆佹湰鍦版祻瑙堝櫒鐧诲綍椤垫樉绀� `V1.0.0`銆乣pack-deploy.ps1 -SkipFrontend -NoFrontendDist -SkipServiceWrapper` 缁勮/鍘诲瘑/鐗堟湰鑷鍏ㄩ儴閫氳繃锛涜礋鍚戦獙璇佺‘璁ょ増鏈笉鍖归厤鍜� `SNAPSHOT` 閮戒細鍦ㄦ墦鍖呭墠涓銆傛祴璇曟姤鍛婏細鏈湴 `docs/鍔熻兘娴嬭瘯鎶ュ憡/鍔熻兘娴嬭瘯鎶ュ憡_鐗堟湰绠$悊鏂规A_2026-10-05.md`锛堢洰褰曚笉鍏ュ簱锛夈��
+- **鐘舵��**锛氱増鏈鐞嗕唬鐮佹彁浜や负 `8b71049`锛宍main` 宸插悓姝ワ紱`v1.0.0` 宸叉帹閫佸埌鍏徃 Gitblit骞舵寚鍚� `21a5bf7`銆�2026-10-05 鐢ㄦ埛纭鏈湴鐧诲綍椤靛強鎵�鏈夌櫥褰曞悗椤甸潰鍧囨纭樉绀� `V1.0.0`銆傛寮忓彂甯冨寘宸蹭簬 10-05 涓婂崍鐢ㄦ柊鐗堟墦鍖呰剼鏈噸鏂扮敓鎴愶紙瑙佷笂鏂� 10-05 涓婂崍涓�鑺傦級銆�
+
+### 10-04 娣卞锛氭姌绾挎暟鍊兼爣绛惧洖鍒拌创杩戞湰鐐癸紙v35锛屽緟鐢ㄦ埛楠屾敹锛�
+
+- **鐢ㄦ埛鍙嶉**锛歷31銆岄伩寮�鎶樼嚎銆嶇増鏈笉濡備笂涓�鐗� v27 鈥斺�� 铏界劧鏍囩涓嶅帇绾夸簡锛屼絾寰堝鏍囩绂昏嚜宸辩殑鏁版嵁鐐瑰お杩滐紝涓�鐪肩湅涓嶅嚭鏍囩殑鏄摢涓偣銆傝姹傚洖鍒拌创杩戞湰鐐圭殑鏁堟灉銆�
+- **鏂伴棶棰樹笌鏃ч渶姹傜殑鍐茬獊**锛氭洿鏃╃殑鍘熷璇夋眰鏄�屾暟瀛楀帇绾跨湅涓嶆竻銆嶏紱杩欐璇夋眰鏄�屾暟瀛楀繀椤昏创杩戞湰鐐广�嶃�備袱鑰呭苟瀛橈紝蹇呴』鍚屾椂婊¤冻銆�
+- **瀹炵幇锛坄HolidayTrendChartService`锛�**锛�
+ - 鏍囩鎺掔増**瀹屾暣鍥為��鍒� v27 绠楁硶**锛氭寜鍚屾棩鐐逛綅璺濈鍋氫唬浠凤紙`ownDistance 脳10`锛夛紝鍊欓�夊惈杩�/杩滅殑涓娿�佷笅銆佸乏銆佸彸鍙婂洓瑙掞紝鍔ㄦ�佽鍒掍繚璇佸悓鏃ユ爣绛句笌鐐逛綅涓婁笅椤哄簭涓�鑷达紙`lineLabelCost`銆乣lineLabelCandidates`锛夈�倂31 鐨勭嚎鏉℃帺鑶溿�丆hamfer 璺濈鍦恒�侀鑹蹭翰鍜岄」鍏ㄩ儴绉婚櫎銆�
+ - **鏂板绮楃櫧鎻忚竟锛坔alo锛�** 瑙e喅銆屽帇绾跨湅涓嶆竻銆嶏細`drawLabelWithHalo` 鐢� `TextLayout` 鍙栧瓧褰㈣疆寤擄紝鍏堜互鐧借壊 `HALO_OUTLINE_WIDTH=4.0f` 鍦嗚鎻忚竟锛屽啀鐢ㄦ湰鑹插~鍏呫�傛暟瀛楀嵆浣胯惤鍦ㄦ姌绾夸笂涔熸竻鏅板彲璇伙紝浠庤�屽厑璁告爣绛捐创鍥炴湰鐐广�傛姌绾垮浘銆佸爢鍙犲浘澶栫疆鏍囩銆侀ゼ鍥炬爣绛剧粺涓�璧拌鏂规硶銆�
+ - 椤哄甫娓呯悊璋冭瘯閬楃暀锛氬垹闄� `LABEL_DEBUG`/`CHART_SEQ`/`TRENDDBG` 鎵撳嵃锛屼互鍙婁笉鍐嶄娇鐢ㄧ殑 `lineNormal(...)` 涓� `DayLabel.normalX/normalY`銆�
+- **閲忓寲楠岃瘉锛圧apidOCR 鏍囩鍖呭洿妗嗕腑蹇� 鈫� 鐪熷疄鏁版嵁鐐瑰潗鏍囷級**锛氭爣绛惧埌鏈偣骞冲潎璺濈 v27=`36.8px`銆乿31=`53.2px`銆乿35=`36.7px`锛堣秺灏忚秺璐磋繎鏈偣锛夈�倂35 涓庣敤鎴疯鍙殑 v27 鍩烘湰涓�鑷达紝鏄庢樉浼樹簬琚惁鐨� v31銆�
+- **鍏朵綑楠岃瘉**锛歚mvn compile`/`mvn test` 閫氳繃锛堟棤缂栬瘧鍛婅閬楃暀锛夛紱Word COM 8 椤� 15 鍥撅紙1+2脳7锛夛紱RapidOCR 澶嶆 12 寮犳姌绾垮浘鏁板�兼爣绛惧寘鍥存閲嶅彔鏁� = 0锛沗label_affinity` 澶嶆鍥� 11 姣忎釜鏁版嵁鏍囩鍒版湰绾� 3~8px 涓旀湰绾挎槸鏈�杩戠嚎锛堟棤銆孋LOSER TO 鍏朵粬鑹层�嶏級锛涙斁澶� 6脳 澶嶆牳鍥� 11 宸︿笂鏍囩纭负 `630.99`锛堜綆鍒嗚鲸鐜� OCR 鏇捐璇讳负 `1630.99`锛夈�倂27鈫攙35 鍍忕礌宸害 1%锛岄泦涓湪鏍囩澶勶紝鍗崇櫧鎻忚竟鐢熸晥涓旀湭鐮村潖鎶樼嚎銆�
+- **浜х墿涓庣姸鎬�**锛歚_tmp_holiday_verify/holiday_trend_2026-10-04_v35.docx`锛涗笅杞藉壇鏈� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏁板瓧璐磋繎鎶樼嚎-鐧借竟娓呮櫚锛�.docx`銆傛湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傚緟鐢ㄦ埛楠屾敹锛涢獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�侊紱鐜版湁 `deploy/traffic-audit-deploy-20261004.zip` 浠嶆槸 v24 琛屼负锛岄獙鏀跺悗闇�閲嶅缓銆�
+
+### 10-05锛氳蛋鍔垮浘 v35 楠屾敹閫氳繃 + 鍗曠鍙e彂甯冨寘锛�-NoFrontendDist -SkipServiceWrapper锛�
+
+- **楠屾敹**锛歷35锛堟姌绾挎暟鍊兼爣绛惧洖鍒拌创杩戞湰鐐� + 鐧芥弿杈癸級2026-10-05 鐢ㄦ埛楠屾敹閫氳繃銆�
+- **鎵撳寘闇�姹傦紙鐢ㄦ埛婢勬竻锛�**锛氭湇鍔″櫒鍗曠鍙o紝鍙渶瑕� `backend\web`锛屼笉瑕佺嫭绔嬬殑 `frontend/dist`锛涗负淇濊瘉鍖呭畬鏁达紝`backend\web` + jar + docs 閮借銆傛湇鍔″櫒闈為娆″彂甯冦�佹湇鍔″凡瑁咃紝鍙姞 `-SkipServiceWrapper` 鍘绘帀鏈嶅姟娉ㄥ唽鍣ㄣ��
+- **鎵撳寘宸ュ叿淇**锛氬厛鍓嶅姞鐨� `-NoFrontend` 璇箟閿欎簡锛堟妸 `backend\web` 涔熷幓鎺変簡锛夛紝鐜版敼涓� `-NoFrontendDist`銆傚畠鍙烦杩囩嫭绔� `frontend\` 鐩綍锛堜笉澶嶅埗 dist / nginx 绀轰緥锛夛紝**浠嶆瀯寤猴紙鎴栬姹傦級dist 骞跺悓姝� `backend\web`**锛堝崟绔彛蹇呴渶锛屽惁鍒欓〉闈� 404锛夈�俙-SkipFrontend` 浠嶄负鈥滀笉閲嶅缓銆佸鐢ㄧ幇鏈� dist鈥濄��
+- **鍙戝竷鍖�**锛歚deploy/traffic-audit-deploy-20261005-2/`锛�351 鏂囦欢 / 94.55 MB锛泎ip **77.78 MB**锛夈�傚凡鏍稿锛氭棤 `frontend/`銆佹棤 `trafficAudit.exe/.xml/install-service*`锛堟湇鍔″寘瑁呭櫒宸叉帓闄わ級锛沗backend\web` 44 鏂囦欢涓� `frontend\dist` 閫愭枃浠跺搱甯屼竴鑷达紱jar 鍐呯疆閰嶇疆鍏ㄥ崰浣嶇锛堝幓瀵嗚嚜妫�閫氳繃锛夛紱鍖呭唴 `HolidayTrendChartService.class` 涓� `target/classes` 涓�鑷达紙鍚� v35 浠g爜锛夈�傝緝涓婁竴瀹屾暣鍖咃紙110.49 MB / zip 83.77 MB锛夌渷绾� 16 MB 瑙e帇 / 6 MB 鍘嬬缉銆�
+- **浣滃簾**锛歚deploy/traffic-audit-deploy-20261005/`锛堥敊璇涔変笅鐨勪骇鐗╋紝缂� `backend\web`锛夊凡琚� `-2` 鍙栦唬锛屽嬁鐢ㄣ��
+- **閮ㄧ讲娉ㄦ剰**锛氳鐩� jar锛堝強鎸夐渶 `application.yml`锛夊嵆鍙紱`backend\web` 宸查殢鍖呮彁渚涖�傛湇鍔″凡瑁咃紝鏃犻渶閲嶆柊娉ㄥ唽銆�
+- **鐘舵��**锛氬凡鎵撳寘锛屽緟鐢ㄦ埛涓婁紶鍒版湇鍔″櫒锛涜鍖呬负鐗堟湰鏀归�犲墠浜х墿锛屽凡鐢� 10-05 涓婂崍鐨� `traffic-audit-deploy-20261005-v1.0.0` 鍙栦唬锛堝嬁鐢級銆�
+
+### 10-04 娣卞锛氭姌绾挎暟鍊兼爣绛鹃伩寮�鎶樼嚎骞惰创杩戞湰绾匡紙v31锛屽緟鐢ㄦ埛楠屾敹锛�
+
+- **鐢ㄦ埛鍙嶉锛堥瀵肩湅鍚庯紝涓よ疆锛�**锛氣憼 鍚勫勾浠芥暟瀛楃鏈嚎鏈夎繙鏈夎繎锛屽笇鏈涜创杩戝悇鑷壊绾夸笖涓嶉敊涔憋紱鈶� 浠嶆湁鏁板瓧鍘嬪湪鎶樼嚎涓婄湅涓嶆竻锛堢涓�杞彧鏄�滆创杩戔�濓紝鏍囩浼氳惤鍦ㄧ嚎涓婏級銆傜敤鎴峰缓璁細璋冪旱杞村埢搴︽媺寮�鎶樼嚎銆佹垨鍔犲乏鍙抽伩璁╄鏍囩涓嶅帇绾裤��
+- **瀹炵幇锛坄HolidayTrendChartService`锛�**锛�
+ - 鏍囩鎺掔増鏀逛负銆屽姩鎬佽鍒� + 绾挎潯鍍忕礌閬胯銆嶃�傚浘琛ㄥ厛鐢绘姌绾垮拰鏍囪锛屽啀鎶婃墍鏈夌郴鍒楅鑹插儚绱狅紙瀹瑰樊 45锛夊仛鎴愭帺鑶滐紝瀵规瘡涓�欓�夋爣绛句綅缃粺璁″叾瑕嗙洊鐨勭嚎鏉″儚绱犳暟锛宍脳700` 閲嶇綒鈥斺�旀暟瀛椾笉鍐嶅帇绾裤��
+ - 涓烘瘡鏉$郴鍒楀缓绔� Chamfer 璺濈鍦猴紙鍒拌鑹叉渶杩戝儚绱犵殑璺濈锛夈�備唬浠锋柊澧炰袱椤癸細`max(0, 鑷繁閭f潯绾跨殑璺濈-3)脳40`锛堟妸鏍囩鎷夊洖鏈嚎鏃侊紝闃叉椋樿繙锛変笌棰滆壊浜插拰椤� `max(0, 鑷繁绾胯窛 - 鍒殑绾胯窛 + 4)脳200`锛堣姹傛湰绾挎槸鏈�杩戠殑绾匡紝閬垮厤璐村埌鍒殑绯诲垪锛夈��
+ - 鏃х殑銆岀鍒殑鍚屾棩鐐逛綅鏇磋繎銆嶆儵缃氱敱 `脳5000` 闄嶅埌 `脳400`锛岄伩鍏嶅畠鍘嬪�掍翰鍜岄」锛沗ITEM_LABEL_GAP` 12鈫�10锛涘�欓�夊惈杩�/杩滅殑涓娿�佷笅銆佸乏銆佸彸鍙婂洓瑙掋��
+ - 淇濈暀鍚屾棩鏍囩涓庢暟鎹偣涓�鑷寸殑涓婁笅椤哄簭锛岄伩鍏嶆爣绛句笌鎶樼嚎涓婁笅鍙嶈浆銆�
+- **璋冨弬杩囩▼鎻愮ず**锛歷27锛堝彧鎸夌偣浣嶈窛绂绘帓搴忥級鈫� 鏍囩鍘嬬嚎锛泇28锛堝彧鍔犺鐩栧儚绱犳儵缃氾級鈫� 鏍囩绂绘湰绾裤�佽创鍒板埆鐨勮壊绾挎梺锛泇31锛堣鐩栨儵缃� + 鏈嚎璺濈 + 棰滆壊浜插拰锛夆啋 涓よ�呭吋椤俱�傞獙璇佹爣绛炬槸鍚︹�滆创閿欑嚎鈥濅笉鑳藉彧鐪� OCR 鍖呭洿妗嗗埌寮傝壊鐨勮窛绂伙細鐩搁偦鐨勫紓鑹�**鏍囩**浼氳璇垽涓哄紓鑹�**绾�**锛屽繀椤昏倝鐪兼牳瀵规暣鍥俱��
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃銆傜湡瀹炲簱鍙鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v31.docx`锛沇ord COM 8 椤� 15 鍥撅紙1+2脳7锛夈�俁apidOCR 澶嶆 15 寮犲浘鏁板�兼爣绛惧寘鍥存閲嶅彔鏁颁负 0銆傝倝鐪兼牳瀵瑰浘 1锛堣法鍖哄煙浜哄憳娴佸姩閲忥級銆佸浘 2锛堢疮璁★級銆佸浘 4锛堣嚜椹撅級銆佸浘 6锛堟按璺級銆佸浘 9锛堥珮閫燂級銆佸浘 11锛堣溅娴佹�婚噺锛夛細鏁板�兼爣绛惧潎绂诲紑鎶樼嚎銆佷綅浜庡悇鑷壊绾挎梺銆侀鑹插搴旀纭紱鍥� 11 鐨� 630.99/601.04/571.31 鍒嗗埆绱ц创钃�/绾�/缁挎姌绾裤��
+- **浜х墿涓庣姸鎬�**锛歚C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏁板�兼爣绛鹃伩寮�鎶樼嚎锛�.docx`銆傛湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傚緟鐢ㄦ埛楠屾敹锛岄獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�侊紱鐜版湁 `deploy/traffic-audit-deploy-20261004.zip` 浠嶆槸 v24 琛屼负锛岄渶楠屾敹鍚庨噸寤恒��
+
+### 10-04 鏅氾細璧板娍鍥� 3 椤瑰潗鏍囪酱璋冩暣锛坴24锛岀敤鎴峰凡楠屾敹锛�
+
+- **鐢ㄦ埛淇敼椤�**锛氣憼 鍧愭爣杞村崟浣嶏紙涓囦汉娆°�佷竾杈嗘銆佸叕閲�/灏忔椂銆佹嫢鎸ゅ害銆佹棩鏈燂級鏀规垚鍜屽埢搴︿竴鏍风殑棰滆壊锛涒憽 涓や釜鍫嗗彔鍥剧殑鍧愭爣杞存敼鎴愪笌鍏朵粬鍥句竴鏍风殑榛戣壊锛涒憿 鈥滀竾浜烘/鍗犳瘮鈥濈户缁斁鍦ㄥ爢鍙犲浘鍙充笂瑙掋��
+- **瀹炵幇**锛歚HolidayTrendChartService` 鎶� `drawUnitLabel`銆乣drawUnitLabelTopRight`銆乣drawDateLabel` 鐨勬枃瀛楄壊鐢遍粦鑹叉敼涓哄埢搴︾伆 `(102,102,102)`锛涙í鍚戝爢鍙犲浘鍦ㄦ暟鎹尯搴曡竟琛ョ敾 `2.2px` 绾粦鍩虹嚎锛岃鐩� JFreeChart 榛樿鍋忕伆鐨� 1px 杞寸嚎鍜屾姉閿娇杈圭紭锛屼笉娣诲姞绠ご銆傛姌绾垮浘榛戣壊鍧愭爣杞撮�昏緫鏈敼锛屽爢鍙犲浘鍗曚綅浠嶇粯鍒跺湪鍙充笂瑙掋��
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃銆傜湡瀹炲簱鍙鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v24.docx`锛沇ord COM 瀹炴祴 8 椤点��15 寮犲浘銆傚儚绱犳鏌ワ細鎶樼嚎鍥惧乏涓婂崟浣嶃�佹棩鏈熷強鍫嗗彔鍥惧彸涓婂崟浣嶅潎涓� `(102,102,102)`锛涘浘 12/13 搴曡酱鍦� y=529 涓虹函榛戣繛缁嚎锛涙櫘閫氭姌绾垮浘搴曡酱浠嶄负绾粦銆�
+- **浜х墿涓庣姸鎬�**锛歚C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂帮級.docx` 褰撴椂琚� Word/WPS 鍗犵敤锛屽凡鍙﹀瓨涓� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂�-鍧愭爣杞磋皟鏁达級.docx`锛涘師鈥滄渶鏂扳�濇枃浠舵湭瑕嗙洊銆傛湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆�2026-10-04 鐢ㄦ埛宸查獙鏀堕�氳繃锛岄殢鏈鎻愪氦鎺ㄩ�併��
+
+### 10-04 鏅氾細璧板娍鍥� 4 椤逛慨鏀癸紙v23锛屽緟鐢ㄦ埛楠屾敹锛�
+
+- **鐢ㄦ埛淇敼椤�**锛氣憼 鎶樼嚎鏁板�兼爣绛句笌鎶樼嚎涓婁笅椤哄簭鍙嶄簡锛堢疮璁℃祦鍔ㄩ噺鍥撅細钃濈嚎鍦ㄤ笂銆佹爣绛惧嵈鍦ㄤ笅锛涚涓�寮犲浘棣栨棩鏈�楂樻槸钃濈偣銆佹渶杩戠殑鏍囩鍗存槸妫曡壊锛夛紱鈶� 鍏矾鍑鸿缁撴瀯鍥句緥鎶婅嚜椹炬斁鍓嶉潰锛堟鑹诧級锛屼笌姣忔棩瀹㈡祦鏋勬垚涓�鑷达紱鈶� 妯潗鏍囧簲涓轰竾浜烘/鍗犳瘮锛堟暟鍊艰酱鏀惧埌搴曢儴锛夈�佸幓鎺夌旱杞寸嚎涓庣澶达紱鈶� 姣忔棩瀹㈡祦鏋勬垚鎭㈠ 5 鏉★紙鑷┚/閬撹矾/閾佽矾/姘戣埅/姘磋矾锛夛紝鍗犳瘮灏忕殑鏁板瓧鏍囧湪鑹插潡鍙宠竟銆佺敤棰滆壊鍖哄垎銆�
+- **瀹炵幇**锛�
+ - `HolidayTrendChartService.drawXYItemLabels` 閲嶅啓锛氬厛鎶婂悓涓�鏃ユ湡鍚勭郴鍒楁寜鐐逛綅 y 浠庝笂鍒颁笅鎺掑簭锛屾爣绛惧啀鎸夎椤哄簭浠庝笂寰�涓嬫憜锛堜笅鐣屼负鍓嶄竴涓爣绛惧簳閮� +3锛夛紝**浠庤�屼笉浼氬嚭鐜版爣绛句笌鎶樼嚎涓婁笅鍙嶈浆**锛涢伩璁╂潈閲嶆敼涓恒�屾爣绛鹃噸鍙� 脳 1000 + 鏁版嵁鐐归噸鍙� 脳 1銆嶏紝骞跺垹闄ゅ師鏉ヤ細鎶婃爣绛句竴璺帹鍒板浘琛ㄤ笅鏂圭殑闈炵櫧鍍忕礌鎯╃綒椤癸紙`nonWhiteRatio`锛夈��
+ - 妯悜鍫嗗彔鍥撅細`plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT)` 鎶婃暟鍊艰酱鏀惧埌搴曢儴锛堜竾浜烘 / 鍗犳瘮 灏辨槸妯潗鏍囷級锛沗domainAxis.setAxisLineVisible(false)` + `setTickMarksVisible(false)` 鍘绘帀绾佃酱绾垮拰鍒诲害锛沗render` 瀵规í鍚戝爢鍙犲浘涓嶅啀鐢诲潗鏍囪酱绠ご锛涘崟浣嶆敼鍒板彸涓婅锛坄drawUnitLabelTopRight`锛夈��
+ - 鍫嗗彔鍥惧潡鍐呮斁涓嶄笅鐨勫皬鏁板瓧锛屾敼涓哄湪鑹插潡鍙充晶鐢ㄥ悓鑹叉枃瀛楁爣娉紙鍚屼竴琛屾寜鑹插潡浣嶇疆鎺掓垚涓�鍒楋紝鍒� x 鍙栬琛屾渶鍙宠壊鍧楃 +8锛屽苟闄愬埗鍦ㄩ鐣� 74px 鍙宠竟璺濆唴锛夛紱鏀惧緱涓嬩粛鐢ㄧ櫧瀛楀啓鍦ㄥ潡鍐呫��
+ - `HolidayTrendService.roadStructureSeries` 椤哄簭鏀逛负 鑷┚鍑鸿閲忥紙妫曪級銆侀亾璺杩愰噺锛堣摑锛夛紱`dailyShareSeries` 鎭㈠ 鑷┚/閬撹矾/閾佽矾/姘戣埅/姘磋矾 5 鏉★紝鍒犻櫎涓婁竴杞复鏃跺悎骞剁敤鐨� `sumNullableLists`銆�
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃锛泇23 Word COM 瀹炴祴 8 椤碉紙1+2脳7锛�15 寮犲浘锛夈�俁apidOCR 澶嶆 12 寮犳姌绾垮浘鏃犳爣绛鹃噸鍙狅紱閫愭棩姣斿鏍囩棰滆壊椤哄簭涓庢姌绾跨偣浣嶉『搴忎竴鑷达紙绱鍥� 10鏈�4鏃� 鐐逛綅 2025 鍦ㄤ笂/2024 鍦ㄤ笅锛屾爣绛惧悓鏍� 2025 鍦ㄤ笂銆�2024 鍦ㄤ笅锛涗慨鏀瑰墠 v20 鎭板ソ鐩稿弽锛夛紱棣栧浘 10鏈�1鏃ユ渶楂樼偣锛堣摑鑹� 2025锛夌殑鏁板�兼爣绛惧氨鍦ㄨ鐐逛綅姝d笂鏂广�傚浘 12 鍥句緥涓恒�岃嚜椹惧嚭琛岄噺路閬撹矾瀹㈣繍閲忋�嶏紝鏁板�艰酱鍦ㄥ簳閮� 0鈥�12,000锛屽崟浣嶄竾浜烘鍦ㄥ彸涓婅锛屾棤绠ご銆佹棤绾佃酱绾匡紱452/228 浠ヨ摑瀛楁爣鍦ㄨ摑鍧楀彸渚э紝648 鐧藉瓧鍦ㄥ潡鍐呫�傚浘 13 鍥句緥鎭㈠ 5 椤癸紝87.8% 绛夊ぇ鍗犳瘮鐧藉瓧鍦ㄥ潡鍐咃紝閬撹矾/姘戣埅/姘磋矾绛夊皬鍗犳瘮浠ュ悓鑹叉暟瀛楁爣鍦ㄥ彸渚т竴鍒椼��
+- **鏁版嵁鎿嶄綔涓庣姸鎬�**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傛渶缁堟枃浠跺凡瑕嗙洊鍒� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂帮級.docx`锛涘緟鐢ㄦ埛楠屾敹锛岄獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�併��
+
+### 10-04 鍌嶆櫄锛氳蛋鍔垮浘 5 椤瑰井璋冿紙v20锛屽緟鐢ㄦ埛楠屾敹锛�
+
+- **鐢ㄦ埛淇敼椤�**锛氣憼 绾佃酱鍗曚綅浠嶄笌绾佃酱閲嶅彔锛岀户缁笂绉伙紱鈶� 绗� 1 椤甸櫎鏍囬澶栧叏绌虹櫧锛岄渶鏀� 1 寮犲浘鍒扮 1 椤碉紱鈶� 妯旱鍧愭爣鍒诲害涓庡浘渚嬪亸榛戯紝绋嶅井璋冩贰锛涒懀 楗煎浘鏍囩棰滆壊鏀逛负涓庢墖鍖轰竴鑷达紱鈶� 涓や釜鍫嗗彔鍥炬晥鏋滀粛涓嶅ソ锛屽潡鍋氬ぇ銆佹暟瀛楀啓杩涘潡閲屻��
+- **瀹炵幇**锛歚HolidayTrendChartService` 涓哄潗鏍囩被鍥捐〃棰勭暀 `UNIT_LABEL_BAND=58px` 鐨勯《閮ㄧ┖鐧斤紙`reserveUnitLabelBand` 璋� `plot.setInsets`锛夛紝鍗曚綅鏍囩鍩虹嚎鏀逛负 `dataArea.getY()-22`锛屽交搴曡劚绂昏酱绾夸笌鏈�楂樺埢搴︼紱鍒诲害/鍥句緥鏂囧瓧鑹茬敱 (70,70,70) 璋冩贰涓� (102,102,102)锛屽潗鏍囪酱绾夸笌绠ご浠嶄负榛戣壊锛涢ゼ鍥炬爣绛撅紙鍚紩绾挎鍐呮枃瀛楋級閫氳繃 `TextFragment` 鍙嶅皠鏀逛负瀵瑰簲鎵囧尯鑹诧紱鍫嗗彔鍥剧淮鎸佹í鍚戯紝`categoryMargin 0.08->0.05`銆乣itemMargin 0.05` 璁╄壊鍧楀彉鍘氾紝鍧楀唴鏀惧緱涓嬪氨鐢� 17px 鐧藉瓧鍐欏湪鍧楀唴锛屾斁涓嶄笅鎵嶇敤鍚岃壊寮曠嚎鎷夊埌鍧楀锛涙姌绾挎暟鎹爣绛惧乏渚ф渶灏忕暀鐧� `dataArea.minX+16`锛岄伩鍏嶇揣璐寸旱杞村埢搴︺��
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃銆傜湡瀹炲簱鍙鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v20.docx`锛�571 KB锛�15 寮犲浘锛夛紱Word COM 瀹炴祴 8 椤碉紝绗� 1 椤垫爣棰�+1 寮犲浘锛岀 2-8 椤靛悇 2 寮犲浘锛屾棤绌虹櫧椤点�俁apidOCR 澶嶆 12 寮犳姌绾垮浘鏂囧瓧鍖呭洿妗嗛噸鍙犳暟涓� 0锛涘崟浣嶁�滀竾浜烘/鍗犳瘮鈥濅笌鏈�楂樺埢搴︺�佽酱绾垮凡鍒嗙锛堝浘 1 鍗曚綅 y=17..44銆侀《閮ㄥ埢搴� y=53..77锛夛紱鍫嗗彔鍥� 12 鐨� 452/8,595銆�648/10,410銆�228/3,962 涓庡爢鍙犲浘 13 鐨� 87.8%/7.4% 鍧囧湪鍧楀唴锛屼釜鍒繃绐勭殑 4.8% 鐢ㄥ悓鑹插紩绾挎媺鍒板潡澶栵紱楗煎浘鏍囩棰滆壊涓庢墖鍖轰竴鑷达紙閾佽矾缁裤�佹皯鑸传銆侀亾璺摑銆佹按璺潚锛夈��
+- **鍙e緞鍙樺寲鎻愰啋**锛氬浘 13銆屾瘡鏃ュ娴佹瀯鎴愬彉鍖栥�嶏紙闄勫綍涓�锛夌敱鍘熷厛 5 鏉★紙鑷┚/閬撹矾/閾佽矾/姘戣埅/姘磋矾锛夊悎骞朵负 3 鏉★紙鑷┚/閬撹矾/鍏朵粬鏂瑰紡=閾佽矾+姘戣埅+姘磋矾锛夛紝鐩殑鏄鑹插潡瓒冲瀹姐�佹暟瀛楄兘鍐欒繘鍧楀唴锛涙鏂� 5 绉嶈繍杈撴柟寮忚蛋鍔垮浘鏈彈褰卞搷銆�
+- **鏁版嵁鎿嶄綔涓庣姸鎬�**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傛渶缁堟枃浠跺凡瑕嗙洊鍒� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂帮級.docx`锛涘緟鐢ㄦ埛楠屾敹锛岄獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�併��
+
+### 10-04 涓嬪崍锛�3锛夛細璧板娍鍥� 8 椤逛慨鏀癸紙寰呯敤鎴烽獙鏀讹級
+
+- **鐢ㄦ埛淇敼椤�**锛氱旱杞村崟浣嶄笂绉婚伩寮�绠ご锛涘垹闄ら檮褰曚簩鍚屾瘮璧板娍锛涘悓鏃ユ湡鐨勪笁骞存姌绾跨偣鎭㈠鍨傜洿瀵归綈锛涘浘琛ㄦ爣棰樺幓鎺夆��2026骞粹�濓紱鈥滆娴嬬珯鈥濇敼涓衡�滄櫘閫氬浗鐪侀亾瑙傛祴绔欌�濓紱鍫嗗彔鍥惧皬鍗犳瘮鏀逛负鏌卞寮曠嚎鏍囨敞锛涙枃妗f敼涓烘瘡椤� 2 鍥撅紱鍥句緥鈥滈亾璺惀涓氭�у杩愰噺鈥濇敼涓衡�滈亾璺杩愰噺鈥濄�佲�滈潪钀ヤ笟鎬ц嚜椹炬姌绠楀娴佲�濇敼涓衡�滆嚜椹惧嚭琛岄噺鈥濄��
+- **瀹炵幇**锛歚HolidayTrendChartService` 鎭㈠鎵�鏈夊勾浠藉悓涓� x 鍧愭爣锛岀旱杞村崟浣嶇粯鍒跺湪鏁版嵁鍖轰笂鏂癸紝鍫嗗彔鍥惧皬鍗犳瘮浣跨敤鍚岃壊寮曠嚎鎷夊埌鏌卞锛涙姌绾挎暟鍊兼爣绛炬敼涓哄鍏朵粬鏁板�兼爣绛捐缃瀬楂橀伩璁╀紭鍏堢骇锛岀櫧鎻忚竟淇濈暀銆俙HolidayTrendService` 鍒犻櫎闄勫綍浜屽悓姣� 2 鍥撅紝鍘熼檮褰曚笁椤哄欢涓洪檮褰曚簩锛屽苟鏇存柊鏍囬鍜屽浘渚嬫枃瀛楋紝褰撳墠鍏� 15 寮犲浘銆俙HolidayTrendWordService` 灏侀潰鍚庡己鍒跺垎椤碉紝鍙栨秷绔犺妭鏍囬鍚庣殑澶氫綑绌烘锛屽啀鎸夋瘡 2 鍥捐嚜鍔ㄥ垎椤点��
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃銆傜湡瀹炲簱鍙鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v17.docx`锛沇ord COM 瀹炴祴 9 椤碉紝灏侀潰鍗曠嫭绗� 1 椤碉紝绗� 2鈥�8 椤靛潎涓� 2 寮犲浘锛屾渶鍚� 1 寮犲浘鍗曠嫭绗� 9 椤碉紝鏃犵┖鐧介〉銆俁apidOCR 澶嶆 12 寮犳姌绾垮浘鐨勬暟鍊兼爣绛鹃噸鍙犳暟涓� 0锛涘爢鍙犲浘鍥� 12銆�13 鐨勫皬鍗犳瘮鏁板瓧鍧囧彲瑙侊紱鍥� 3鈥滄按璺� 0.23%鈥濅粛鍦ㄥ彸渚э紱鍥句緥宸茶瘑鍒负鈥滈亾璺杩愰噺鈥濃�滆嚜椹惧嚭琛岄噺鈥濓紝鍥捐〃鏍囬涓笉鍐嶅嚭鐜扳��2026骞粹�濄��
+- **鏁版嵁鎿嶄綔涓庣姸鎬�**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傛渶缁堥獙鏀舵枃浠跺凡澶嶅埗鍒� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増-鏈�鏂帮級.docx`锛堝師鈥滀慨鏀圭増鈥濇枃浠跺綋鏃惰 WPS 鍗犵敤锛屾湭瑕嗙洊锛夛紱寰呯敤鎴烽獙鏀讹紝楠屾敹鍓嶄笉鎻愪氦銆佷笉鎺ㄩ�併��
+
+### 10-04 涓嬪崍锛�2锛夛細璧板娍鍥炬爣绛俱�佹姌绾块棿璺濅笌妯潗鏍囧噯纭�т慨姝o紙寰呯敤鎴烽獙鏀讹級
+
+- **鐢ㄦ埛鍙嶉**锛氶ゼ鍥锯�滄按璺�濆紩绾块毦杈ㄨ闇�鍥哄畾鍒板彸渚э紱鍫嗗彔鍥捐鏄剧ず鏁板�硷紱鍥句緥鏀归粦鑹叉斁澶э紱妯酱鈥滄棩鏈熲�濅笂绉伙紱绾佃酱鍗曚綅鏀惧埌绠ご涓婃柟锛涘潗鏍囪酱鍜屽埢搴︽敼榛戣壊骞舵斁澶э紱鎶樼嚎鍔犵矖銆佸敖閲忔媺寮�锛涗慨澶嶆í杞撮敊浣嶄竴澶╋紱鎶樼嚎鏁板�兼爣绛捐涓庢姌绾垮悓鑹诧紝骞舵鏌ユ爣绛鹃噸鍙犮��
+- **淇**锛歚HolidayTrendChartService` 楗煎浘瀛愮被鏀逛负鍦� JFreeChart 宸插垎濂界殑宸﹀彸鏍囩闆嗗悎涓妸鈥滄按璺�濅粠宸﹂泦鍚堢Щ鍒板彸闆嗗悎锛涙姌绾垮搴︾敱 4px 璋冭嚦 6px锛屽搴忓垪鎸� 卤0.08 鍋氳交寰í鍚戦敊寮�锛涘叧闂� JFreeChart 榛樿鎶樼嚎鏍囩锛屾敼涓洪�愮偣鑷畾涔夋帓鐗堬細鎸変笂/涓�/杩滀笂/杩滀笅/宸�/鍙冲�欓�変綅缃伩璁╁凡鏀炬爣绛惧拰鏁版嵁鐐癸紝骞跺鍗曚綅鍖哄煙鐣欑┖锛涙爣绛句娇鐢ㄥ搴旀姌绾块鑹插苟鍔犵櫧鑹叉弿杈广�傛í杞撮敊浣嶆牴鍥犳槸鍘� `XYSeries.add(i + 1, ...)` 涓� `SymbolAxis` 瀹為檯鏁存暟浣嶇疆 `0..7` 涓嶄竴鑷达紝宸叉敼涓� `add(i, ...)`銆�
+- **楠岃瘉**锛歚mvn test -f traffic-audit-server\pom.xml` 閫氳繃锛涚湡瀹炲簱鍙鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v14.docx`銆俁apidOCR 澶嶆 14 寮犳姌绾垮浘锛屾暟鍊兼爣绛惧寘鍥存閲嶅彔鏁颁负 0锛屾湭鍐嶈瘑鍒嚭涓や釜鏁板�肩矘杩烇紱涓夊勾鎶樼嚎姣忓浘鍧囪瘑鍒嚭 2026 骞� 3 涓��2025 骞� 8 涓��2024 骞� 7 涓紝鍏� 18 涓暟鍊兼爣绛撅紝棰滆壊璁℃暟涓虹孩 3銆佽摑 8銆佺豢 7銆傞ゼ鍥锯�滄按璺� 0.23%鈥濅綅浜庡彸渚э紙OCR 涓績 x鈮�801.5锛夛紝鍏朵粬灏忓崰姣旀爣绛惧湪宸︿晶锛涘爢鍙犲浘鏁板�煎彲瑙併��10 鏈� 1 鏃ヨ酱鍒诲害 x鈮�123.5锛岄鏃ユ暟鎹偣宸茶惤鍦ㄧ涓�鏍笺��
+- **鏁版嵁鎿嶄綔涓庣姸鎬�**锛氭湭瀵煎叆銆佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傛柊鐗堝凡澶嶅埗鍒� `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増锛�.docx`锛涘緟鐢ㄦ埛楠屾敹锛岄獙鏀跺墠涓嶆彁浜ゃ�佷笉鎺ㄩ�併��
+
+### 10-04 涓崍锛氳蛋鍔垮浘澶栬鎸夋ā鏉块噸鍒讹紙寰呯敤鎴烽獙鏀讹級
+
+- **鐢ㄦ埛鍙嶉鐨� 7 椤归棶棰�**锛氱珷鑺傛爣棰橀噸澶嶃�佸浘姣旀ā鏉垮ぇ銆佸瓧灏忕嚎缁嗐�佺綉鏍煎拰娴呯伆鑳屾櫙銆佸潗鏍囪酱鏃犵澶翠笖鍗曚綅浣嶇疆涓嶇銆佺旱杞磋寖鍥翠笉鍚堥�傘�侀ゼ鍥惧皬鍗犳瘮鏍囩搴旀媺鍒板渾澶栥��
+- **淇**锛歚HolidayTrendWordService` 鍏嬮殕绔犺妭鏃剁Щ闄ゆā鏉胯嚜鍔ㄧ紪鍙凤紱姣忓紶鍥炬寜妯℃澘瀵瑰簲灏哄璁剧疆 `wp:extent` 鍜屽唴閮� `a:ext`锛屽苟绉婚櫎妯℃澘鏃� `srcRect`銆俙HolidayTrendChartService` 鏀逛负绾櫧鑳屾櫙銆佹棤缃戞牸/鏃犱氦鏇跨伆鑹插甫銆侀粦鑹插潗鏍囪酱甯︾澶淬�佸乏涓婃柟姘村钩鍗曚綅銆佸彸涓嬭鏃ユ湡锛涙姌绾垮瓧鍙锋寜 Word 绾稿紶瀹為檯鏄剧ず灏哄鏀惧ぇ銆佸姞绮楃嚎瀹藉拰鐐瑰緞锛涚旱杞翠笉寮哄埗浠� 0 寮�濮嬪苟鎸夋暟鎹笂涓嬬暀鐧斤紱鐧惧垎姣斿爢绉浘鍥哄畾 0%-100%锛涢ゼ鍥惧叧闂� `simpleLabels`锛屼娇鐢ㄥ閮ㄦ爣绛惧拰寮曠嚎銆�
+- **楠岃瘉**锛歚mvn test` 鍏ㄩ儴閫氳繃锛屾柊澧炲浘琛ㄦ祬鐏拌儗鏅笌 Word 缂栧彿/灏哄鍥炲綊鏂█锛涘墠绔� `npm run build` 閫氳繃锛堜粎鍘熸湁 bundle 浣撶Н璀﹀憡锛夈�傜湡瀹炲簱鐢熸垚鏈�缁堢 `C:\Users\jcxiong\Downloads\2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘锛堜慨鏀圭増锛�.docx`锛學ord COM 瀹炴祴 9 椤点��17 寮犲浘锛涚▼搴忔鏌ュ叏閮� `gray243=0` 涓� 6 涓珷鑺傛 `numPr=false`锛屽浘鐗囧昂瀵镐笌妯℃澘 7 绉嶇洰鏍囨涓�鑷淬�傛渶缁堢増 PNG 鎸夋ā鏉挎樉绀烘瀹介珮姣旇緭鍑猴紙涓嶅啀琚� Word 鎷変几鍙樺舰锛夛紝鍒诲害/鍥句緥 18px銆佹暟鎹爣绛� 20px銆佸崟浣� 22px锛岀嚎瀹� 4px銆�
+- **鏁版嵁鎿嶄綔**锛氬叏绋嬪彧璇伙紝鏈鍏ャ�佽鐩栨垨閲嶅浠讳綍鏁版嵁銆傜姸鎬侊細寰呯敤鎴烽獙鏀讹紝楠屾敹鍚庡啀鎻愪氦/鎺ㄩ�併��
+
+### 10-04 涓嬪崍锛氭柊澧炶妭鍋囨棩缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘鍒濈増锛堝瑙傚凡鎸夊弽棣堥噸鍒讹級
+
+- **闇�姹傚彛寰勶紙鐢ㄦ埛 2026-10-04 纭锛�**锛氳蛋鍔垮浘鍒� 12 寮犳鏂� + 闄勫綍锛涢瀵煎師璇濅负鈥滃悇绉嶈繍杈撴柟寮忓娴侀噺璧板娍鍥撅紝楂橀�熷拰鍥界渷閬撹溅娴侀噺璧板娍鍥锯�濄�傛渶缁堟鏂囧垎涓轰笁閮ㄥ垎锛歚涓�銆佹�讳綋鎯呭喌`锛堝綋鏃ユ�诲娴併�佺疮璁℃�诲娴併�佽繍杈撴柟寮忓崰姣旓級锛沗浜屻�佸悇绉嶈繍杈撴柟寮忓娴侀噺璧板娍`锛堣嚜椹俱�侀搧璺�佹按璺�佹皯鑸�侀亾璺紝鍏� 5 绉嶆柟寮忥級锛沗涓夈�佽溅娴侀噺璧板娍`锛堥珮閫熴�佹櫘閫氬浗鐪侀亾銆佽溅娴佹�婚噺銆佸叕璺嚭琛岀粨鏋勶級銆傞檮褰曞垎涓烘瘡鏃ュ娴佹瀯鎴愬彉鍖栥�佸悓姣旇蛋鍔裤�佽娴嬬珯璺綉杩愯銆傝嚜椹句綔涓鸿繍杈撴柟寮忎箣涓�锛屼娇鐢ㄩ潪钀ヤ笟鎬ц嚜椹炬姌绠楀娴侊紝鍗曚綅涓囦汉娆★紱楂橀��/鏅�氬浗鐪侀亾/杞︽祦鎬婚噺鍗曚綅涓囪締娆°��
+- **瀹炵幇**锛氭柊澧� `HolidayTrendService`銆乣HolidayTrendChartService`銆乣HolidayTrendWordService` 鍜� `ObservationDailyOperation`锛屼娇鐢� JFreeChart 鐢熸垚 17 寮� PNG锛沇ord 浠嶄互 `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx` 涓烘ā鏉匡紝閲嶅缓姝f枃銆佷繚鐣欐ā鏉块〉鐪夐〉鑴氬拰鍥剧墖灏哄锛屾浛鎹�/杩藉姞濯掍綋骞堕噸寤哄浘鐗囧叧绯汇�傛柊澧炲悗绔帴鍙� `GET /api/holiday/trend?year=2026&holidayType=NATIONAL_DAY`锛涘墠绔妭鍋囨棩缁熻椤垫柊澧炩�滀笅杞借蛋鍔垮浘鈥濇寜閽��
+- **鏁版嵁涓庡彛寰�**锛�2024/2025/2026 涓夊勾锛屾í杞村浐瀹� 8 澶╋紱2024銆�2026 鏃犵 8 澶╂暟鎹椂鐣欑┖锛�2025 鏄剧ず绗� 8 澶┿�傜疮璁℃洸绾垮彧杩炴帴瀹為檯宸叉湁鏃ユ湡锛岀己鏃ヤ笉寤剁画鏈�鍚庣疮璁″�笺�傝娴嬬珯闄勫綍鎸� 2026-10-01 鑷� 10-08 鏌ヨ姣忔棩骞冲潎鏈哄姩杞﹂�熷害鍜屽钩鍧囨嫢鎸ゅ害锛�2026 褰撳墠瑕嗙洊 10-01/02/03銆�
+- **楠岃瘉**锛歚mvn test` 鍏ㄩ�氳繃锛涘墠绔� `npm run build` 鎴愬姛锛堜粎 bundle 浣撶Н鍘熸湁璀﹀憡锛夈�傜湡瀹炲簱鏁版嵁鐢熸垚 `_tmp_holiday_verify/holiday_trend_2026-10-04_v3.docx`锛學ord 缁熻 12 椤点��17 寮犲浘锛涗笅杞芥帴鍙h繑鍥炵害 399 KB DOCX銆傛湭鎵ц浠讳綍鏁版嵁瀵煎叆銆佽鐩栨垨閲嶅銆�
+- **鏈窡韪」**锛氳蛋鍔垮浘妯℃澘鍜� `_tmp_holiday_verify/` 浠嶄负鏈窡韪枃浠讹紱鏈湴鍙繍琛岋紝鑻ュ悗缁彁浜�/閮ㄧ讲闇�鎶婃ā鏉跨撼鍏ラ儴缃插寘鎴栦粨搴撱�傜姸鎬侊細澶栬闂宸茬敱鏈棩涓崍娈典慨澶嶏紝鏈�缁堢寰呯敤鎴烽獙鏀躲��
+
+### 10-04 涓婂崍锛�10 鏈� 3 鏃ユ暟鎹鍏ュ苟鐢熸垚蹇姤
+
+- **婧愭枃浠�**锛歚D:\03_鍏徃\02_椤圭洰\27_婀栧寳鐪侀亾璺繍杈撲簨涓氬彂灞曚腑蹇僜10_涓氬姟\99_涓撻」缁熻\01_閲嶈鑺傚亣鏃2026\鍗佷竴\10鏈�3鏃2026骞村崄涓�鏁版嵁10鏈�3鏃�.xlsx`锛�2026-10-04 10:11 鏇存柊锛夛紱瑙傛祴绔欏師濮嬫暟鎹负鍚岀洰褰� `瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹�2026.10.3.xls`銆�
+- **瀵煎叆杩囩▼锛堜慨姝o級**锛氱敤鎴峰厛浜� 10:13:34 瀵煎叆涓绘暟鎹紱鍔╂墜闅忓悗鍦� 10:17:41 鍙堢敤鍚屼竴鏂囦欢閲嶅瀵煎叆浜嗕竴娆°�傛帴鍙h繑鍥� `passengerRows=14, vehicleRows=6, skippedRows=24, successRows=20, failRows=0`锛屾湭妫�娴嬪埌 10 鏈� 1銆�2 鏃ュ巻鍙叉暟鎹彉鍖栥�傚敮涓�閿懡涓悗浠呮洿鏂� `updated_at`锛屾病鏈夋柊澧為噸澶嶈銆�
+- **10 鏈� 3 鏃ュ叆搴撳��**锛氶搧璺� 72.4銆佹皯鑸� 5.7571銆侀亾璺� 73.12銆佹按璺� 5.5668 涓囦汉娆★紱楂橀�� 193.3634銆佹櫘閫氬浗鐪侀亾 323.7837 涓囪締娆★紱鍚堣浜哄憳娴佸姩閲� 1397.99694 涓囦汉娆°��
+- **瑙傛祴绔欐暟鎹紙淇锛�**锛氱敤鎴峰厛瀵煎叆锛涘姪鎵嬮噸澶嶅鍏ユ椂鎸夊悓鏃ヨ鐩栭噸鎻掞紝褰撳墠 2026-10-03 涓� 691 琛� / 691 涓敮涓�閿紝鏃犻噸澶嶏紱涓皬瀹㈡祦閲忓悎璁� 3,237,837 杈嗘锛�323.7837 涓囪締娆★級锛屼笌鏅�氬浗鐪侀亾鍏ュ簱鍊间竴鑷淬��
+- **蹇姤**锛氬凡鐢熸垚 `C:\Users\jcxiong\Downloads\婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ワ紙10鏈�3鏃ワ級.docx`銆傛鏂囷細褰撴棩 1398.00 涓囦汉娆★紝鐜瘮涓嬮檷 3.3%锛屽悓姣� 2025 涓嬮檷 10.1%锛屽悓姣� 2024 涓嬮檷 5.0%锛涚疮璁� 4486.63 涓囦汉娆★紝鍚屾瘮 2025 涓嬮檷 6.3%锛屽悓姣� 2024 涓嬮檷 2.7%銆�
+- **浜哄伐绋挎瘮瀵癸紙10-04 鏇存柊锛�**锛氭墜宸ョ涓庣郴缁熶慨姝g増鎻愬彇鏂囨湰閫愯涓�鑷达紱绯荤粺鍘熺増浠呮湁涓ゅ绱鏁板樊寮傦紝宸查殢鏈浠g爜淇娑堥櫎銆備粖鏃ヨ蛋鍔垮浘鍔熻兘鏆傚仠銆�
+
+### 10-04 涓婂崍锛�2锛夛細蹇姤绱鏁版寜 Excel 浜垮崟浣嶆樉绀虹簿搴﹀悗鍐嶆崲绠�
+
+- **闂**锛氱郴缁熷師鐗堜笌鎵嬪伐绋夸粎涓ゅ宸紓锛氶潪钀ヤ笟鎬у皬瀹㈣溅绱 `3961.76` 搴斾负 `3961.77`锛涘叕璺悎璁$疮璁� `4189.31` 搴斾负 `4189.32`銆�
+- **鏍瑰洜**锛氭簮琛� `浜哄憳娴佸姩閲忔眹鎬�!X5/AA5`锛堟�昏瀵瑰簲 `U5`锛夊瓨鐨勬槸浜垮崟浣嶏紝鍗曞厓鏍兼牸寮忎负 `0.0000000_`銆備汉鍏堢湅鍒� 7 浣嶅皬鏁扮殑鏄剧ず鍊硷紝鍐嶆崲绠楁垚涓囦汉娆″苟淇濈暀 2 浣嶏紱鏃т唬鐮佺洿鎺ユ嬁鍘熷绱鍊兼崲绠楀悗鍙� 2 浣嶏紝婕忔帀浜� Excel 鏄剧ず杩欎竴姝ャ��
+- **淇**锛歚HolidayBulletinService` 鏂板 `displayedCumulativeWan(...)`锛屽厛鎸夋簮鍗曞厓鏍� 7 浣嶅皬鏁板彇鏁达紝鍐嶄箻 10000锛屾渶鍚庢寜蹇姤 2 浣嶈緭鍑猴紱搴旂敤浜庢�昏銆侀潪钀ヤ笟鎬у皬瀹㈣溅銆佸叕璺悎璁′笁澶勭疮璁℃暟銆�
+- **楠岃瘉**锛氭柊澧炲崟娴嬭鐩� `3961.76472 -> 3961.77`銆乣4189.31472 -> 4189.32`銆乣4486.62652 -> 4486.63`锛沗mvn test` 鍏ㄩ�氳繃銆傞噸鍚悗鍩轰簬鐜版湁搴撻噸鏂扮敓鎴� 10 鏈� 3 鏃ュ揩鎶ワ紝涓庢墜宸ョ鎻愬彇鏂囨湰閫愯姣斿缁撴灉涓� `NO DIFFERENCES`銆傛湰娆℃湭鎵ц浠讳綍鏁版嵁瀵煎叆銆�
+- **浜х墿**锛歚_tmp_holiday_verify/bulletin_cumulative_display_2026-10-03.docx`銆�
+- **鐘舵��**锛氬緟鐢ㄦ埛楠屾敹/鎻愪氦锛涜蛋鍔垮浘鍔熻兘浠嶆殏鍋溿��
+
+### 10-04 涓婂崍锛�3锛夛細蹇姤鍏朵綑 Excel 鏄剧ず绮惧害璺緞瀹¤
+
+- **瀹¤鑼冨洿**锛氶�愰」鏄犲皠蹇姤姝f枃鍜岃〃鏍肩殑鎵�鏈夋暟鍊兼潵婧愶紝鏍稿婧愬伐浣滅翱鐨勫疄闄呭崟鍏冩牸鏍煎紡銆丒xcel 鏄剧ず鍊煎拰浠g爜浼犲叆鐨勬樉绀虹簿搴︺��
+- **缁撹**锛氶櫎宸蹭慨澶嶇殑绱鍊� `U/X/AA` 澶栵紝娌℃湁鍙戠幇鍏朵粬鏈鐞嗙殑蹇姤鍙栧�艰矾寰勩�傛簮琛ㄧ洿鎺ュ弬涓庡揩鎶ョ殑 `0.00%` 鍗曞厓鏍煎叡 11 澶勶細`浜哄憳娴佸姩閲忔眹鎬籤 D/P/J銆乂/W/Y/Z/AB/AC锛岄亾璺� H锛堝悓姣�2024骞达級锛屾按璺� D锛堝綋鏃ョ幆姣旓級锛涗唬鐮佸潎鍏堟寜婧愭牸寮忓彇 2 浣嶏紝鍐嶆寜蹇姤 1 浣嶅鐞嗐��
+- **琛ヤ慨杈圭晫**锛氶亾璺� H 鍘熸寜 1 浣嶆簮鏍煎紡澶勭悊锛�10 鏈� 3 鏃ョ宸т粛涓� `6.6%`锛涙敼涓哄厛鎸� `0.00%` 寰楀埌 `6.62%` 鍚庡啀鍙� 1 浣嶏紝杈圭晫鍊间笉浼氬樊 0.1 涓櫨鍒嗙偣銆�
+- **鏈撼鍏ョ洿鎺ュ揩鎶ヨ矾寰勭殑鏍煎紡**锛歚鏅�氬浗鐪侀亾` E/N 铏戒负 `0.00%`锛屽揩鎶ヤ娇鐢ㄧ殑鏄� `浜哄憳娴佸姩閲忔眹鎬籤 姹囨�诲悗鐨� P/R/T锛屼笉浣跨敤璇ヤ袱鍒楋紱楂橀�熷叕璺�/鏅�氬浗鐪侀亾鍐呴儴 4 浣嶅皬鏁颁汉鍛樻祦鍔ㄩ噺鍚岀悊鍙綔涓烘簮鍏紡涓棿鍒楋紝涓嶇洿鎺ヨ緭鍑恒��
+- **楠岃瘉**锛歚mvn test` 鍏ㄩ�氳繃锛涢噸鍚悗绔悗閲嶆柊鐢熸垚 10 鏈� 3 鏃ュ揩鎶ワ紝涓庢墜宸ョ閫愬潡銆侀�愬崟鍏冩牸姣旇緝鏃犱换浣曞樊寮傘��10 鏈� 2 鏃ヤ笌鎵嬪伐绋跨殑鍐呭鍜岃〃鏍兼枃鏈竴鑷达紝浠呮湁 Word run 鍒嗘鏍煎紡宸紓銆俙_tmp_holiday_verify/bulletin_after_road_format_2026-10-01/02/03.docx` 涓烘湰杞牳瀵逛骇鐗┿��
+- **鏁版嵁鎿嶄綔**锛氬彧璇绘牳瀵癸紝鏈鍏ャ�佽鐩栨垨閲嶅浠讳綍鏁版嵁銆�
+- **鐘舵��**锛氬緟鐢ㄦ埛楠屾敹/鎻愪氦銆�
+
+### 10-04锛氬揩鎶ョ櫨鍒嗘瘮鎸� Excel 鍗曞厓鏍兼樉绀虹簿搴﹀悗鍐嶅彇 1 浣�
+
+- **鐢ㄦ埛纭鐨勪笟鍔″彛寰�**锛氫汉宸ユ牳瀵逛互 `2026骞村崄涓�鏁版嵁.xlsx` 鍗曞厓鏍煎疄闄呮樉绀哄�间负鍑嗐�傛瘮濡傛�昏绱鍚屾瘮 2025 搴曞眰绮剧‘鍊间负 `-4.448602%`锛孍xcel 鍗曞厓鏍兼牸寮忎负 `0.00%`锛屼汉鐪嬪埌骞剁敤浜庢牳瀵圭殑鏄� `-4.45%`锛屽啀鍙栧揩鎶� 1 浣嶅皬鏁板氨鏄� `-4.5%`銆傛鍓嶁�滅洿鎺ユ寜搴曞眰鍊煎彇涓�浣嶅緱鍒� -4.4%鈥濈殑缁撹宸蹭綔搴熴��
+- **瀹炵幇**锛歚HolidayBulletinService` 鏂板 `bulletinPercent(...)`锛屼弗鏍兼寜鐧惧垎姣旀潵婧愬崟鍏冩牸鏍煎紡鍏堝彇鏄剧ず绮惧害锛屽啀鍙栧揩鎶� 1 浣嶅皬鏁帮紱姝f枃鎺緸涓庤〃鏍肩櫨鍒嗘瘮鍏辩敤鍚屼竴閫昏緫銆傚綋鍓嶆簮琛ㄩ�愬垪鍙e緞锛氬鏁版簮鍗曞厓鏍间负 `0.0%`锛沗浜哄憳娴佸姩閲忔眹鎬籤 鐨勫綋鏃ョ幆姣� D/P/J銆佺疮璁″悓姣� V/W/Y/Z/AB/AC锛屼互鍙婃按璺綋鏃ョ幆姣� D 涓� `0.00%`銆�
+- **棰嗗鍒嗘瀽鎶ュ憡鏍稿瑙勫垯**锛氶瀵煎垎鏋愭姤鍛婁笉濂楃敤蹇姤鐨� 1 浣嶅皬鏁帮紝涓�寰嬫寜璇ユ姤鍛婅嚜韬樉绀虹殑灏忔暟浣嶆牳瀵癸紝纭繚涓庝汉宸ュ湪鏂囨。閲岀湅鍒扮殑鍊间竴鑷淬��
+- **楠岃瘉**锛氭柊澧�/鏇存柊鍗曞厓娴嬭瘯瑕嗙洊 `-4.448602%` 鐨� `-4.4%`锛�1 浣嶆簮鏍煎紡锛変笌 `-4.5%`锛�2 浣嶆簮鏍煎紡锛夛紝浠ュ強 `-0.045% -> -0.05% -> -0.1%` 杈圭晫锛涘悗绔� `mvn test` 鍏ㄩ儴閫氳繃銆傞噸鍚� 8090 鍚庣敤鐪熷疄搴撴暟鎹敓鎴� 2026-10-02 蹇姤锛屾鏂囧拰琛ㄦ牸涓庡凡浜哄伐鏍稿鐨勬ā鏉块�愰」涓�鑷达紝鎬昏绱鍚屾瘮 2025/2024 涓� `-4.5% / -1.6%`銆�
+- **鏈湴楠岃瘉浜х墿**锛歚_tmp_holiday_verify/bulletin_excel_display_rounding_2026-10-02.docx`锛涘姛鑳芥祴璇曟姤鍛婁笌宸ヤ綔鏃ョ粨鎸夋湰鍦扮暀妗h鍒欏啓鍏� `docs/鍔熻兘娴嬭瘯鎶ュ憡/`銆乣docs/宸ヤ綔鏃ョ粨/`锛屼笉鍏ュ簱銆�
+- **鐘舵��**锛氫唬鐮佸拰鍙e緞璇存槑闅忔湰娆℃彁浜ゆ帹閫侊紱鏈嶅姟淇濇寔杩愯锛岃闂� `http://localhost:8080`銆�
+
+## 鏈�鏂帮紙2026-10-03锛�
+
+### 銆愭柊瀵硅瘽鍏堣銆戝綋鍓嶇姸鎬佷笌涓嬩竴姝ワ紙2026-10-03 娣卞鍐欏叆锛�
+
+- **浠g爜鐘舵�侊紙宸叉帹閫侊級**锛歚HEAD = origin/main = f329f50`锛屽唴瀹逛负 `feat(observation): 鏂板瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹鍏ヤ笌鏄庣粏鏌ョ湅`锛岀敤鎴峰凡銆岄獙鏀堕�氳繃銆嶃�傚伐浣滃尯闄ゆ湰 HANDOFF.md 鐨勪氦鎺ユ洿鏂帮紙灏氭湭鎻愪氦锛岀瓑鐢ㄦ埛纭锛夊锛屾棤鍏朵粬宸茶窡韪枃浠舵敼鍔紱浠呭墿鏈窡韪殑 `_tmp_doc_compare/`銆乣_tmp_holiday_verify/` 鍜� `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹� (2026骞�10鏈�1鏃�).xls`锛堣繃绋嬩骇鐗╀笌婧愭暟鎹紝鎸夌害瀹氫繚鐣欍�佷笉鍏ュ簱銆佷笉鍒犻櫎锛夈�俙docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鏁版嵁.xlsx` 宸茬敱鐢ㄦ埛鏀瑰洖鍘熷悕锛屾ā鏉夸笅杞芥帴鍙e疄娴� HTTP 200 / 36335 瀛楄妭銆�
+- **鏈嶅姟鐘舵��**锛氬悗绔� 8090銆佸墠绔� 8080 鍧囧湪杩愯锛岃闂� `http://localhost:8080`锛涚櫥褰� admin/admin123銆傛暟鎹簱 `127.0.0.1:3305/traffic_audit`锛坮oot/root锛夈��
+- **搴撳唴鏁版嵁**锛氳妭鍋囨棩 2026-09-30锛堥搧璺�/姘戣埅鍓嶄竴鏃ワ級銆�2026-10-01銆�2026-10-02 宸插鍏ワ紱**2026-10-03 灏氭湭瀵煎叆**銆傝娴嬬珯 `observation_station_flow` 鐜版湁 2026-10-01锛�692 绔欙級涓� 2026-10-02锛�689 绔欙級銆�
+- **涓嬩竴姝ワ紙2026-10-04 鏃╀笂锛�**锛�10 鏈� 3 鏃ユ暟鎹嚭鏉ュ悗锛屾寜鏃㈡湁鍙e緞瀵煎叆 2026-10-03 骞剁敓鎴愬綋澶┿�屾箹鍖楃渷鍥藉簡鍋囨湡缁煎悎杩愯緭瀹㈡祦蹇姤銆嶃�傞瀵艰姹� 8:30 鍓嶅嚭绗竴鐗堬紝鑰屾皯鑸暟鎹彲鑳芥櫄浜� 8:30 鎵嶆湁锛氭皯鑸湭鍒板氨鍏堝嚭缂烘皯鑸増鏈紙绱鍚屾瘮鍙笌寰�骞村凡瑕嗙洊鏃ユ湡姣旇緝锛夛紝姘戣埅鍒伴綈鍚庨噸鏂板鍏ュ苟閲嶆柊鐢熸垚銆傚畬鏁村彛寰勪笌宸查獙璇佺粨璁鸿鏈枃浠� 10-03 鍚勬銆�
+- **鏃犺嚜鍔ㄥ寲**锛氭浘寤虹殑蹇冭烦鎻愰啋锛坕d `10-03`锛夊凡鎸夌敤鎴疯姹傚垹闄わ紝鐢ㄦ埛 2026-10-04 鏄庣‘銆屽埌鏃舵垜浼氭墜宸ユ搷浣溿�嶃�傛槑澶╂棭涓婄敱鐢ㄦ埛鎵嬪伐瀵煎叆 10-03 鏁版嵁骞剁敓鎴愬揩鎶ワ紝鍔╂墜鍙湪鍏舵彁鍑烘椂閰嶅悎鏍稿/鎺掓煡銆備笉瑕佸湪鏈幏鐢ㄦ埛鏄庣‘瑕佹眰鏃惰嚜琛屽垱寤哄畾鏃朵换鍔℃垨鑷姩鍐欏簱銆�
+- **鏁版嵁鍙戠幇锛堜緵鍙傝�冿紝鏃犻渶鏀逛唬鐮侊級**锛�2026-10-02 姣� 10-01 灏� 3 涓娴嬬珯鈥斺�擿G348L149420501` 鍦熼棬锛堝疁鏄屽競/甯傝緰鍖猴級銆乣S464L105422828` 澶у吀娌筹紙鎭╂柦宸�/楣ゅ嘲鍘匡級銆乣S483L105420801` 灞堝宀紙鑽嗛棬甯�/甯傝緰鍖猴級銆傚凡鏍稿涓や唤婧� Excel锛岀‘灞炴簮鏁版嵁褰撳ぉ鏈笂鎶ワ紝涓嶆槸瀵煎叆婕忚銆�
+- **闀挎湡寰呭姙**锛氳涓嬫柟绗竷鑺傘�屾暟鍊肩簿搴︾粺涓�鏀归�犮�嶏紙鏃㈡湁 16 寮犺〃銆�228 涓� double 鍒楁敼 `DECIMAL(30,15)` + Java `BigDecimal`锛夈��
+
+### 10-03 鏅氾細閲嶈鑺傚亣鏃ユ柊妯℃澘锛�2019/鍓嶄竴鏃ワ級閫傞厤
+- **妯℃澘鍙樺寲**锛歚2026骞村崄涓�鏁版嵁.xlsx` 鐨勯亾璺〃澧炲姞 2019 骞村強鍚屾瘮2019锛涢搧璺�佹皯鑸鍔犫�滃墠涓�鏃モ�濊锛涘揩鎶ユā鏉垮鍔犲悓姣�2024 鍒椼�傛棫妯℃澘宸叉斁鍦� `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/澶囦唤/`銆�
+- **鏁版嵁搴撳垵濮嬪寲**锛氬惎鍔ㄦ椂鐩存帴鍐欏叆 2019 閬撹矾鏁版嵁锛屼互鍙� 2024/2025/2026 骞撮搧璺�佹皯鑸墠涓�鏃ユ暟鎹紱`holiday_calendar.day_index=0` 琛ㄧず鈥滃墠涓�鏃モ�濓紝鏃у簱鍚姩浼氳嚜鍔ㄨˉ榻愬凡鏈夊勾浠界殑鍓嶄竴鏃ラ厤缃锛屼笖涓嶈鐩栫鐞嗗憳宸茬淮鎶ょ殑鏃ユ湡銆�
+- **姝e父瀵煎叆鍙e緞**锛氬彧璇诲彇鎵�閫夊勾浠界殑褰撳勾鍒楋紙2026 骞撮搧璺�/姘戣埅/閬撹矾/姘磋矾涓� C 鍒椼�侀珮閫熶负 C 鍒椼�佹櫘閫氬浗鐪侀亾涓� D 鍒楋級锛涘悓鏃跺彧棰濆璇诲彇褰撳墠骞翠唤閾佽矾/姘戣埅鐨勨�滃墠涓�鏃モ�濊銆�2019/2024/2025 鍘嗗彶鍒椾笉鍙備笌瀵煎叆銆�
+- **蹇姤鍙e緞**锛氬悓姣斿彧浣跨敤 2025銆�2024锛涘亣鏈熺 1 鏃ョ殑閾佽矾銆佹皯鑸幆姣斾娇鐢ㄢ�滃墠涓�鏃モ�濇暟鎹紝鍏矾銆佹按璺�佹�昏鍙婂叾浠栧彛寰勭 1 鏃ヤ粛鏄剧ず鈥溾�斺�濓紱绗� 2 鏃ヨ捣姝e父璁$畻鐜瘮銆�
+- **杩佺Щ鑴氭湰**锛歚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 搴� + 鐪熷疄妯℃澘锛�**锛氭帴鍙e鍏ョ粨鏋� `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锛堥噸鍚悗宸插姞杞芥柊浠g爜锛夈�佸墠绔� 8080 鍧囧湪杩愯锛涜闂� `http://localhost:8080`銆�
+
+### 10-03 娣卞锛氬揩鎶ヨ〃鏍肩櫨鍒嗘瘮涓庢ā鏉挎牱寮忎慨澶嶏紙宸查獙鏀讹級
+- **闂**锛氱敓鎴愬揩鎶ヨ〃鏍间娇鐢ㄢ�滃闀�/涓嬮檷鈥濇枃瀛楋紝鏁板瓧鎷嗚骞跺鍑� 1 椤碉紱澶嶆牳鍙戠幇妯℃澘璺緞姝g‘锛屾牴鍥犳槸 `HolidayBulletinService#setCellText` 鏇挎崲鍗曞厓鏍兼椂鍙繚鐣欐钀藉睘鎬э紝鏈繚鐣欐ā鏉� run 鐨勫瓧浣�/瀛楀彿灞炴�с��
+- **鍙e緞**锛氳〃鏍肩幆姣�/鍚屾瘮鏀逛负甯︾鍙蜂竴浣嶅皬鏁帮紙姝f暟涓嶅姞 `+`锛岃礋鏁颁繚鐣� `-`锛夛紝闆舵垨涓�浣嶅皬鏁板悗涓洪浂鏄剧ず `0%`锛岀己澶�/鍩烘暟涓虹┖鎴栦负闆舵樉绀� `鈥擿锛涙鏂囩户缁娇鐢ㄢ�滃闀�/涓嬮檷鈥濄��
+- **淇**锛氬崟鍏冩牸鏇挎崲鏃剁户鎵挎ā鏉� `rPr`锛涙柊澧� `HolidayBulletinServiceTest` 瑕嗙洊姝f暟銆佽礋鏁般�侀浂銆佸洓鑸嶄簲鍏ヤ负闆躲�佺己澶卞拰闆跺熀鏁般��
+- **楠岃瘉**锛歁aven 缂栬瘧鍜屾祴璇曢�氳繃锛涢噸鏂扮敓鎴� 2026-10-01 蹇姤锛屾鏂囦粛涓衡�滃闀�2.3%鈥濅笖涓嶅惈鈥滃闀夸簡鈥濓紱Word 瀵煎嚭鐢� 2 椤垫仮澶嶄负 1 椤碉紝鏁板瓧涓嶅啀鎷嗚銆傛渶缁堟牱鎶ヨ鏈湴 `_tmp_holiday_verify/bulletin_final_signed_styled_2026-10-01.docx`銆�
+- **鐘舵��**锛�2026-10-03 鐢ㄦ埛楠屾敹閫氳繃锛涙湰娆℃彁浜ゅ彧鍖呭惈浠g爜銆佸洖褰掓祴璇曞拰鏈氦鎺ヨ褰曪紝涓嶅寘鍚� `_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`锛堜粎浣滅敤浜庢皯鑸锛夛紝鍏朵綑琛屼笌鎬讳汉鍛樻祦鍔ㄩ噺鍙e緞涓嶅彉銆�
+- **銆岀疮璁″悓姣� -4.65% vs 蹇姤 -4.6%銆嶇粨璁猴紙2026-10-03锛屾棤闇�鏀逛唬鐮侊級**锛欵xcel 鏄剧ず鐨� -4.65% 鏄�**涓や綅灏忔暟**缁撴灉锛涘簳灞傜簿纭瘮鍊兼槸 `-4.64633%`锛�2026 绱 3082.23818銆�2025 绱 3232.42742锛夛紝**涓�娆″洓鑸嶄簲鍏ュ埌 1 浣嶅皬鏁板氨鏄� -4.6%**銆傛妸宸茬粡鍙栧埌涓や綅灏忔暟鐨� -4.65 鍐嶅彇涓�浣嶆墠寰楀埌 -4.7锛屽睘浜屾鍙栨暣锛屼笉閲囩敤銆傚悓鐞� 2026 瀵� 2024 涓� `-1.7710%` 鈫� `-1.8%`銆傚揩鎶ョ櫨鍒嗘瘮缁熶竴 1 浣嶅皬鏁板彛寰勪笉鍙樸��
+ - **2026-10-04 淇**锛氭寜 Excel 鍗曞厓鏍兼樉绀虹簿搴﹀彛寰勶紝`-4.65%` 鍐嶅彇涓�浣嶅簲涓� `-4.7%`锛涗互鏂囦欢椤堕儴鏈�鏂版涓哄噯銆�
+- **銆屾殏缂烘皯鑸�嶆彁绀�**锛氱敤鎴风‘璁ょ敱浜哄伐鍦ㄧ敓鎴愮殑蹇姤绋夸笂淇敼锛岀郴缁熶笉鐢熸垚璇ユ彁绀鸿锛�**涓嶅仛浠g爜鏀瑰姩**銆�
+- **楠岃瘉**锛歚HolidayBulletinServiceTest` 3 涓敤渚嬮�氳繃锛堟柊澧� 2 涓鐩栨皯鑸疮璁″姣旀湡闂翠笌鏃犳暟鎹繑鍥� null锛夛紱绔埌绔敤搴撳唴鐪熷疄 2026-10-02 鏁版嵁锛堥搧璺�/閬撹矾/姘磋矾/杞︽祦榻愬叏銆佺己姘戣埅锛夐噸鏂扮敓鎴愬揩鎶ワ紝姘戣埅绱鍚屾瘮 `-10.0%/-0.9%`銆佹皯鑸疮璁� `6.75`銆佹�昏绱 `3082.24`銆佹�昏绱鍚屾瘮 `-4.6%/-1.8%`锛屼笌鎵嬪伐绋垮彛寰勪竴鑷淬��
+- **Git**锛氭湰娆℃敼鍔ㄥ緟鐢ㄦ埛銆岄獙鏀堕�氳繃銆嶅悗鍐嶆彁浜�/鎺ㄩ�併��
+
+### 10-03 娣卞锛�3锛夛細蹇姤绱姝f枃鍗曚綅鐢便�屼嚎浜烘銆嶆敼銆屼竾浜烘銆嶏紙寰呴獙鏀讹級
+
+- **鍙e緞锛堢敤鎴�/棰嗗纭锛�**锛氬亣鏈熺疮璁℃暟鍊肩洰鍓嶅亸灏忥紙0.31 浜匡級锛屾鏂囧彲璇绘�у樊锛屾敼涓轰笌褰撴棩涓�鑷寸殑**涓囦汉娆�**锛涗緥濡� `鍋囨湡浠ユ潵锛屽叏鐪佸叏绀句細璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺绱3088.63涓囦汉娆锛屼笉鍐嶈緭鍑恒��0.31浜夸汉娆°�嶃�傝〃鏍间笌鍏朵綑鍙e緞涓嶅彉銆�
+- **瀹炵幇**锛歚HolidayBulletinService` 鎶藉嚭 `cumulativeParagraph(...)`锛岀洿鎺ユ寜涓囦汉娆★紙2 浣嶅皬鏁帮級杈撳嚭绱鍊硷紱鍚屾瘮浠嶄负銆屽闀�/涓嬮檷 + 涓�浣嶅皬鏁般�嶏紝**淇濇寔涓�娆″彇鏁翠笉鍙�**锛堣澶� 2026-10-04 宸蹭慨姝o細婧愬崟鍏冩牸涓� `0.00%`锛孍xcel 鏄剧ず -4.45%锛屽啀鍙栦竴浣嶄负 -4.5%锛涗互鏂囦欢椤堕儴鏈�鏂版涓哄噯锛夈��
+- **楠岃瘉**锛歚HolidayBulletinServiceTest` 4 涓敤渚嬮�氳繃锛堟柊澧� `cumulativeParagraphUsesWanUnitAndSingleRounding` 鍥哄寲銆屼竾浜烘 + 涓�娆″彇鏁淬�嶏級锛涢噸鍚悗绔悗閲嶆柊鐢熸垚 2026-10-02 蹇姤锛屾鏂囦负 `绱3088.63涓囦汉娆★紝姣�2025骞村悓鏈熶笅闄�4.4%锛屾瘮2024骞村悓鏈熶笅闄�1.6%`锛屼笌鎵嬪伐绋夸粎宸棦瀹氱殑鍙栨暣鍙e緞锛�2026-10-01 鍥炲綊姝e父锛坄绱1643.17涓囦汉娆锛夈��
+- **鐘舵��**锛氬緟鐢ㄦ埛銆岄獙鏀堕�氳繃銆嶅悗鎻愪氦/鎺ㄩ�併��
+
+### 10-03 鏅氾紙4锛夛細鏂板銆岃娴嬬珯鍒嗘棩璋冩煡鏁版嵁銆嶅師濮嬫暟鎹鍏ワ紙寰呴獙鏀讹級
+- **鑳屾櫙锛堢敤鎴� 2026-10-03 鎻愬嚭锛�**锛氭櫘閫氬浗鐪侀亾杞︽祦閲忕殑鏉ユ簮涔嬩竴鏄娴嬬珯鍒嗘棩璋冩煡鏁版嵁锛堟ā鏉� `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹� (2026骞�10鏈�1鏃�).xls`锛屽叾涓瓧娈点�屼腑灏忓娴侀噺銆嶅嵆鏅�氬浗鐪侀亾杞︽祦閲忔潵婧愶級銆�**绯荤粺鍐呬笉鍋氬叧鑱�**锛岃妭鍋囨棩缁熻浠嶈蛋鍘熸湁瀵煎叆锛涙湰鍔熻兘鍙仛鍘熷鏁版嵁鐣欏瓨锛屼负鍚庣画鍒嗘瀽鎸栨帢绉疮鏁版嵁鍩虹銆�
+- **鏁版嵁琛�**锛氭柊澧� `observation_station_flow`锛孌DL 瑙� `docs/sql_observation_station.sql`锛岄儴缃插寘鍚屾涓� `deploy-templates/db/sql_observation_station.sql`銆備繚鐣欐ā鏉垮叏閮� 26 鍒楋紙瑙傛祴绔欑紪鍙�/鍚嶇О/瑙傛祴閲岀▼/鍦板競/鍖哄幙/琛岄┒鏂瑰悜 + 鍚勮溅鍨嬫祦閲� + 瀹㈣溅/璐ц溅/姹借溅/鏈哄姩杞︽祦閲� + 鍚勭被褰撻噺 + 鏈哄姩杞﹂�熷害 + 鎷ユ尋搴� + 鏉ユ簮鏂囦欢鍚嶏級銆傚敮涓�閿� `(obs_date, station_code, direction)`锛�**鏁板�煎垪涓�寰� `DECIMAL(30,15)`**锛孞ava 瀹炰綋瀛楁涓�寰� `BigDecimal`锛屼笌鑺傚亣鏃ヨ〃鍚屽彛寰勶紝鏃� float/double銆�
+- **瀵煎叆瀹炵幇**锛氭柊澧炲寘 `com.trafficaudit.observation`锛坋ntity / 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 涓�鑷达紱鈶ゆ眹鎬诲垪琛ㄣ�岃娴嬫棩鏈熴�嶆敼涓�**姝e簭**锛坢apper `ORDER BY obs_date ASC`锛夈�傚凡鐢� 10-01/10-02 鐪熷疄鏁版嵁楠岃瘉琛屽簭涓� Excel 鍓� 5 琛屼竴鑷达紙涓府/涓囧簵/娌煎北绔�/閭惧煄绔�/鐧戒簯锛夈��
+- **鐘舵��**锛氬緟鐢ㄦ埛銆岄獙鏀堕�氳繃銆嶅悗鎻愪氦/鎺ㄩ�併��
+
+---
+
+### 琛ュ厖锛�10-03 涓嬪崍锛夛細鏈嶅姟鍣ㄩ儴缃叉姤 鈥淎ccess 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)`銆�
+- **鏍瑰洜**锛歫ar 鍐呯疆閰嶇疆閲屽簱鍙d护鏄崰浣嶇 `${TRAFFIC_DB_PASSWORD:}`锛堥粯璁ょ┖涓诧級锛岃�� 10-02 閭d釜鍖�**鍙惈 jar + web + dist**锛屾病鏈夊缃� `application.yml`銆佹病鏈� `docs/`銆乣db/`銆佸惎鍔ㄨ剼鏈�傞儴缃叉満娌℃湁浠讳綍澶栭儴閰嶇疆瑕嗙洊锛孲pring 灏辨嬁绌哄瘑鐮佽繛鏈満 MySQL 鈫� `using password: NO`锛堟敞鎰忥細杩欐槸鈥滄湯浼犲瘑鐮佲�濓紝涓嶆槸瀵嗙爜閿欙級銆�
+- **鏈嶅姟鍣ㄤ晶涓�姝ヤ慨澶嶏紙鏃犻渶閲嶆墦鍖咃級**锛氬湪 jar 鍚岀骇鏀� `application.yml`锛屾妸 `spring.datasource.url/username/password` 鏀规垚鐜板満鐪熷疄鍊煎悗閲嶅惎鍗冲彲锛涗篃鍙敤鐜鍙橀噺 `TRAFFIC_DB_PASSWORD` 浠f浛銆傚惎鍔ㄨ剼鏈凡鍔犻妫�鎻愰啋锛堢己 `application.yml` / password 浠嶆槸鍗犱綅鏂囧瓧閮戒細 WARN锛夈�備慨澶嶅悗鑻ユ姤鈥滄湭鎵惧埌鏁版嵁鐩綍 docs鈥濆垯璇存槑鍖呭唴缂� `backend/docs`锛屼粠鏂板寘鎴栨簮鏈哄櫒鎷枫��
+- **鎵撳寘宸ュ叿宸蹭慨锛堝師 鈥滄墦鍖呭伐鍏峰緟淇�� 涓ら」鍧囧凡钀藉疄锛�**锛歚pack-deploy.ps1` 閲嶅啓涓轰簲姝ワ細鈶� 鐢ㄦ柊澧炴ā鏉� `deploy-templates/jar-application.yml`锛�**鍏ㄥ崰浣嶇**锛変复鏃舵浛鎹㈡簮鐮侀厤缃悗 `mvn clean package`锛屾棤璁烘垚璐ラ兘杩樺師鐪熷疄閰嶇疆锛涒憽 瑙e帇 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锛寊ip 76.37 MB锛夛紝宸查獙璇侊細jar 鍐呯疆閰嶇疆鍏ㄥ崰浣嶇锛堝幓瀵嗚嚜妫�閫氳繃锛夈�乣backend/web` 涓� `frontend/dist` 閫愭枃浠朵竴鑷达紙鍚� 44 涓級銆乣backend/docs/鐢熸垚姹囨�诲ぇ琛╜ 姣嶇増瀛樺湪銆乣鍔熻兘娴嬭瘯鎶ュ憡/宸ヤ綔鏃ョ粨/闂姹囨��/_cache` 宸叉帓闄ゃ��
+- **韪╁潙锛堥噸瑕侊級**锛氭湰鏈� PowerShell 5.1 鎶婃棤 BOM 鐨� `.ps1` 褰� ANSI 璇伙紝涓枃娉ㄩ噴浼氬彉鎴愪贡鐮佸苟瀵艰嚧鑴氭湰瑙f瀽澶辫触銆俙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`锛孧ySQL 鏈嶅姟鏈仠銆�
+- **鍙戝竷鏋勫缓锛堝凡瀹屾垚锛�**锛氬簲鈥滃彂甯冪紪璇戔�濊姹傦紝宸插畬鎴愬悗绔� `mvn clean package -DskipTests` 鍜屽墠绔� `npm run build`銆俲ar 鍐呭凡鍋氬幓瀵嗚嚜妫�锛屽彧淇濈暀鐜鍙橀噺鍗犱綅绗︼紱鏋勫缓鍚庢湰鏈虹湡瀹� `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 鍗曠鍙fā寮忥紙褰撳墠闈欐�佽祫婧愯矾寰勪负 `file:./web/,classpath:/static/`锛夈�備袱澶勫凡閫愭枃浠舵瘮瀵癸紝鏂囦欢鍒楄〃涓� SHA-256 瀹屽叏涓�鑷淬��
+- **瀹夊叏鐘舵��**锛氭湰鏈虹湡瀹為厤缃凡淇濆瓨鍦ㄨ蹇界暐鐨� `traffic-audit-server/config/application.yml`锛涘彂甯� jar 涓嶅惈鐪熷疄鏁版嵁搴撳彛浠ゃ�丣WT secret 鎴� DeepSeek key銆傚彂甯冨寘涓嶅寘鍚儴缃叉満鐨勭湡瀹炲閮ㄩ厤缃紝閮ㄧ讲鏃堕渶瑕佺户缁娇鐢ㄧ幇鍦� `application.yml` 鎴栨寜妯℃澘濉啓銆�
+- **鎵撳寘宸ュ叿寰呬慨锛堥噸瑕侊級**锛歚pack-deploy.ps1` 鐩墠鐩存帴娑堣垂 `src/main/resources/application.yml`锛岃嫢璇ユ枃浠惰鏈満鐪熷疄閰嶇疆瑕嗙洊锛屼細鍦ㄥ幓瀵嗚嚜妫�闃舵涓锛涗笖瀹冨彧鎶� dist 鏀惧埌 `frontend/dist`锛屾病鏈夊悓姝ュ埌 `backend/web`锛岄粯璁ゅ寘鏃犳硶鐩存帴鐢ㄤ簬 8090 鍗曠鍙fā寮忋�傛湰娆′娇鐢ㄢ�滀复鏃跺崰浣嶇閰嶇疆 鈫� 鏋勫缓鑷 鈫� 鎭㈠鐪熷疄閰嶇疆鈥濈殑鏂瑰紡瀹屾垚銆備笅娆″彂甯冨墠寤鸿鏀逛负浠� `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锛圵indows 鏈嶅姟锛�/C 涓夋柟妗堝悗锛岀敤鎴烽�� **B**銆�
+- **鏂板妯℃澘锛堝凡鍏ュ簱锛�**锛�
+ - `deploy-templates/backend/trafficAudit.xml`锛歐inSW 鏈嶅姟閰嶇疆鈥斺�攊d `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锛圝AVA_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 鏈嶅姟銆俲ava.exe 鑷繁涓嶆槸鏈嶅姟绋嬪簭锛岀洿鎺� `sc create` 浼氭姤鈥滄湇鍔℃病鏈夊強鏃跺搷搴斿惎鍔ㄦ垨鎺у埗璇锋眰鈥濓紱WinSW 瀹炵幇浜嗘湇鍔℃帶鍒跺崗璁啀鎷夎捣 `java -jar`锛屽苟绠¢噸鍚笌鏃ュ織銆�
+- **瀹炴祴楠岃瘉锛堟湰鏈猴紝鏈湡姝e畨瑁呮湇鍔★級**锛歚trafficAudit.exe status` 鑳借鎵撳寘 XML 骞惰緭鍑� `NonExistent`锛堣В鏋愰�氳繃锛夛紱鏁呮剰鍐欏潖 XML 鈫� `FATAL - The configuration file cound not be loaded`锛堣瘉鏄庤鏍¢獙璺緞鐪熷疄鏈夋晥锛夛紱`install-service.ps1 -Status` 姝e父杈撳嚭鈥滄湭瀹夎鈥濓紝鏃犲弬鏁� / `-Remove` 鍦ㄩ潪绠$悊鍛樹笅鎶涒�滃繀椤荤鐞嗗憳鈥濅笖涓嶄骇鐢熸敞鍐岄」銆�
+- **韪╁潙锛堥噸瑕侊級**锛歑ML 鏀瑰啓鏈�鍒濈敤姝e垯 `(?s)<executable>.*?</executable>`锛屼細**鍏堝懡涓敞閲婇噷鐨� `<executable>` 瀛楁牱**锛屾妸娉ㄩ噴澶翠笌 service 寮�澶存暣娈靛悶鎺� 鈫� XML 闈炴硶銆傚凡鏀逛负 DOM锛坄SelectSingleNode('/service/executable')`锛夊苟鎶婃敞閲婇噷鐨勮8鏍囩鏀规帀銆傚彟锛氭柊澧炴墦鍖呬唬鐮佹浘澶氫竴涓� `}` 瀵艰嚧璇硶閿欙紝宸茬敤 `Parser::ParseFile` AST 澶嶆牳锛屼袱涓� ps1 鐜板湪璇硶 OK 涓斿潎涓� UTF-8 with BOM銆�
+- **鍏煎鎬э紙鐢ㄦ埛闂�淲in7/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` 鍗冲彲锛孹ML 涓嶅姩锛夛紱32 浣嶇郴缁熸崲 `WinSW-x86.exe`銆�
+- **瀹夊叏鎻愮ず**锛歐inSW 瀹樻柟浜х墿**鏈仛 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锛寊ip 83.77 MB锛夈�傚凡楠岃瘉锛氭湇鍔′笁浠跺鍦� `backend\`銆佸寘鍐� `trafficAudit.exe` SHA-256 涓庡浐瀹氬�间竴鑷淬�乣backend\web` 涓� `frontend/dist` 鍚� 44 鏂囦欢銆乯ar 鍘诲瘑鑷閫氳繃銆佹棤鏄庢枃 key銆�**鏃х殑 `traffic-audit-deploy-20261003/` 涓� `-2/` 宸茶鍙栦唬**锛岄儴缃茶鐢� `-3`锛堟棫鍖呭緟鐢ㄦ埛纭鍚庡啀鍒狅級銆�
+- **杩愯鐘舵�侊紙鍐欐湰鏉℃椂锛�**锛氬紑鍙戠敤鍚庣 8090锛圥ID 43040锛夈�佸墠绔� 8080锛圥ID 43152锛夊潎鍦ㄨ窇锛涙暟鎹簱 `127.0.0.1:3305/traffic_audit`銆�
+- **琛ュ厖鍙搷浣滄�э紙鐢ㄦ埛鍙嶉鈥渂ackend 涓嬫枃浠跺お澶氾紝涓嶇煡閬撴�庝箞鎿嶄綔鈥濓級**锛�
+ - 鏂板鍙屽嚮鍏ュ彛 `deploy-templates/backend/install-service.cmd`锛氳嚜鍔� UAC 鎻愭潈 鈫� 璋� `install-service.ps1`锛涜嫢鎵句笉鍒� `trafficAudit.exe` 鍒欐彁绀衡�滃繀椤绘暣涓� backend 涓�璧锋嫹鈥濄��
+ - `install-service.ps1` 瑁呭畬浼氳嚜宸辨帰娴嬬鍙o紙TCP 鎺㈡祴锛屽吋瀹� PowerShell 2.0锛夛紝鎵撳嵃 OK + 鏈満/瀵瑰缃戝潃锛涜捣涓嶆潵鏃剁洿鎺ユ墦鍗� `logs\*.log` 鏈�鍚� 15 琛岋紱鑻� `application.yml` 鍙d护浠嶅儚鍗犱綅绗︼紝鏈�鍚庡啀鎵撲竴娆¢啋鐩彁绀恒��
+ - `README-閮ㄧ讲璇存槑.txt` 椤堕儴鏂板銆愭湇鍔″櫒涓婂彧瑕佸仛杩� 3 姝ャ��+鈥滃摢浜涜鎷�/鍝簺涓嶇敤鎷封�濓紱`docs/鎵撳寘閮ㄧ讲璇存槑.md` 鐨� 5.4 鏀逛负鈥滃彸閿� `install-service.cmd` 鈫掍互绠$悊鍛樿繍琛屸�濄��
+ - 涓変釜 `.cmd` 妯℃澘缁熶竴涓� CRLF锛堟柊澧炵殑涓や釜鍘熸潵鏄� LF锛屾壒澶勭悊鍦� Windows 涓婂簲鐢� CRLF锛夈��
+ - 瀹炴祴锛氬己鍒惰蛋鈥滃凡鎻愭潈鈥濆垎鏀椂 `cmd 鈫� install-service.ps1 -Status` 閾捐矾璺戦�氥�乣%*` 浼犲弬姝e父锛沗Test-LocalPort` 瀵� 8090 杩斿洖 True銆佸 8099 杩斿洖 False銆�
+ - 鏂板寘锛歚deploy/traffic-audit-deploy-20261003-4/`锛�394 鏂囦欢 / 110.50 MB锛寊ip 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` 瀵嗙爜瑙f瀽宕╂簝锛屾湇鍔℃牴鏈病瑁呬笂**
+ - 鐜拌薄锛氱敤鎴峰湪鏈嶅姟鍣紙`D:\trafficAudit`锛夊弻鍑� `install-service.cmd`锛屾姤 `鏃犳硶灏嗏�淭rim鈥濈殑鍙傛暟鈥渢rimChars鈥濊浆鎹负绫诲瀷鈥淪ystem.Char鈥漙锛屼綅缃� `install-service.ps1:210`銆�
+ - 鏍瑰洜锛氶偅琛屾槸 `.Trim(' ''', '"')`锛屽叾涓� `' '''` 琚� PowerShell 瑙f瀽鎴愪竴涓�**闀垮害 2 鐨勫瓧绗︿覆**锛堢┖鏍� + 鍗曞紩鍙凤級锛宍Trim(char[])` 鏃犳硶鎶� 2 瀛楃涓茶浆鎴愬崟涓� char 鈫� 鏁磋鎶涘紓甯革紙`MethodArgumentConversionInvalidCastArgument`锛夈��
+ - 淇锛氭妸杩欐鎶芥垚绾嚱鏁� `Test-AppConfigPassword`锛屾敼鍐欎负 `Trim()` 鈫� `Trim("'")` 鈫� `Trim('"')`锛涘苟鍔犺剼鏈骇 `trap`锛屼换浣曞紓甯稿彧鎵撳嵃涓�琛岃鏄� + 琛屽彿锛屼笉鍐嶇敥绾㈠瓧鍫嗘爤銆�
+ - 楠岃瘉锛�8 涓牱鏈紙缂烘枃浠� / 绌哄�� / 涓枃鍗犱綅绗� / 瑁稿�� / 鍙屽紩鍙� / 鍗曞紩鍙� / 鍚┖鏍肩壒娈婂瓧绗� / 鐪熷疄妯℃澘鏂囦欢锛夊叏閮ㄧ鍚堥鏈燂紱鎵撳寘鍐呰剼鏈娉曡В鏋愰�氳繃銆乣-Status` 瀹炶窇姝e父銆侀潪绠$悊鍛樻椂 trap 杈撳嚭骞插噣閿欒骞� exit 1銆�
+ - 鏁欒锛氳繖鏉″垎鏀箣鍓嶆病琚换浣曟祴璇曡鐩栵紙`-Status` 鎻愬墠 exit銆侀潪绠$悊鍛樺湪 `Assert-Admin` 灏遍��鍑猴級锛屾墍浠ヨВ鏋愮被閫昏緫瑕佹娊鎴愮函鍑芥暟骞剁敤鏍锋湰娴嬨��
+ - 鍖呭悕锛氫慨澶嶇増閲嶆墦鍖呮椂鑴氭湰澶嶇敤浜嗗凡閲婃斁鐨勫熀纭�鍚嶏紝鏈�缁堝凡鍒犳帀鏃у潖鍖呭苟鏀瑰悕锛宍deploy\` 浠嶇劧鏄�**鍞竴**鐨� `traffic-audit-deploy-20261003-4\`锛堝惈淇鍚庣殑 install-service.ps1锛変笌鍚屽悕 zip銆�
+ - 鐢ㄦ埛渚ф渶灏忎慨澶嶏細鍙渶鐢ㄦ柊鍖呴噷鐨� `backend\install-service.ps1` 瑕嗙洊鏈嶅姟鍣� `D:\trafficAudit\install-service.ps1`锛屽啀閲嶈窇 `install-service.cmd`锛堜笉蹇呴噸鎷� 110MB锛夈��
+- **鏈嶅姟鍣ㄩ儴缃叉垚鍔燂紙10-03 鏅氾紝鐢ㄦ埛纭锛�**锛氭湇鍔″櫒锛堥儴缃茬洰褰� `D:\trafficAudit`锛屾妸 backend 閲岀殑鍐呭鐩存帴閾哄湪璇ョ洰褰曚笅锛夌敤淇鍚庣殑 `install-service.cmd` 瀹夎鏈嶅姟 `TrafficAuditBackend` 鎴愬姛銆�
+ - **宸查獙璇侊紙鐢ㄦ埛纭锛�**锛氣憼 鍔炲叕鐢佃剳娴忚鍣ㄨ兘鎵撳紑鏈嶅姟鍣ㄩ〉闈紙璇存槑缃戠粶/闃茬伀澧欒繖涓�灞傞�氾紝鏃犻渶棰濆鏀捐 8090锛夛紱鈶� **閲嶅惎鏈嶅姟鍣ㄥ悗浠嶈兘浠庡姙鍏數鑴戞墦寮�椤甸潰 鈫� 寮�鏈鸿嚜鍚疄娴嬬敓鏁�**锛堟湇鍔′笉渚濊禆浠讳綍璐﹀彿鐧诲綍锛夈��
+ - **浠嶅緟鍋�**锛氫笟鍔″姛鑳介獙鏀讹紙鐧诲綍 / 鏁版嵁瀵煎叆 / 鎶ヨ〃瀵煎嚭 / Word 蹇姤锛夛紱纭 `application.yml` 鐨� jwt.secret 宸叉崲鎴愭寮忛殢鏈哄�笺�侀缃� admin/admin123 宸叉敼瀵嗐��
+ - 闃茬伀澧欐斁琛岋紙鑰佺郴缁熶篃鑳界敤锛夛細`netsh advfirewall firewall add rule name="TrafficAudit 8090" dir=in action=allow protocol=TCP localport=8090`
+ - 瑁呭畬鍚庡父鐢細`install-service.ps1 -Status / -Restart / -Remove`锛涙棩蹇楀湪 `D:\trafficAudit\logs\trafficAudit.out.log`锛堥敊璇湅 `trafficAudit.err.log`锛夈��
+
+## 鏈�鏂帮紙2026-10-02锛�
+
+- **鏂版ā鍧楋紙鐢ㄦ埛宸查獙鏀讹紝鏈鎻愪氦/鎺ㄩ�侊級**锛氭柊澧炩�滀笓椤圭粺璁�-閲嶈鑺傚亣鏃モ�濇ā鍧楋紝鍚妭鍋囨棩閰嶇疆銆佽惀涓氭�у娴併�侀潪钀ヤ笟鎬ц溅娴併�佸杩愮郴鏁� 4 寮犺〃锛涚郴缁熼厤缃彍鍗曚粎 admin 鍙淮鎶わ紝涓撻」缁熻-鑺傚亣鏃ユ敮鎸佸鍏ャ�佸鍑恒�佹ā鏉夸笅杞藉拰 Word 蹇姤涓嬭浇銆�
+- **鏁版嵁鍒濆鍖�**锛氫粠 `docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鏁版嵁.xlsx` 鍒濆鍖� 2024/2025 骞村崄涓�瀹㈡祦銆佽溅娴佸拰 2024-2026 骞村杩愮郴鏁帮紱婧愯〃 90 涓暟鎹崟鍏冩牸鍏ュ簱閫愰」姣斿宸紓 0銆�
+- **鏃ュ父瀵煎叆鍙e緞**锛氬彧璇诲彇鎵�閫夊勾浠界殑褰撳勾鍒楋紙2026 骞翠负鍚勫娴佸伐浣滆〃 C 鍒椼�佹櫘閫氬浗鐪侀亾 D 鍒楋級锛涘巻鍙插勾浠戒笉瑕佹眰鏃ュ父閲嶅銆�
+- **鍘嗗彶鏁版嵁淇濇姢**锛氬鍏ユ椂鍏堟鏌ユ洿鏃╁巻鍙叉棩鏈熸槸鍚﹁淇敼銆傛湁鍙樺寲鍒欏厛涓嶅啓搴擄紝杩斿洖宸紓娓呭崟锛屽墠绔脊绐楄鐢ㄦ埛閫夋嫨鈥滃厑璁稿鍏�/涓嶅厑璁糕�濓紱纭鍚庢墠瑕嗙洊銆傛帴鍙e弬鏁� `allowHistoricalOverwrite=true`銆�
+- **蹇姤**锛氶粯璁や笅杞界郴缁熸棩鏈熷墠涓�澶╃殑銆婃箹鍖楃渷鍥藉簡鍋囨湡缁煎悎杩愯緭瀹㈡祦蹇姤锛�10鏈圶鏃ワ級.docx銆嬶紝鐢ㄦ埛鍙嚜閫夋棩鏈燂紱鐜瘮/鍚屾瘮鎸夊闀裤�佷笅闄嶃�佹寔骞宠緭鍑恒��
+- **Excel 娴偣绮惧害锛�2026-10-02 杩藉姞锛�**锛氬弬鑰� `docs/Excel娴偣鏁板瓨鍌�.txt`锛屾柊澧� `HolidayNumberUtils`锛坄NumberToTextConverter.toText` + `BigDecimal`锛宍MathContext(15, HALF_UP)`锛夛紱鑺傚亣鏃ユā鍧楃敱 `Double` 鍏ㄩ潰鏀� `BigDecimal`锛涗笁寮犺〃鏁板�煎垪鏀� `DECIMAL(30,15)`锛涘簱鍐� 6 鏉″杩愮郴鏁扮敱 2.3999999999999995 鏍℃涓� 2.4銆傝縼绉昏剼鏈� `docs/sql_holiday_decimal_migration.sql`銆�
+- **鑿滃崟椤哄簭**锛氣�滀笓椤圭粺璁♀�濆凡绉诲埌鈥滄姤琛ㄧ敓鎴愨�濅箣鍚庯紝褰撳墠涓� 鎶ヨ〃鐢熸垚 -> 涓撻」缁熻 -> 瑙勫垯绠$悊 -> 鐢ㄦ埛绠$悊 -> 绯荤粺閰嶇疆銆�
+- **鑺傚亣鏃ュ垹闄ゅ娲讳慨澶嶏紙2026-10-02 杩藉姞锛�**锛氬師鍒濆鍖栭�昏緫姣忔鍚姩琛ラ綈缂哄け鐨勭 N 鏃ワ紝瀵艰嚧 admin 鍒犻櫎 2026-10-08 鍚庤閲嶆柊鎻掑叆锛涚幇鏀逛负浠� `holiday_calendar` 鏁磋〃涓虹┖鏃堕娆″垵濮嬪寲銆傞噸鍚悗 2026 骞村崄涓�淇濇寔 10-01 鑷� 10-07 鍏� 7 澶┿��
+- **鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛�2026-10-02 杩藉姞锛�2026-10-06 鏂规 D 宸蹭慨姝o級**锛欳 鍒楄涔夋渶缁堢‘瀹氫负鈥滀笅涓�鏃ョ幆姣斿垎姣嶇殑璋冩暣鍊尖�濓紱濉啓鍚庡彧褰卞搷涓嬩竴鏃ョ殑鐜瘮鍒嗘瘝锛屼笉褰卞搷 C 鎵�鍦ㄦ棩鍙婄疮璁°�佽蛋鍔垮浘銆俙holiday_vehicle_flow.comparison_base` 宸插叆搴擄紝瀵煎叆瀵煎嚭涓庡巻鍙插彉鏇寸‘璁ゅ潎宸叉帴鍏ャ��
+- **蹇姤棣栨棩鍙e緞锛�2026-10-02 杩藉姞锛�**锛氬亣鏈熺1澶╂病鏈夊墠涓�鏃ョ幆姣斿熀鏁帮紝姝f枃涓嶈緭鍑衡�滅幆姣斿闀�/涓嬮檷/鎸佸钩鈥濈煭璇紝琛ㄦ牸褰撴棩鐜瘮鍒椾负鈥溾�斺�濓紱绗�2澶╄捣浠嶆寜瀹為檯鍓嶄竴鏃ヨ緭鍑虹幆姣斻�傚凡鐢� 2025-10-01/02 蹇姤鍥炲綊楠岃瘉銆�
+- **蹇姤鐧惧垎姣旂簿搴︼紙2026-10-02 杩藉姞锛�**锛歐ord 蹇姤涓悓姣�/鐜瘮鐧惧垎姣旂粺涓�淇濈暀1浣嶅皬鏁帮紙濡�6.79%鏄剧ず6.8%銆�11.46%鏄剧ず11.5%锛夛紱浜烘銆佽溅娴佺瓑涓氬姟鏁板�间粛淇濈暀2浣嶅皬鏁般��
+- **楠岃瘉缁撴灉**锛氬悗绔紪璇戙�佸墠绔瀯寤恒�佹潈闄�/CRUD/璐熷悜瀵煎叆銆佸巻鍙插彉鏇翠袱闃舵瀵煎叆銆丒xcel 瀵煎嚭銆乄ord 蹇姤銆佸墠绔� admin/闈� admin 鐑熸祴鍧囬�氳繃锛涜彍鍗曢『搴忋�佸杩愮郴鏁� 2.4 灞曠ず锛堢郴缁熼厤缃〉 + 鑺傚亣鏃ョ粺璁¢〉锛夈�乣decimal(30,15)` 鍒楀瀷銆佸巻鍙插彉鏇翠袱闃舵瀵煎叆鍥炲綊鍧囧凡瀹炴祴锛涘姛鑳芥祴璇曟姤鍛婁笌宸ヤ綔鏃ョ粨鎸� 2026-09-27 鎻愪氦瑙勮寖浠呮湰鍦扮暀瀛橈紙涓嶅叆搴擄級锛歚docs/鍔熻兘娴嬭瘯鎶ュ憡/鍔熻兘娴嬭瘯鎶ュ憡_閲嶈鑺傚亣鏃ヤ笓椤圭粺璁2026-10-02.md`銆乣docs/宸ヤ綔鏃ョ粨/宸ヤ綔鏃ョ粨_2026-10-02.md`銆�
+- **杩愯鐘舵��**锛氬悗绔� 8090銆佸墠绔� 8080 宸插惎鍔紱鏁版嵁搴� `127.0.0.1:3305/traffic_audit`銆傛祴璇曚骇鐢熺殑 2026 涓存椂鏁版嵁鍜屽鍏ユ壒娆″凡娓呯悊銆�
+- **鍏虫満鏀跺熬锛�2026-10-02锛�**锛氬凡鍋滄帀鏈寮�鍙戠敤鍚庣 8090銆佸墠绔� 8080 鍙婂悇鑷繘绋嬫爲锛岀鍙e潎宸查噴鏀撅紱MySQL 鏈嶅姟淇濈暀銆傚綋鍓嶅彲瀹夊叏鍏虫満銆�
+- **Git**锛氱敤鎴峰凡浜� 2026-10-02 鏄庣‘楠屾敹閫氳繃锛涙湰娆℃寜瑕佹眰鍚堝苟杩滅 `origin/main` 鍚庢彁浜ゅ苟鎺ㄩ�侊紝涓嶈鐩栬繙绔悓浜嬫彁浜ゃ��
+- **閮ㄧ讲妯℃澘鍚屾**锛氳妭鍋囨棩 4 寮犺〃宸插悓姝ュ埌 `deploy-templates/db/init.sql`锛沗sql_holiday.sql` 鍙婁袱涓棫搴撹縼绉昏剼鏈殢閮ㄧ讲鍖呭彂甯冿紝閬垮厤鏂板寘缂鸿〃銆�
+
## 鏈�鏂帮紙2026-09-28锛�
- **鐢ㄦ埛鐢� WPS 缂栬緫姣嶇増**锛氭敼銆婄彮绾垮寘杞︺�嬭竟妗嗐�併�婇亾璺繍杈撳懆杞噺銆嬪瓧鍙凤紝骞跺垹闄ゃ�婁腑鍙e緞鏄庣粏銆媊AW` 鍒楋紙鎵嬪伐瀹氱鍚屽垹锛夈�傚凡楠岃瘉鐢熸垚绔甯革細涓彛寰勬槑缁� 2024 鍙婃洿鏃╁潡鏁翠綋宸︾Щ锛堣法椤�/鍚岄〉鍏紡鑷姩鏀瑰紩鐢紝`#REF!` 2鈫�0锛夈�佷腑鍙e緞鎺掑悕鍧� J..R 鈫� A..I 鍧囨纭甫鍏ヤ骇鐗┿��
@@ -392,6 +764,11 @@
- **2026-08-28 澶嶆牳銆岀偣鏁版嵁瀵煎叆娴忚鍣ㄥ穿婧冦�嶏紙鎵撳寘娴嬭瘯鐜锛岄儴鍒嗙敤鎴凤級**锛氶噸鐐规帓鏌� `GET /api/import/batches`鈥斺�斿悗绔� limit 涓婇檺 200锛涘叏琛� 81 鏉°�乪rror_detail 鏈�闀� 2.6KB銆佸叏閲忓搷搴旂害 17KB锛堝墠绔彧鍙� 50 鏉★級锛涘墠绔〃鏍间粎 8 鍒楃煭瀛楁锛坋rrorDetail 涓嶆覆鏌擄級銆佹棤杞/瀹氭椂鍣ㄣ��**缁撹锛氭帴鍙d笉鏄穿婧冩牴鍥�**锛堣嫢鎺ュ彛鏈夐棶棰橈紝鎵�鏈夌敤鎴烽兘浼氬穿锛岃�岄潪"閮ㄥ垎鐢ㄦ埛"锛夈�傜幇璞$鍚堝鎴风鐜宸紓锛堢涓夋柟鎵╁睍娉ㄥ叆/鏃ф樉鍗¢┍鍔�/浣庨厤鍐呭瓨锛屼笌 08-27 杩呴浄鎵╁睍+Intel 鏃ч┍鍔ㄧ粨璁轰竴鑷达級銆傚缓璁宕╂簝鐢ㄦ埛渚濇楠岃瘉锛氭棤鐥曟ā寮忊啋鎹� Edge鈫掔鎵╁睍锛屽苟鐪� chrome://crashes銆�
- 銆愭柊闇�姹偮蜂粎璁板綍鏈疄鐜奥�2026-08-28銆戝鍔�**缁忔祹鎸囨爣璇曠畻鍔熻兘**锛氶瀵奸潪甯稿叧娉ㄧ粡娴庢寚鏍囷紝**璐ц繍鏄ぇ澶�**锛涘父椹荤幇鍦虹殑鎵�鏈変汉閮借浼氳绠楄揣杩愮浉鍏虫暟鎹紙璐ц繍閲�/鍛ㄨ浆閲忕瓑锛夈�傞瀵肩粡甯歌姹傛妸鏌愪釜鏁版嵁銆屽帇涓�鍘嬨�嶏紝浠婂悗鑰冭檻鍦ㄨ蒋浠堕噷澧炲姞璇曠畻鍔熻兘鈥斺�斿彲鎸夐渶瑕佽皟鏁�/娴嬬畻鎸囨爣鏁板�硷紙濡傛ā鎷熶笅璋冩煇浼佷笟/鏌愬彛寰勭殑璐ц繍閲忓悗鐪嬫眹鎬讳笌瓒嬪娍褰卞搷锛夛紝杈呭姪鐜板満浜哄憳蹇�熻瘯绠椾笌搴斿銆�
+- 銆愬緟鍔灺�2026-10-03銆�**鏁板�肩簿搴︾粺涓�鏀归��**锛氭妸鏃㈡湁涓氬姟琛ㄧ殑鏁板�煎垪鐢� `double` 鏀逛负 `DECIMAL(30,15)`锛孞ava 瀹炰綋瀛楁鐢� `Double/Float` 鏀逛负 `BigDecimal`锛屼笌鑺傚亣鏃ヨ〃銆佽娴嬬珯琛ㄧ粺涓�鍙e緞锛堢敤鎴� 2026-10-03 鏄庣‘瑕佹眰锛屾嫨鏈哄疄鏂斤級銆�
+ - 鐜扮姸鐩樼偣锛坱raffic_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 鍙e緞銆佹棤闇�鏀瑰姩锛歚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`锛堣繘灞曘�佸潙銆佹柊闇�姹傘�佸緟鍔烇級锛屽啀鍏冲璇濄��
diff --git "a/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt" "b/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt"
index 7d759e2..2a8fb0b 100644
--- "a/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt"
+++ "b/deploy-templates/README-\351\203\250\347\275\262\350\257\264\346\230\216.txt"
@@ -1,12 +1,33 @@
-TrafficAudit 浜ら�氳揣杩愮粺璁″鏍哥郴缁� 鈥斺�� 閮ㄧ讲鍖咃紙{stamp}锛�
+TrafficAudit 浜ら�氳揣杩愮粺璁″鏍哥郴缁� 鈥斺�� 閮ㄧ讲鍖咃紙{stamp}锛岀増鏈� v{version}锛�
========================================================
+
+銆愭湇鍔″櫒涓婂彧瑕佸仛杩� 3 姝ャ�戯紙鍏跺畠鏂囦欢閮戒笉鐢ㄥ姩锛�
+ 绗� 1 姝� 鏀归厤缃細鐢ㄨ浜嬫湰鎵撳紑 backend\application.yml锛屾妸鏁版嵁搴撳湴鍧�/璐﹀彿/瀵嗙爜
+ 锛坰pring.datasource 涓嬮潰涓夐」锛夊拰 jwt.secret 鏀规垚杩欏彴鏈嶅姟鍣ㄧ殑鐪熷疄鍊笺��
+ 绗� 2 姝� 瑁呮湇鍔★細杩涘叆 backend 鏂囦欢澶癸紝鍙抽敭 install-service.cmd 鈫掋�屼互绠$悊鍛樿韩浠借繍琛屻��
+ 锛堢洿鎺ュ弻鍑讳篃琛岋紝瀹冧細鑷繁寮圭鐞嗗憳纭锛夈�傝剼鏈細娉ㄥ唽寮�鏈鸿嚜鍚湇鍔″苟鍚姩瀹冦��
+ 绗� 3 姝� 鐪嬬粨鏋滐細鑴氭湰鏈�鍚庝細鎵撳嵃 OK 鍜岀綉鍧�锛涙祻瑙堝櫒鎵撳紑 http://<鏈嶅姟鍣↖P>:8090
+ 锛堣处鍙� admin / admin123锛夛紝鑳界湅鍒扮櫥褰曢〉灏辩畻鎴愬姛銆�
+
+ 鍝簺瑕佹嫹銆佸摢浜涗笉鐢ㄦ嫹锛�
+ backend\ 蹇呴』鏁翠釜鐩綍涓�璧锋嫹锛坖ar銆乤pplication.yml銆乨ocs銆亀eb銆佹湇鍔℃枃浠堕兘鍦ㄩ噷闈級锛�
+ 涓嶈鍙寫鍑犱釜鏂囦欢鎷枫��
+ db\ 鍙湁闇�瑕佸垵濮嬪寲/杩樺師鏁版嵁搴撴椂鎵嶇敤锛屽钩鏃跺彲涓嶆嫹銆�
+ frontend\ 涓嶇敤鎷凤紙鍓嶇宸插唴宓屽湪 jar 閲岋紝8090 绔彛鐩存帴璁块棶锛夈��
+ 鏈枃浠� 鍙嫹鍙笉鎷凤紝灏辨槸杩欎唤璇存槑涔︺��
銆愮洰褰曠粨鏋勩��
backend/
- traffic-audit-server-1.0.0-SNAPSHOT.jar 鍚庣绋嬪簭锛堝惈鍏ㄩ儴渚濊禆锛�**宸插唴宓屽墠绔�**锛�
+ traffic-audit-server.jar 鍚庣绋嬪簭锛堝惈鍏ㄩ儴渚濊禆锛�**宸插唴宓屽墠绔�**锛屽浐瀹氭枃浠跺悕锛�
docs/ 鎶ヨ〃妯℃澘涓庤繍琛屾暟鎹洰褰曪紙蹇呴渶锛屽嬁鍒狅級
application.yml 澶栭儴閰嶇疆锛堟敼杩欓噷鍗冲彲锛屾棤闇�閲嶆柊鎵撳寘锛�
- start-backend.cmd / start-backend.sh 鍚姩鑴氭湰
+ start-backend.cmd / start-backend.sh 鍓嶅彴鍚姩鑴氭湰锛堜汉宸ヨ皟璇曠敤锛屽叧浜嗙獥鍙e氨鍋滐級
+ trafficAudit.exe WinSW 鏈嶅姟鍖呰鍣紙鎶� java 鍙樻垚 Windows 鏈嶅姟锛屽嬁鍒犲嬁鏀瑰悕锛�
+ trafficAudit.xml WinSW 鏈嶅姟閰嶇疆锛堟湇鍔″悕/鍚姩鏂瑰紡/鏃ュ織/澶辫触閲嶅惎绛栫暐锛�
+ install-service.cmd 鍙屽嚮鍏ュ彛锛氭敞鍐屽紑鏈鸿嚜鍚湇鍔★紙浼氳嚜鍔ㄨ姹傜鐞嗗憳鏉冮檺锛�
+ install-service.ps1 涓婇潰閭d釜 cmd 瀹為檯璋冪敤鐨勮剼鏈紙娉ㄥ唽/鏌ョ湅/閲嶅惎/鍒犻櫎锛�
+ start-backend-service.cmd 闈炰氦浜掑惎鍔ㄥ櫒锛堟棤 pause銆佸啓 app.log锛岄厤鍚堣鍒掍换鍔$敤锛�
+ install-autostart.ps1 澶囬�夛細娉ㄥ唽鈥滆鍒掍换鍔♀�濇柟寮忕殑寮�鏈鸿嚜鍚�
frontend/
dist/ 鍓嶇鏋勫缓浜х墿锛堝鐢細鎯崇敤 nginx 鎵樼鏃剁敤锛�
nginx.conf.example nginx 閰嶇疆绀轰緥
@@ -15,6 +36,12 @@
sql_city_bus.sql 鍩庡競瀹㈣繍-鍏氦鏈堝害琛紙蹇呴』鎵ц锛�
sql_city_taxi.sql 鍩庡競瀹㈣繍-鍑虹鏈堝害琛�/鍑虹鎺堟潈琛紙蹇呴』鎵ц锛�
sql_wyc.sql 缃戠害杞﹀叏鐪佹�婚噺琛�/璁㈠崟鍒嗗競宸炶〃锛堝繀椤绘墽琛岋級
+ sql_holiday.sql 涓撻」缁熻-閲嶈鑺傚亣鏃ヨ〃锛堟棫搴撶己琛ㄦ椂鎵ц锛�
+ sql_holiday_decimal_migration.sql 鑺傚亣鏃ユ暟鍊煎垪绮惧害杩佺Щ锛堟棫搴撳崌绾э級
+ sql_holiday_comparison_base_migration.sql 鑺傚亣鏃ョ幆姣斿熀鏈熷瓧娈佃縼绉伙紙鏃у簱鍗囩骇锛�
+ sql_holiday_previous_day_migration.sql 鑺傚亣鏃ュ墠涓�鏃ラ厤缃縼绉伙紙鏃у簱鍗囩骇锛�
+ sql_holiday_vehicle_adjustment_migration.sql 寰�骞村浗鐪侀亾鏁版嵁璋冩暣琛紙鏃у簱鍗囩骇锛�
+ sql_observation_station.sql 瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹〃锛堟柊鍔熻兘锛屾棫搴撶己琛ㄦ椂鎵ц锛�
README-閮ㄧ讲璇存槑.txt
銆愰儴缃叉満鐜瑕佹眰銆�
@@ -23,12 +50,38 @@
**涓嶉渶瑕� Redis**锛堜唬鐮佹湭浣跨敤锛涙棫鐗堣鏄庨噷鐨� Redis 鏄仐鐣欓厤缃紝鍙拷鐣ワ級
**涓嶉渶瑕� nginx**锛堝墠绔凡鍐呭祵杩� jar锛岃闂� 8090 鍗冲彲锛沶ginx 浠呬负澶囬�夋柟妗堬級
+銆愭搷浣滅郴缁熷吋瀹规�с��
+ 寮�鏈鸿嚜鍚敤鐨勬槸 Windows 鏈嶅姟锛岃�屸�滆嚜鍔ㄥ惎鍔� / 寤惰繜鍚姩 / 澶辫触鑷姩閲嶅惎鈥濇槸绯荤粺鑷甫鑳藉姏锛�
+ 浠� Win7 SP1 鍒� Win11銆丼erver 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 鍙婁互涓娿��
+ - 鑴氭湰瑕佹眰锛歩nstall-service.ps1 鍦� Windows PowerShell 2.0+ 涓嬪彲鐢紙Win7 鑷甫 2.0锛屽凡鍋氬吋瀹癸級锛�
+ 3.2 鐨勮鍒掍换鍔¤剼鏈敤鍒颁簡 New-ScheduledTask* 鍛戒护锛岄渶瑕� Win8 / Server 2012 鍙婁互涓娿��
+
+銆愪粠鏈増璧凤細鍚庣 jar 浣跨敤鍥哄畾鏂囦欢鍚嶃��
+ backend\traffic-audit-server.jar
+ - 鏂版湇鍔″櫒锛氭寜涓嬮潰鐨勬櫘閫氶儴缃叉楠ゆ墽琛屽嵆鍙��
+ - 宸插畨瑁呮棫鐗堟湇鍔$殑鏈嶅姟鍣紙棣栨鍗囩骇鍒版湰鐗堬級锛�
+ 1) 鍋滄鏈嶅姟锛歋top-Service TrafficAuditBackend
+ 2) 澶囦唤骞剁紪杈� backend\trafficAudit.xml锛屾妸 <arguments> 閲岀殑鏃� jar 鍚嶆敼鎴� traffic-audit-server.jar
+ 3) 鏇挎崲 backend\traffic-audit-server.jar锛屽啀鎵ц Start-Service TrafficAuditBackend
+ - 浠ュ悗姣忔鍗囩骇锛氬彧鏇挎崲 backend\traffic-audit-server.jar锛屽啀閲嶅惎鏈嶅姟锛涗笉鍐嶉渶瑕佷慨鏀� trafficAudit.xml銆�
+ - 椤甸潰鐧诲綍椤靛拰鍙充笂瑙掓樉绀虹殑鐗堟湰鍙蜂笌鏈彂甯冨寘鍚嶇О涓殑鐗堟湰鍙蜂竴鑷淬��
+
銆愰儴缃叉楠ゃ��
1. 鍒濆鍖栨暟鎹簱锛�4 涓剼鏈兘瑕佹墽琛岋紝椤哄簭濡備笅锛�
mysql --default-character-set=utf8mb4 -u root -p < db/init.sql
mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_city_bus.sql
mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_city_taxi.sql
mysql --default-character-set=utf8mb4 -u root -p traffic_audit < db/sql_wyc.sql
+ 娉細鍏ㄦ柊搴撶殑 init.sql 宸插惈鑺傚亣鏃ュ強鐩稿叧琛紱鑻ユ槸鍦ㄦ棭鏈熺増鏈簱涓婅ˉ鑺傚亣鏃ユā鍧楋紝鍐嶆墽琛� db/sql_holiday.sql锛屽苟鎸夐渶鎵ц杩佺Щ鑴氭湰锛堟暟鍊肩簿搴︺�佺幆姣斿熀鏈熴�佸墠涓�鏃ラ厤缃�佸線骞村浗鐪侀亾鏁版嵁璋冩暣銆佽娴嬬珯鍒嗘棩璋冩煡鏁版嵁锛夈�傝縼绉昏剼鏈潎鍙噸澶嶆墽琛屻��
棰勭疆璐﹀彿锛歛dmin / admin123锛堥娆$櫥褰曞悗璇锋敼瀵嗙爜锛�
2. 鏀归厤缃� backend/application.yml
@@ -37,10 +90,47 @@
- deepseek.api-key 锛氶渶瑕� AI 鍒嗘瀽鏃跺~锛屼笉闇�瑕佸彲鐣欑┖
娉細application.yml 涓� jar 鍚岀骇鍗崇敓鏁堬紝涓嶇敤閲嶆柊鎵撳寘銆�
- 3. 鍚姩鍚庣锛堝湪 backend 鐩綍涓嬶級
+ 3. 鍚姩鍚庣锛堝湪 backend 鐩綍涓嬶紱杩欐鍙槸鎵嬪伐璇曡窇锛屽叧绐楀彛灏卞仠锛岃寮�鏈鸿嚜鍚鐪� 3.1锛�
Windows锛氬弻鍑� start-backend.cmd
Linux锛� sh start-backend.sh
鎴愬姛鏍囧織锛氬嚭鐜� "Started TrafficAuditApplication" 涓� "Tomcat started on port(s): 8090"
+
+ 3.1 Windows 寮�鏈鸿嚜鍚紙鎺ㄨ崘锛氭敞鍐屾垚 Windows 鏈嶅姟锛屼竴娆¢厤缃暱鏈熺敓鏁堬級
+ 鍓嶆彁锛歜ackend 鐩綍涓嬬殑 trafficAudit.exe / trafficAudit.xml / install-service.cmd / install-service.ps1
+ 閮藉凡闅忔湰閮ㄧ讲鍖呮彁渚涳紝涓嶉渶瑕佸彟澶栦笅杞� WinSW锛屼篃涓嶉渶瑕佺粰 java 瑁呭埆鐨勪笢瑗裤��
+ 鏈�鐪佷簨锛氬湪 backend 鐩綍鍙抽敭 install-service.cmd 鈫掋�屼互绠$悊鍛樿韩浠借繍琛屻��
+ 锛堢洿鎺ュ弻鍑讳篃琛岋紝瀹冧細鑷繁寮� UAC 绠$悊鍛樼‘璁わ級銆�
+ 绛変环鐨勬墜宸ユ柟寮忥紙鍦� backend 鐩綍涓嬶紝鐢ㄣ�愮鐞嗗憳銆慞owerShell锛夛細
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
+ 鏁堟灉锛氭敞鍐� Windows 鏈嶅姟 TrafficAuditBackend锛屽紑鏈鸿嚜鍔ㄥ惎鍔紙寤惰繜鍚姩锛屽厛绛� MySQL 灏辩华锛夛紝
+ 涓嶉渶瑕佷换浣曠敤鎴风櫥褰曪紱java 杩涚▼寮傚父閫�鍑虹敱鏈嶅姟绠$悊鍣ㄨ嚜鍔ㄩ噸鍚紙10 绉� / 30 绉� / 60 绉掗�掑閲嶈瘯锛�
+ 1 灏忔椂鍚庨噸璇曡鏁版竻闆讹級銆傛湇鍔′互 LocalSystem 韬唤杩愯銆�
+ 鑴氭湰浼氳嚜鍔ㄥ畾浣� java.exe锛圝AVA_HOME 鈫� PATH 鈫� JavaSoft 娉ㄥ唽琛� 鈫� Program Files锛夛紝
+ 骞舵妸缁濆璺緞鍐欒繘 trafficAudit.xml锛圫YSTEM 鏈嶅姟鐨� PATH 涓庣櫥褰曠敤鎴蜂笉鍚岋紝鍐欑粷瀵硅矾寰勬洿绋筹級銆�
+ 甯哥敤鍙傛暟锛�
+ -Status 鏌ョ湅鏈嶅姟鐘舵�侊紙涓嶉渶瑕佺鐞嗗憳锛�
+ -Restart 閲嶅惎鏈嶅姟 -Remove 鍋滄骞跺垹闄ゆ湇鍔�
+ -NoStart 鍙敞鍐屼笉鍚姩 -JavaPath "D:\jdk8\bin\java.exe" 鎵嬪姩鎸囧畾 JDK
+ 鏃ュ織锛歜ackend\logs\trafficAudit.out.log锛堣繍琛岃緭鍑猴級涓� trafficAudit.err.log锛堥敊璇緭鍑猴級锛�
+ 鍗曚釜鏂囦欢 10MB锛屼繚鐣� 10 涓粴鍔ㄦ枃浠躲��
+ 楠岃瘉锛氳剼鏈瀹屼細鑷姩鎺㈡祴绔彛锛屾墦鍗� OK + 鏈満/瀵瑰缃戝潃锛涜捣涓嶆潵鏃剁洿鎺ユ墦鍗� logs\*.log 鏈�鍚� 15 琛岋紝
+ 鎸夋彁绀烘敼 application.yml 鍚庢墽琛� install-service.ps1 -Restart 鍗冲彲銆備篃鍙墜宸ョ敤娴忚鍣ㄦ墦寮�
+ http://<閮ㄧ讲鏈篒P>:8090 纭銆�
+ 娉ㄦ剰锛�
+ - 寮�鏈鸿嚜鍚潬鏈嶅姟鐨勨�滆嚜鍔ㄢ�濆惎鍔ㄧ被鍨嬶紱濡傛灉鎶婂畠鏀规垚鈥滄墜鍔ㄢ�濓紝灏变笉浼氬啀闅忔満鍣ㄥ惎鍔ㄣ��
+ - 閲嶅鎵ц install-service.ps1 鏄畨鍏ㄧ殑锛氫細鍏堝仠鏈嶅姟鍐嶉噸鏂版敞鍐屽苟鍚姩銆�
+ - 鍚屼竴鍙版満涓嶈鍚屾椂鍚敤涓嬮潰鐨勮鍒掍换鍔℃柟寮忥紝涓や釜杩涚▼浼氭姠 8090 绔彛銆�
+ - 寤鸿鎶婇儴缃插寘鏀惧湪 D:\trafficAudit 杩欑被绠�鍗曡矾寰勶紱鏀惧湪鏌愪簺鐢ㄦ埛鐩綍涓嬫椂 LocalSystem
+ 鍙兘娌℃湁鍐欐潈闄愶紝鏃ュ織浼氭姤鏉冮檺閿欒銆�
+
+ 3.2 澶囬�夛細璁″垝浠诲姟鏂瑰紡锛堜笉鎯虫妸鍚庣瑁呮垚鏈嶅姟鏃剁敤锛屼笌 3.1 浜岄�変竴锛�
+ 鍦� backend 鐩綍涓嬶紝鐢ㄣ�愮鐞嗗憳銆慞owerShell 鎵ц锛�
+ 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://<閮ㄧ讲鏈篒P>:8090 锛堝墠绔凡鍐呭祵锛岄〉闈笌鎺ュ彛鍚屾簮锛�
@@ -54,6 +144,10 @@
娉ㄦ剰锛氬叏閲� dump 浼氬甫涓婃湰鏈虹殑璐﹀彿瀵嗙爜锛岃鐩� init.sql 棰勭疆鐨� admin 璐﹀彿銆�
銆愬父瑙侀棶棰樸��
+ 0. 鍚姩鎶� java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)锛�
+ 鈥渦sing password: NO鈥� = 鍚庣鎷垮埌鐨勬暟鎹簱瀵嗙爜鏄┖涓诧紙涓嶆槸瀵嗙爜閿欙級銆�
+ 妫�鏌� backend/application.yml 鏄惁涓� jar 鍚岀骇涓� spring.datasource.password 宸插~鐪熷疄瀵嗙爜锛�
+ 鑻ユ湭鏀� application.yml锛屼篃鍙敤鐜鍙橀噺 TRAFFIC_DB_PASSWORD 浠f浛銆傛敼瀹岄噸鍚嵆鍙紝鏃犻渶閲嶆柊鎵撳寘銆�
1. 鍚姩鎶ャ�屾湭鎵惧埌鏁版嵁鐩綍 docs銆�/ 瀵煎嚭鎶ャ�屾湭鎵惧埌妯℃澘鏂囦欢銆嶏細
蹇呴』鍦� backend 鐩綍涓嬪惎鍔紙jar 涓� docs 鍚岀骇锛夛紝涓嶈鍙嫹 jar銆�
2. 鎶ヨ〃瀵煎嚭鎶ャ�屾湭鎵惧埌姹囨�昏〃姣嶇増銆奩YYY骞碝鏈堥亾璺繍杈撻噺姹囨�昏〃.xlsx銆嬨�嶏細
@@ -61,7 +155,15 @@
3. 8090 绔彛琚崰鐢細鏀� application.yml 鐨� server.port 鍚庨噸鍚��
4. 椤甸潰鎵撳紑鏄┖鐧斤細纭璁块棶鐨勬槸 http://<IP>:8090 鏍硅矾寰勶紝涓旀祻瑙堝櫒寮哄埗鍒锋柊锛圕trl+F5锛夈��
5. 涓婁紶澶ф枃浠跺け璐ワ細鍚庣闄愬埗 100MB锛屽凡閰嶇疆锛涘鍓嶉潰杩樻寕浜� nginx锛岄渶鍚屾鏀惧ぇ client_max_body_size銆�
+ 6. 鏈嶅姟宸叉敞鍐屼絾 8090 鎵撲笉寮�锛氱湅 backend\logs\trafficAudit.err.log 鐨勬姤閿欏唴瀹癸紱
+ 鏈嶅姟鏂瑰紡涓嬬敤鐨勬槸 backend\application.yml锛宲assword 蹇呴』鏄湡瀹� MySQL 瀵嗙爜锛堝崰浣嶆枃瀛椾細瀵艰嚧 using password: NO锛夈��
+ 7. 鏈嶅姟鎶モ�滄嫆缁濊闂�濇垨鍐欎笉浜嗘棩蹇楋細閮ㄧ讲鐩綍鏉冮檺涓嶈冻銆�
+ 鎶婇儴缃插寘鏀惧埌 D:\trafficAudit 杩欑被绠�鍗曡矾寰勶紝鎴栫粰 LocalSystem 鎺堜簣璇ョ洰褰曠殑璇诲啓鏉冮檺銆�
+ 8. 鎯充复鏃跺彇娑堝紑鏈鸿嚜鍚絾淇濈暀鏈嶅姟锛歋et-Service TrafficAuditBackend -StartupType Manual
銆愬仠姝㈡湇鍔°��
- Windows锛氬叧闂� start-backend.cmd 绐楀彛
- Linux锛� Ctrl+C锛屾垨 kill 瀵瑰簲 java 杩涚▼锛坧s -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 杩涚▼锛坧s -ef | grep java锛�
diff --git a/deploy-templates/backend/application.yml b/deploy-templates/backend/application.yml
index 7cf2f24..ace265a 100644
--- a/deploy-templates/backend/application.yml
+++ b/deploy-templates/backend/application.yml
@@ -3,15 +3,19 @@
# 閮ㄧ讲鍒版柊鏈哄櫒鍚庯紝璇�**鑷冲皯**淇敼涓嬮潰鏍囨敞 TODO 鐨勪笁澶勬暟鎹簱閰嶇疆锛�
# ============================================================
server:
- port: 8080
+ port: 8090 # 鍗曠鍙fā寮忥細鍚庣 8090 鍚屾椂鎵樼鍓嶇椤甸潰锛堜笉瑕佹敼鎴� 8080锛�
app:
captcha:
required: false # 鐢熶骇鐜濡傞渶鍥惧舰楠岃瘉鐮侊紝鏀逛负 true
spring:
+ web:
+ resources:
+ # 鍗曠鍙fā寮忥細浼樺厛璇诲彇 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:
diff --git a/deploy-templates/backend/install-autostart.ps1 b/deploy-templates/backend/install-autostart.ps1
new file mode 100644
index 0000000..fed0b89
--- /dev/null
+++ b/deploy-templates/backend/install-autostart.ps1
@@ -0,0 +1,109 @@
+锘�<#
+ Register / remove / inspect a Windows Scheduled Task that starts the TrafficAudit
+ backend at machine boot, with no user session required.
+
+ Run from an ELEVATED PowerShell in the backend folder (jar + application.yml +
+ this script + start-backend-service.cmd):
+
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -DelaySeconds 90
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -StartNow
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -Status
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-autostart.ps1 -Remove
+
+ What it does:
+ - trigger : AtStartup, delayed by -DelaySeconds (default 60s, so MySQL is up first)
+ - principal: SYSTEM, highest privileges, "run whether user is logged on or not"
+ - settings : no execution time limit, start when available, restart 3x / 1 min on failure
+ - action : cmd.exe /c start-backend-service.cmd (logs to app.log next to the jar)
+
+ Notes:
+ - Re-running without -Remove is idempotent (-Force overwrites the task).
+ - Start/stop manually with: Start-ScheduledTask / Stop-ScheduledTask -TaskName <name>
+ - Keep this file ASCII-only: Windows PowerShell 5.1 reads a BOM-less .ps1 as ANSI.
+#>
+param(
+ [string]$TaskName = 'TrafficAuditBackend',
+ [int]$DelaySeconds = 60,
+ [switch]$Remove,
+ [switch]$Status,
+ [switch]$StartNow
+)
+
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $MyInvocation.MyCommand.Path
+$launcher = Join-Path $root 'start-backend-service.cmd'
+$logFile = Join-Path $root 'app.log'
+
+function Assert-Admin {
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+ $principal = New-Object Security.Principal.WindowsPrincipal($identity)
+ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'This script must run from an elevated (Run as Administrator) PowerShell.'
+ }
+}
+
+if ($Status) {
+ $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
+ if (-not $task) { Write-Host "Scheduled task '$TaskName' is not registered."; exit 1 }
+ $task | Select-Object TaskName, TaskPath, State | Format-List
+ Get-ScheduledTaskInfo -TaskName $TaskName |
+ Select-Object LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns | Format-List
+ exit 0
+}
+
+if ($Remove) {
+ Assert-Admin
+ if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
+ Write-Host "Removed scheduled task '$TaskName'."
+ } else {
+ Write-Host "Scheduled task '$TaskName' was not registered; nothing to remove."
+ }
+ exit 0
+}
+
+Assert-Admin
+if (-not (Test-Path $launcher)) { throw "Missing launcher: $launcher" }
+if (-not (Test-Path (Join-Path $root 'traffic-audit-server.jar'))) {
+ Write-Host "WARN: jar not found in $root - the task will be registered, but startup will fail until the jar is there."
+}
+
+$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument ('/c "' + $launcher + '"') -WorkingDirectory $root
+
+$trigger = New-ScheduledTaskTrigger -AtStartup
+if ($DelaySeconds -gt 0) { $trigger.Delay = 'PT' + $DelaySeconds + 'S' }
+
+$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
+
+$settings = New-ScheduledTaskSettingsSet `
+ -AllowStartIfOnBatteries `
+ -DontStopIfGoingOnBatteries `
+ -StartWhenAvailable `
+ -MultipleInstances IgnoreNew `
+ -RestartCount 3 `
+ -RestartInterval (New-TimeSpan -Minutes 1) `
+ -ExecutionTimeLimit ([TimeSpan]::Zero)
+
+Register-ScheduledTask `
+ -TaskName $TaskName `
+ -Action $action `
+ -Trigger $trigger `
+ -Principal $principal `
+ -Settings $settings `
+ -Description 'TrafficAudit backend auto-start at machine boot (script-managed).' `
+ -Force | Out-Null
+
+Write-Host "Registered scheduled task '$TaskName'."
+Write-Host " Trigger : At startup, +${DelaySeconds}s"
+Write-Host " Principal: SYSTEM (highest privileges, no logon required)"
+Write-Host " Launcher : $launcher"
+Write-Host " Log : $logFile"
+Write-Host ''
+Write-Host "Service address after boot: http://<server-ip>:8090"
+Write-Host "Manage it with: -Status | -StartNow | -Remove"
+
+if ($StartNow) {
+ Start-ScheduledTask -TaskName $TaskName
+ Write-Host "Started task '$TaskName' now."
+}
diff --git a/deploy-templates/backend/install-service.cmd b/deploy-templates/backend/install-service.cmd
new file mode 100644
index 0000000..6e4199f
--- /dev/null
+++ b/deploy-templates/backend/install-service.cmd
@@ -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
diff --git a/deploy-templates/backend/install-service.ps1 b/deploy-templates/backend/install-service.ps1
new file mode 100644
index 0000000..8ff8a19
--- /dev/null
+++ b/deploy-templates/backend/install-service.ps1
@@ -0,0 +1,330 @@
+<#
+ Install / update / remove the TrafficAudit backend as a Windows service.
+
+ The service is a WinSW (Windows Service Wrapper) host: trafficAudit.exe reads
+ trafficAudit.xml and runs "java -jar traffic-audit-server.jar".
+ Because it is a real Windows service it starts at boot with no user logged on,
+ is listed in services.msc / Get-Service, and is restarted by the Service
+ Control Manager when the java process dies.
+
+ Run from an ELEVATED PowerShell in the backend folder
+ (the folder that contains the jar, application.yml, docs, web and trafficAudit.exe):
+
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Status
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Restart
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Remove
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -NoStart
+ powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -JavaPath 'D:\jdk8\bin\java.exe'
+
+ What it does:
+ 1. locates java.exe (JAVA_HOME -> PATH -> JavaSoft registry -> Program Files)
+ 2. writes that absolute path into trafficAudit.xml (<executable>)
+ 3. installs or reinstalls the service and starts it (unless -NoStart)
+
+ Notes:
+ - Re-running is idempotent: an existing service is stopped and reinstalled.
+ - Logs: logs\trafficAudit.out.log / logs\trafficAudit.err.log (10 MB x 10 roll).
+ - Do NOT enable the scheduled-task autostart (install-autostart.ps1) at the
+ same time: two hosts would race for the same jar and port 8090.
+ - Keep this file ASCII-only; Windows PowerShell 5.1 reads a BOM-less .ps1 as ANSI.
+#>
+param(
+ [switch]$Remove,
+ [switch]$Status,
+ [switch]$Restart,
+ [switch]$NoStart,
+ [string]$JavaPath
+)
+
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $MyInvocation.MyCommand.Path
+$wrapper = Join-Path $root 'trafficAudit.exe'
+$config = Join-Path $root 'trafficAudit.xml'
+$appConfig = Join-Path $root 'application.yml'
+$serviceId = 'TrafficAuditBackend'
+
+# Any terminating error below lands here instead of dumping a raw stack trace
+# at the operator. The line number is kept so a copy of this window is enough
+# to diagnose a report.
+trap {
+ Write-Host ''
+ Write-Host '=============================================================='
+ Write-Host ' ERROR: the installer stopped before finishing.'
+ Write-Host (' ' + $_.Exception.Message)
+ Write-Host (' at line ' + $_.InvocationInfo.ScriptLineNumber + ' of install-service.ps1')
+ Write-Host ' The service may not be installed. Send this window text to the'
+ Write-Host ' developer if the reason is not obvious.'
+ Write-Host '=============================================================='
+ exit 1
+}
+
+function Assert-Admin {
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+ $principal = New-Object Security.Principal.WindowsPrincipal($identity)
+ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'This script must run from an elevated (Run as Administrator) PowerShell.'
+ }
+}
+
+function Resolve-JavaPath {
+ param([string]$Explicit)
+
+ if ($Explicit) {
+ if (-not (Test-Path $Explicit)) { throw "JavaPath does not exist: $Explicit" }
+ return (Resolve-Path $Explicit).Path
+ }
+
+ if ($env:JAVA_HOME) {
+ $candidate = Join-Path $env:JAVA_HOME 'bin\java.exe'
+ if (Test-Path $candidate) { return (Resolve-Path $candidate).Path }
+ }
+
+ $onPath = Get-Command java.exe -ErrorAction SilentlyContinue
+ if ($onPath -and $onPath.Source) { return $onPath.Source }
+
+ # A SYSTEM service inherits the machine PATH, which often lacks the JDK.
+ # Fall back to the install locations used by Oracle / OpenJDK installers.
+ foreach ($reg in @('HKLM:\SOFTWARE\JavaSoft\JDK', 'HKLM:\SOFTWARE\JavaSoft\Java Development Kit')) {
+ if (-not (Test-Path $reg)) { continue }
+ foreach ($ver in (Get-ChildItem $reg -ErrorAction SilentlyContinue | Sort-Object Name -Descending)) {
+ $home = (Get-ItemProperty -Path $ver.PSPath -Name JavaHome -ErrorAction SilentlyContinue).JavaHome
+ if ($home) {
+ $candidate = Join-Path $home 'bin\java.exe'
+ if (Test-Path $candidate) { return (Resolve-Path $candidate).Path }
+ }
+ }
+ }
+
+ $roots = @($env:ProgramFiles)
+ if (${env:ProgramFiles(x86)}) { $roots += ${env:ProgramFiles(x86)} }
+ foreach ($base in $roots) {
+ foreach ($vendor in @('Java', 'Eclipse Adoptium', 'AdoptOpenJDK', 'Microsoft')) {
+ $hit = Get-ChildItem -Path (Join-Path $base ($vendor + '\*\bin\java.exe')) -ErrorAction SilentlyContinue |
+ Sort-Object FullName -Descending | Select-Object -First 1
+ if ($hit) { return $hit.FullName }
+ }
+ }
+
+ throw 'java.exe not found. Install a JDK 8 and re-run, or pass -JavaPath "D:\path\to\bin\java.exe".'
+}
+
+function Set-WrapperJavaPath {
+ param([string]$JavaExe)
+
+ if (-not (Test-Path $config)) { throw "Missing config file: $config" }
+
+ # Edit through the DOM: a regex would also hit the executable mention inside
+ # the header comment and would mangle the whole document.
+ $doc = New-Object System.Xml.XmlDocument
+ $doc.PreserveWhitespace = $true
+ $doc.Load($config)
+
+ $node = $doc.SelectSingleNode('/service/executable')
+ if (-not $node) { throw "No /service/executable element found in $config" }
+
+ if ($node.InnerText -eq $JavaExe) {
+ Write-Host "Config already points at $JavaExe"
+ return
+ }
+
+ $node.InnerText = $JavaExe
+
+ $settings = New-Object System.Xml.XmlWriterSettings
+ $settings.Encoding = New-Object System.Text.UTF8Encoding $false
+ $settings.Indent = $true
+ $settings.IndentChars = ' '
+ $writer = [System.Xml.XmlWriter]::Create($config, $settings)
+ try { $doc.Save($writer) } finally { $writer.Close() }
+ Write-Host "Updated $config -> $JavaExe"
+}
+
+function Get-ServiceState {
+ Get-Service -Name $serviceId -ErrorAction SilentlyContinue
+}
+
+function Test-LocalPort {
+ param([int]$Port, [string]$TargetHost = '127.0.0.1')
+
+ # Probe TCP instead of HTTP: Invoke-WebRequest does not exist on Windows
+ # PowerShell 2 (Windows 7), and this works on every PowerShell version.
+ $client = New-Object System.Net.Sockets.TcpClient
+ try {
+ $async = $client.BeginConnect($TargetHost, $Port, $null, $null)
+ if ($async.AsyncWaitHandle.WaitOne(1000, $false) -and $client.Connected) { return $true }
+ return $false
+ } catch {
+ return $false
+ } finally {
+ $client.Close()
+ }
+}
+
+function Test-AppConfigPassword {
+ param([string]$Path)
+
+ # Returns $true when the datasource password looks usable. Prints a WARN and
+ # returns $false for the states that break startup with
+ # "Access denied ... (using password: NO)": missing file / empty value / placeholder.
+ if (-not (Test-Path -LiteralPath $Path)) {
+ Write-Host 'WARN: application.yml is missing next to the jar.'
+ Write-Host ' The built-in password placeholder resolves to an empty string and startup will fail with'
+ Write-Host " Java SQLException: Access denied for user 'root'@'localhost' (using password: NO)."
+ return $false
+ }
+
+ $line = Select-String -LiteralPath $Path -Pattern '^\s*password\s*:' | Select-Object -First 1
+ if (-not $line) {
+ Write-Host "WARN: no 'password:' entry found in application.yml; check the datasource block."
+ return $false
+ }
+
+ # Trim() takes a single char, so strip whitespace, then one quote kind at a
+ # time. Passing a 2-char string here throws
+ # "cannot convert value ... to type System.Char" and aborts the script.
+ $value = $line.Line -replace '^\s*password\s*:\s*', ''
+ $value = $value.Trim()
+ $value = $value.Trim("'")
+ $value = $value.Trim('"')
+
+ if ($value -eq '') {
+ Write-Host "WARN: spring.datasource.password is empty in application.yml; startup may fail with '(using password: NO)'."
+ return $false
+ }
+ if ($value -match '[^\x00-\x7F]') {
+ Write-Host 'WARN: the datasource password contains non-ASCII characters.'
+ Write-Host ' If it is still the Chinese placeholder text, replace it with the real MySQL password first.'
+ return $false
+ }
+ return $true
+}
+
+# ---------- read-only status: no elevation needed ----------
+if ($Status) {
+ $service = Get-ServiceState
+ if (-not $service) {
+ Write-Host "Service '$serviceId' is NOT installed."
+ exit 1
+ }
+ $service | Select-Object Name, DisplayName, Status, StartType | Format-List
+ # Get-CimInstance needs PowerShell 3+; fall back to WMI for Windows 7 / PowerShell 2.
+ $detail = $null
+ try {
+ $detail = Get-CimInstance Win32_Service -Filter "Name='$serviceId'" -ErrorAction Stop
+ } catch {
+ $detail = Get-WmiObject Win32_Service -Filter "Name='$serviceId'" -ErrorAction SilentlyContinue
+ }
+ if ($detail) { $detail | Select-Object ProcessId, StartMode, DelayedAutoStart, PathName | Format-List }
+ if (Test-Path $wrapper) { & $wrapper status }
+ exit 0
+}
+
+if ($Restart) {
+ Assert-Admin
+ if (-not (Get-ServiceState)) { throw "Service '$serviceId' is not installed; run this script without -Restart first." }
+ Write-Host "Restarting service '$serviceId'..."
+ Restart-Service -Name $serviceId -Force
+ Start-Sleep -Seconds 3
+ Get-ServiceState | Select-Object Name, Status | Format-List
+ exit 0
+}
+
+if ($Remove) {
+ Assert-Admin
+ if (-not (Test-Path $wrapper)) { throw "Missing wrapper executable: $wrapper" }
+ if (Get-ServiceState) {
+ Write-Host "Stopping and removing service '$serviceId'..."
+ & $wrapper stop | Out-Null
+ Start-Sleep -Seconds 3
+ & $wrapper uninstall | Out-Null
+ Write-Host "Removed service '$serviceId'. Files in $root were left untouched."
+ } else {
+ Write-Host "Service '$serviceId' is not installed; nothing to remove."
+ }
+ exit 0
+}
+
+# ---------- install / reinstall ----------
+Assert-Admin
+
+if (-not (Test-Path $wrapper)) {
+ throw "Missing wrapper executable: $wrapper`nRebuild the deploy package with pack-deploy.ps1 (it provides WinSW), or copy WinSW-x64.exe here renamed to trafficAudit.exe."
+}
+$passwordOk = Test-AppConfigPassword -Path $appConfig
+
+$java = Resolve-JavaPath -Explicit $JavaPath
+Set-WrapperJavaPath -JavaExe $java
+
+$existing = Get-ServiceState
+if ($existing) {
+ Write-Host "Service '$serviceId' already exists (status: $($existing.Status)); reinstalling..."
+ if ($existing.Status -ne 'Stopped') {
+ & $wrapper stop | Out-Null
+ Start-Sleep -Seconds 3
+ }
+ & $wrapper uninstall | Out-Null
+ Start-Sleep -Seconds 2
+}
+
+Write-Host "Installing service '$serviceId'..."
+& $wrapper install
+if ($LASTEXITCODE -ne 0) { throw "WinSW install failed with exit code $LASTEXITCODE" }
+
+if (-not $NoStart) {
+ Write-Host "Starting service '$serviceId' (auto-start at boot is enabled)..."
+ & $wrapper start
+ Start-Sleep -Seconds 5
+ $service = Get-ServiceState
+ $service | Select-Object Name, DisplayName, Status, StartType | Format-List
+
+ # Read the port from application.yml so the check matches the real config.
+ $port = 8090
+ if (Test-Path $appConfig) {
+ $portLine = Select-String -LiteralPath $appConfig -Pattern '^\s*port\s*:' | Select-Object -First 1
+ if ($portLine -and $portLine.Line -match '(\d{2,5})') { $port = [int]$matches[1] }
+ }
+
+ $ready = $false
+ for ($i = 1; $i -le 30; $i++) {
+ if (Test-LocalPort -Port $port) { $ready = $true; break }
+ Start-Sleep -Seconds 2
+ $state = Get-ServiceState
+ if ($state -and $state.Status -ne 'Running') { break }
+ }
+
+ $logDir = Join-Path $root 'logs'
+ Write-Host ''
+ if ($ready) {
+ Write-Host "OK : the backend is listening on port $port."
+ Write-Host " On this server : http://127.0.0.1:$port"
+ Write-Host " For other people : http://<server-ip>:$port"
+ } else {
+ Write-Host "WARN: nothing is listening on port $port yet - startup is still running, or it failed."
+ Write-Host " Fix application.yml / MySQL, then run: .\install-service.ps1 -Restart"
+ foreach ($name in @('trafficAudit.err.log', 'trafficAudit.out.log')) {
+ $logFile = Join-Path $logDir $name
+ if (Test-Path -LiteralPath $logFile) {
+ Write-Host ''
+ Write-Host "---- last 15 lines of $name ----"
+ Get-Content -LiteralPath $logFile -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" }
+ }
+ }
+ }
+
+ if (-not $passwordOk) {
+ Write-Host ''
+ Write-Host '=============================================================='
+ Write-Host ' IMPORTANT: backend\application.yml still looks unset.'
+ Write-Host ' Put the real MySQL url / username / password in it, then run'
+ Write-Host ' .\install-service.ps1 -Restart'
+ Write-Host '=============================================================='
+ }
+
+ Write-Host ''
+ Write-Host "Logs : $logDir\trafficAudit.out.log / trafficAudit.err.log"
+ Write-Host "Manage: .\install-service.ps1 -Status | -Restart | -Remove"
+ Write-Host " Stop-Service $serviceId | Start-Service $serviceId"
+ Write-Host ''
+ Write-Host 'This service starts automatically on every boot (Automatic + delayed start).'
+}
diff --git a/deploy-templates/backend/start-backend-service.cmd b/deploy-templates/backend/start-backend-service.cmd
new file mode 100644
index 0000000..eb6c71c
--- /dev/null
+++ b/deploy-templates/backend/start-backend-service.cmd
@@ -0,0 +1,49 @@
+@echo off
+rem ============================================================
+rem Non-interactive launcher for Task Scheduler / boot auto-start.
+rem Differences from start-backend.cmd (which is meant for a human):
+rem - no "pause" at the end (nobody can press a key during boot)
+rem - all output goes to app.log next to this file
+rem - resolves java via JAVA_HOME first, then PATH
+rem - exits 0 immediately if port 8090 is already listening
+rem ASCII-only on purpose: cmd.exe reads this file with the OEM
+rem code page, so non-ASCII text here would be garbled.
+rem Registered by install-autostart.ps1; run under the account that
+rem owns the deployment folder (SYSTEM by default).
+rem ============================================================
+setlocal
+cd /d "%~dp0"
+
+set "APP_JAR=traffic-audit-server.jar"
+set "LOG=%~dp0app.log"
+
+netstat -ano | findstr /C:":8090" | findstr /I "LISTENING" >nul 2>nul
+if not errorlevel 1 (
+ echo [%date% %time%] port 8090 is already listening; nothing to do. >> "%LOG%"
+ exit /b 0
+)
+
+if not exist "%~dp0%APP_JAR%" (
+ echo [%date% %time%] ERROR: %APP_JAR% not found in "%CD%". >> "%LOG%"
+ exit /b 1
+)
+
+set "JAVA_EXE="
+if defined JAVA_HOME if exist "%JAVA_HOME%\bin\java.exe" set "JAVA_EXE=%JAVA_HOME%\bin\java.exe"
+if not defined JAVA_EXE for %%J in (java.exe) do set "JAVA_EXE=%%~$PATH:J"
+if not defined JAVA_EXE (
+ echo [%date% %time%] ERROR: java.exe not found. Install JDK 8 or set JAVA_HOME / PATH. >> "%LOG%"
+ exit /b 1
+)
+
+if not exist "%~dp0application.yml" (
+ echo [%date% %time%] WARN: application.yml is missing next to the jar. >> "%LOG%"
+ echo [%date% %time%] The DB password placeholder will resolve to empty and startup will fail with >> "%LOG%"
+ echo [%date% %time%] "Access denied ... (using password: NO)". >> "%LOG%"
+)
+
+echo [%date% %time%] starting backend: "%JAVA_EXE%" -jar "%APP_JAR%" (cwd "%CD%") >> "%LOG%"
+"%JAVA_EXE%" -jar "%~dp0%APP_JAR%" >> "%LOG%" 2>&1
+set "RC=%ERRORLEVEL%"
+echo [%date% %time%] backend exited with code %RC%. >> "%LOG%"
+endlocal & exit /b %RC%
diff --git a/deploy-templates/backend/start-backend.cmd b/deploy-templates/backend/start-backend.cmd
index a95e5c3..b2db8f6 100644
--- a/deploy-templates/backend/start-backend.cmd
+++ b/deploy-templates/backend/start-backend.cmd
@@ -7,10 +7,20 @@
pause
exit /b 1
)
+if not exist "%~dp0application.yml" (
+ echo [WARN] 鏈壘鍒� %~dp0application.yml
+ echo 娌℃湁澶栫疆閰嶇疆鏃讹紝jar 鍐呯疆瀵嗙爜鍗犱綅绗︿細瑙f瀽涓虹┖锛�
+ echo 鍚姩灏嗘姤 java.sql.SQLException: Access denied ... ^(using password: NO^)
+)
+findstr /C:"璇锋敼鎴愪綘鐨勬暟鎹簱瀵嗙爜" "%~dp0application.yml" >nul 2>nul
+if not errorlevel 1 (
+ echo [WARN] application.yml 閲� spring.datasource.password 杩樻槸鍗犱綅鏂囧瓧锛�
+ echo 璇峰厛鏀规垚閮ㄧ讲鏈虹湡瀹炵殑 MySQL 瀵嗙爜鍐嶅惎鍔ㄣ��
+)
echo ============================================
echo TrafficAudit 鍚姩涓細http://localhost:8090
echo 鍓嶇宸插唴宓岋紝鐩存帴璁块棶 8090 鍗冲彲锛堜笉闇�瑕� nginx锛�
echo 鏃ュ織鏄剧ず鍦ㄦ湰绐楀彛锛屽叧闂獥鍙e嵆鍋滄鏈嶅姟
echo ============================================
-java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
+java -jar traffic-audit-server.jar
pause
diff --git a/deploy-templates/backend/start-backend.sh b/deploy-templates/backend/start-backend.sh
index 0d84961..8a18884 100644
--- a/deploy-templates/backend/start-backend.sh
+++ b/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锛坖ar 鍚岀骇锛夛細鏁版嵁搴撳瘑鐮佷細瑙f瀽涓虹┖锛屽彲鑳芥姤 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 锛圕trl+C 鍋滄锛�"
-java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar 2>&1 | tee app.log
+java -jar traffic-audit-server.jar 2>&1 | tee app.log
diff --git a/deploy-templates/backend/trafficAudit.xml b/deploy-templates/backend/trafficAudit.xml
new file mode 100644
index 0000000..43e5234
--- /dev/null
+++ b/deploy-templates/backend/trafficAudit.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ WinSW (Windows Service Wrapper) configuration for the TrafficAudit backend.
+
+ Layout requirement: this file MUST stay in the same folder as the wrapper
+ executable named trafficAudit.exe, the application jar, application.yml,
+ docs\ and web\. WinSW looks for a config file whose name matches the
+ executable (trafficAudit.exe -> trafficAudit.xml).
+
+ %BASE% is expanded by WinSW to the folder that holds this file, so the
+ service always runs with that folder as its working directory.
+
+ The executable element below is rewritten with an absolute java.exe path by
+ install-service.ps1 (a SYSTEM service does not inherit the interactive
+ user PATH, so relying on PATH alone is fragile).
+-->
+<service>
+ <id>TrafficAuditBackend</id>
+ <name>TrafficAudit Backend</name>
+ <description>TrafficAudit freight statistics audit backend (Spring Boot, HTTP port 8090).</description>
+
+ <executable>java.exe</executable>
+ <arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"</arguments>
+ <workingdirectory>%BASE%</workingdirectory>
+
+ <!-- Start at boot, but delayed so a local MySQL service is ready first. -->
+ <startmode>Automatic</startmode>
+ <delayedAutoStart>true</delayedAutoStart>
+
+ <!-- Restart on crash: quickly at first, then back off. Counter resets after 1 hour. -->
+ <onfailure action="restart" delay="10 sec"/>
+ <onfailure action="restart" delay="30 sec"/>
+ <onfailure action="restart" delay="60 sec"/>
+ <resetfailure>1 hour</resetfailure>
+
+ <stoptimeout>30 sec</stoptimeout>
+
+ <logpath>%BASE%\logs</logpath>
+ <log mode="roll-by-size">
+ <sizeThreshold>10240</sizeThreshold>
+ <keepFiles>10</keepFiles>
+ </log>
+ <outfile>trafficAudit.out.log</outfile>
+ <errfile>trafficAudit.err.log</errfile>
+</service>
diff --git a/deploy-templates/db/init.sql b/deploy-templates/db/init.sql
index f75c5d7..7d79383 100644
--- a/deploy-templates/db/init.sql
+++ b/deploy-templates/db/init.sql
@@ -399,6 +399,8 @@
region_code VARCHAR(20) COMMENT '鎵�灞炲湴鍖�',
enterprise_code VARCHAR(50) COMMENT '浼佷笟浠g爜',
enterprise_name VARCHAR(200) COMMENT '浼佷笟鍚嶇О',
+ unified_credit_code VARCHAR(50) COMMENT '缁熶竴绀句細淇$敤浠g爜',
+ report_unit VARCHAR(200) COMMENT '濉姤鍗曚綅',
vehicle_total INT COMMENT '瀹㈣繍杞﹁締_鎬昏',
seat_total INT COMMENT '鏍稿畾杞藉浣峗鎬昏',
vehicle_schedule INT COMMENT '杞﹁締鏁癬瀹㈣繍鐝溅锛堝惈瀹氱嚎鏃呮父瀹㈣繍瀹㈣溅锛�',
@@ -617,3 +619,84 @@
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_type_period (report_type, report_period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鎶ヨ〃鏈熷鏍搁�氳繃鏍囪';
+
+
+-- ============================================
+-- 鍗佷竴銆佷笓椤圭粺璁�-閲嶈鑺傚亣鏃�
+-- ============================================
+
+-- 鑺傚亣鏃ユ棩鏈熼厤缃�
+DROP TABLE IF EXISTS holiday_calendar;
+CREATE TABLE holiday_calendar (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ holiday_type VARCHAR(32) NOT NULL COMMENT '鑺傚亣鏃ュ垎绫伙細SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
+ holiday_date DATE NOT NULL COMMENT '鑺傚亣鏃ュ叿浣撴棩鏈�',
+ day_index INT NOT NULL COMMENT '0=鍓嶄竴鏃ワ紝1..N=鍋囨湡绗琋鏃�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
+ UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
+ KEY idx_holiday_calendar_date (holiday_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='閲嶈鑺傚亣鏃ユ棩鏈熼厤缃�';
+
+-- 鑺傚亣鏃ョ患鍚堣繍杈撹惀涓氭�у娴�
+DROP TABLE IF EXISTS holiday_passenger_flow;
+CREATE TABLE holiday_passenger_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ flow_date DATE NOT NULL COMMENT '鏃ユ湡',
+ transport_mode VARCHAR(32) NOT NULL COMMENT '杩愯緭鏂瑰紡锛歊AIL/CIVIL_AVIATION/ROAD/WATER',
+ passenger_count DECIMAL(30,15) NOT NULL COMMENT '鍙戦�佹梾瀹㈡暟锛堜竾浜烘锛�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
+ KEY idx_holiday_passenger_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鑺傚亣鏃ョ患鍚堣繍杈撹惀涓氭�у娴佹儏鍐�';
+
+-- 鑺傚亣鏃ョ患鍚堣繍杈撻潪钀ヤ笟鎬ц溅娴�
+DROP TABLE IF EXISTS holiday_vehicle_flow;
+CREATE TABLE holiday_vehicle_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ flow_date DATE NOT NULL COMMENT '鏃ユ湡',
+ road_category VARCHAR(32) NOT NULL COMMENT '閬撹矾鍒嗙被锛欵XPRESSWAY/NATIONAL_PROVINCIAL',
+ vehicle_type VARCHAR(32) NOT NULL COMMENT '杞﹀瀷鍒嗙被锛歋MALL_PASSENGER/MEDIUM_SMALL_PASSENGER',
+ vehicle_count DECIMAL(30,15) NOT NULL COMMENT '杞︽祦閲忥紙涓囪締娆★級',
+ comparison_base DECIMAL(30,15) NULL COMMENT '鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼粎褰撳勾浣跨敤锛�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
+ KEY idx_holiday_vehicle_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鑺傚亣鏃ョ患鍚堣繍杈撻潪钀ヤ笟鎬ц溅娴佹儏鍐�';
+
+-- 寰�骞村浗鐪侀亾杞︽祦閲忚皟鏁�
+DROP TABLE IF EXISTS holiday_vehicle_adjustment;
+CREATE TABLE holiday_vehicle_adjustment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ flow_date DATE NOT NULL COMMENT '鏃ユ湡',
+ adjusted_value DECIMAL(30,15) NOT NULL COMMENT '璋冩暣鍚庢櫘閫氬浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級',
+ reason VARCHAR(500) NOT NULL COMMENT '璋冩暣鍘熷洜',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
+ KEY idx_holiday_vehicle_adjustment_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='寰�骞村浗鐪侀亾杞︽祦閲忚皟鏁�';
+
+-- 瀹㈣繍绯绘暟閰嶇疆
+DROP TABLE IF EXISTS holiday_passenger_coefficient;
+CREATE TABLE holiday_passenger_coefficient (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ date_category VARCHAR(32) NOT NULL COMMENT '鏃ユ湡鍒嗙被锛欻OLIDAY/NON_HOLIDAY',
+ coefficient DECIMAL(30,15) NOT NULL COMMENT '瀹㈣繍绯绘暟',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_coefficient (year, date_category)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='瀹㈣繍绯绘暟閰嶇疆';
diff --git a/deploy-templates/db/sql_holiday.sql b/deploy-templates/db/sql_holiday.sql
new file mode 100644
index 0000000..6d80576
--- /dev/null
+++ b/deploy-templates/db/sql_holiday.sql
@@ -0,0 +1,67 @@
+-- Holiday statistics module. Run against the traffic_audit database.
+CREATE TABLE IF NOT EXISTS holiday_calendar (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ holiday_type VARCHAR(32) NOT NULL,
+ holiday_date DATE NOT NULL,
+ -- 0 is the configured previous day; normal holiday days are 1..N.
+ day_index INT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
+ UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
+ KEY idx_holiday_calendar_date (holiday_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_passenger_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ flow_date DATE NOT NULL,
+ transport_mode VARCHAR(32) NOT NULL,
+ passenger_count DECIMAL(30,15) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
+ KEY idx_holiday_passenger_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_vehicle_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ flow_date DATE NOT NULL,
+ road_category VARCHAR(32) NOT NULL,
+ vehicle_type VARCHAR(32) NOT NULL,
+ vehicle_count DECIMAL(30,15) NOT NULL,
+ comparison_base DECIMAL(30,15) NULL COMMENT '鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼粎褰撳勾浣跨敤锛�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
+ KEY idx_holiday_vehicle_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ flow_date DATE NOT NULL,
+ adjusted_value DECIMAL(30,15) NOT NULL,
+ reason VARCHAR(500) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
+ KEY idx_holiday_vehicle_adjustment_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_passenger_coefficient (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ date_category VARCHAR(32) NOT NULL,
+ coefficient DECIMAL(30,15) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_coefficient (year, date_category)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/deploy-templates/db/sql_holiday_comparison_base_migration.sql b/deploy-templates/db/sql_holiday_comparison_base_migration.sql
new file mode 100644
index 0000000..6c73353
--- /dev/null
+++ b/deploy-templates/db/sql_holiday_comparison_base_migration.sql
@@ -0,0 +1,6 @@
+-- Add the optional ordinary national/provincial road comparison base.
+-- The field is used only for the 2026 holiday data.
+ALTER TABLE holiday_vehicle_flow
+ ADD COLUMN comparison_base DECIMAL(30,15) NULL
+ COMMENT '鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼粎褰撳勾浣跨敤锛�'
+ AFTER vehicle_count;
diff --git a/deploy-templates/db/sql_holiday_decimal_migration.sql b/deploy-templates/db/sql_holiday_decimal_migration.sql
new file mode 100644
index 0000000..6235c17
--- /dev/null
+++ b/deploy-templates/db/sql_holiday_decimal_migration.sql
@@ -0,0 +1,16 @@
+-- Convert holiday statistics numeric columns from binary floating point to exact decimal storage.
+-- Run once against the traffic_audit database.
+ALTER TABLE holiday_passenger_flow
+ MODIFY COLUMN passenger_count DECIMAL(30,15) NOT NULL COMMENT '鍙戦�佹梾瀹㈡暟锛堜竾浜烘锛�';
+
+ALTER TABLE holiday_vehicle_flow
+ MODIFY COLUMN vehicle_count DECIMAL(30,15) NOT NULL COMMENT '杞︽祦閲忥紙涓囪締娆★級';
+
+ALTER TABLE holiday_passenger_coefficient
+ MODIFY COLUMN coefficient DECIMAL(30,15) NOT NULL COMMENT '瀹㈣繍绯绘暟';
+
+-- The seeded coefficient was averaged from repeated 2.4 cells through a Java double.
+-- Normalize the migrated value to the exact Excel decimal.
+UPDATE holiday_passenger_coefficient
+SET coefficient = 2.4
+WHERE year IN (2024, 2025, 2026);
diff --git a/deploy-templates/db/sql_holiday_previous_day_migration.sql b/deploy-templates/db/sql_holiday_previous_day_migration.sql
new file mode 100644
index 0000000..3fcb16c
--- /dev/null
+++ b/deploy-templates/db/sql_holiday_previous_day_migration.sql
@@ -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());
diff --git a/deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql b/deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql
new file mode 100644
index 0000000..3c98eb6
--- /dev/null
+++ b/deploy-templates/db/sql_holiday_vehicle_adjustment_migration.sql
@@ -0,0 +1,14 @@
+-- Add the historical national/provincial road adjustment table.
+-- Run against the traffic_audit database on upgrade.
+CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT 'year',
+ flow_date DATE NOT NULL COMMENT 'holiday date',
+ adjusted_value DECIMAL(30,15) NOT NULL COMMENT 'adjusted national/provincial vehicle flow (10k vehicle trips)',
+ reason VARCHAR(500) NOT NULL COMMENT 'adjustment reason',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
+ KEY idx_holiday_vehicle_adjustment_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='historical national/provincial vehicle flow adjustments';
diff --git a/deploy-templates/db/sql_observation_station.sql b/deploy-templates/db/sql_observation_station.sql
new file mode 100644
index 0000000..01c638d
--- /dev/null
+++ b/deploy-templates/db/sql_observation_station.sql
@@ -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;
diff --git a/deploy-templates/jar-application.yml b/deploy-templates/jar-application.yml
new file mode 100644
index 0000000..9bfc876
--- /dev/null
+++ b/deploy-templates/jar-application.yml
@@ -0,0 +1,94 @@
+# ============================================================
+# 鎵撳寘鐢ㄣ�岃繘 jar 鐨勯粯璁ら厤缃�嶁�斺�斿彧鍏佽鍗犱綅绗︼紝绂佹鐪熷疄瀵嗛挜/鍙d护锛�
+#
+# 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
+#
+# 娉ㄦ剰锛歱assword 榛樿鍊间负绌轰覆锛岃嫢閮ㄧ讲鏈烘棦娌℃湁澶栫疆 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锛歁yBatis-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
diff --git "a/docs/Excel\346\265\256\347\202\271\346\225\260\345\255\230\345\202\250.txt" "b/docs/Excel\346\265\256\347\202\271\346\225\260\345\255\230\345\202\250.txt"
new file mode 100644
index 0000000..6aac314
--- /dev/null
+++ "b/docs/Excel\346\265\256\347\202\271\346\225\260\345\255\230\345\202\250.txt"
@@ -0,0 +1,152 @@
+excel鐨勬暟鍊艰绠楅潪甯哥簿纭紝鍏跺唴閮ㄦ槸鎬庝箞瀛樺偍鏁板�肩殑鍛紝鏄敤娴偣鏁板悧
+鐢ㄦ埛鎯崇煡閬揈xcel鍐呴儴濡備綍瀛樺偍鏁板�硷紝鏄惁浣跨敤娴偣鏁般�傛垜闇�瑕佹彁渚涘噯纭殑瑙i噴銆備负浜嗗叏闈㈣幏鍙栦俊鎭紝鎴戝皢鍚屾椂杩涜澶氶」鎼滅储锛岃鐩朎xcel鏁板�煎瓨鍌ㄦ満鍒躲�佹诞鐐规暟绮惧害闂銆佷笌IEEE 754鏍囧噯鐨勫叧绯讳互鍙婂彲鑳界殑浼樺寲鎶�鏈��
+
+鎼滅储鍒� 16 涓綉椤�
+鎼滅储缁撴灉鏄剧ず浜嗕竴浜涚浉鍏抽〉闈€�備负浜嗗叏闈㈣幏鍙栦俊鎭紝鎴戜滑闇�瑕佸悓鏃舵墦寮�杩欎簺椤甸潰銆�
+
+娴忚 1 涓〉闈�
+Floating Point Errors in Excel
+杩欎簺璧勬枡鎻愪緵浜嗗叧浜嶦xcel鏁板�煎瓨鍌ㄧ殑璇︾粏淇℃伅銆傚井杞殑鏂囨。鏄庣‘鎸囧嚭Excel閬靛惊IEEE 754鏍囧噯锛屼娇鐢ㄥ弻绮惧害娴偣鏁帮紝鏈夋晥鏁板瓧涓�15浣嶃�係tack Overflow鐨勮璁轰篃纭浜嗚繖涓�鐐广�侲xcelJet鐨勬枃绔犺В閲婁簡娴偣鏁拌宸殑鍘熷洜銆傛垜鐨勫洖绛斿皢娑电洊浠ヤ笅鍑犱釜鏂归潰锛欵xcel浣跨敤IEEE 754鍙岀簿搴︽诞鐐规暟锛�64浣嶏級锛屾湁鏁堢簿搴︿负15浣嶏紝瀛樺偍鑼冨洿锛屼互鍙婃诞鐐规暟璇樊鍙婂叾鍘熷洜銆傛垜杩樹細鎻愬強涓�浜涚壒娈婃儏鍐碉紝姣斿鏁存暟瀛樺偍鍜屾棩鏈熸椂闂村瓨鍌ㄣ�備负浜嗘洿鍏ㄩ潰鍦拌鐩栬繖浜涙柟闈紝鎴戦渶瑕佸悓鏃惰繘琛屽椤规悳绱€��
+
+鎼滅储鍒� 12 涓綉椤�
+鐜板湪鍙互缁勭粐绛旀浜嗐�傜瓟妗堝皢娑电洊锛欵xcel浣跨敤IEEE 754鍙岀簿搴︽诞鐐规暟锛�64浣嶏級锛屾湁鏁堢簿搴︿负15浣嶏紝瀛樺偍鑼冨洿锛屼互鍙婃诞鐐规暟璇樊鍙婂叾鍘熷洜銆傝繕浼氭彁鍙婃暣鏁板拰鏃ユ湡鏃堕棿鐨勫瓨鍌ㄦ柟寮忋�傚洖绛旀椂浼氬紩鐢ㄥ井杞枃妗c�丼tack Overflow绛夋潵婧愩��
+
+鏄殑锛孍xcel 鐨勬暟鍊艰绠楃‘瀹炲熀浜庢诞鐐规暟锛屽叿浣撴潵璇达紝瀹冮伒寰� IEEE 754 鏍囧噯鐨� 64 浣嶅弻绮惧害娴偣鏁版牸寮忔潵瀛樺偍鍜岃绠楁暟鍊笺��
+
+鏍稿績鏈哄埗锛�64浣嶅弻绮惧害娴偣鏁�
+Excel 鍐呴儴灏嗘墍鏈夊伐浣滆〃鍗曞厓鏍间腑鐨勬暟瀛楋紙鍖呮嫭鏁存暟銆佹棩鏈熷拰鏃堕棿锛夐兘缁熶竴瀛樺偍涓� 8瀛楄妭锛�64浣嶏級鐨勫弻绮惧害娴偣鏁般��
+
+杩欑瀛樺偍鏍煎紡鐨� 64 涓瘮鐗逛綅琚垝鍒嗕负涓変釜閮ㄥ垎锛�
+
+绗﹀彿浣� (1浣�)锛氳〃绀烘璐熴��
+
+鎸囨暟浣� (11浣�)锛氬喅瀹氭暟鍊肩殑鑼冨洿銆�
+
+灏炬暟浣� (52浣�)锛氬喅瀹氭暟鍊肩殑绮惧害銆�
+
+绮惧害闄愬埗锛�15浣嶆湁鏁堟暟瀛�
+灏界鍙岀簿搴︽诞鐐规暟鑳芥彁渚涚害 15 鍒� 16 浣嶇殑鍗佽繘鍒舵湁鏁堟暟瀛楋紝浣� Excel 涓轰簡纭繚璁$畻缁撴灉鐨勫彲闈犳�э紝灏嗘湁鏁堢簿搴﹂檺鍒跺湪 15 浣嶃��
+
+杩欐剰鍛崇潃锛屽鏋滀竴涓暟瀛楃殑鏈夋晥浣嶆暟瓒呰繃 15 浣嶏紝浠庣 16 浣嶅紑濮嬪皢琚浛鎹负 0銆備緥濡傦紝杈撳叆 123456789012345678锛孍xcel 瀹為檯瀛樺偍鐨勫皢鏄� 123456789012345000銆�
+
+鏁板�艰寖鍥�
+鍩轰簬姝ゆ爣鍑嗭紝Excel 鍙互澶勭悊鐨勬暟鍊艰寖鍥村涓嬶細
+
+鏈�澶ф鏁帮細1.79769313486232E+308
+
+鏈�灏忔鏁帮細2.2250738585072E-308
+
+瓒呭嚭姝よ寖鍥寸殑鏁板�间細琚涓衡�滄棤绌峰ぇ鈥濆苟鏄剧ず涓洪敊璇�笺��
+
+涓轰粈涔堜細鏈夋诞鐐规暟璇樊锛�
+浣犲彲鑳戒細閬囧埌 Excel 璁$畻 0.1 + 0.2 寰楀埌 0.30000000000000004 鐨勬儏鍐碉紝杩欏苟闈� Bug锛岃�屾槸浜岃繘鍒舵诞鐐规暟鐨勫浐鏈夌壒鎬с��
+
+鍏舵牴鏈師鍥犲湪浜庯紝鍍� 0.1 杩欐牱鐨勫崄杩涘埗灏忔暟鏃犳硶鐢ㄤ簩杩涘埗娴偣鏁扮簿纭〃绀猴紝灏卞儚鍗佽繘鍒舵棤娉曠簿纭〃绀� 1/3 涓�鏍枫�侲xcel 瀛樺偍鐨勬槸涓�涓瀬鍏舵帴杩� 0.1 鐨勮繎浼煎�硷紝褰撳涓繖鏍风殑杩戜技鍊艰繘琛岃繍绠楁椂锛屽井灏忕殑璇樊浼氱疮绉紝鏈�缁堝鑷寸粨鏋滃嚭鐜版瀬灏忕殑鍋忓樊銆�
+
+鍏朵粬鏁版嵁绫诲瀷鐨勫瓨鍌�
+鏁存暟锛氫篃鎸夊弻绮惧害娴偣鏁板瓨鍌紝鍥犳 Excel 鑳界簿纭〃绀虹殑鏁存暟涓婇檺鏄� 2^53锛堢害 9,007,199,254,740,992锛夈�傝秴杩囨鑼冨洿鐨勬暣鏁板彲鑳戒細涓㈠け绮惧害銆�
+
+鏃ユ湡鍜屾椂闂达細鍚屾牱鏄诞鐐规暟銆傛暣鏁伴儴鍒嗕唬琛ㄦ棩鏈燂紙鑷� 1900 骞� 1 鏈� 1 鏃ヤ互鏉ョ殑澶╂暟锛夛紝灏忔暟閮ㄥ垎浠h〃鏃堕棿锛堜竴澶╀腑鐨勬瘮渚嬶級銆�
+
+鎬荤粨鏉ヨ锛孍xcel 鐨勬暟鍊艰绠楀湪搴曞眰灏辨槸鏍囧噯鐨� IEEE 754 鍙岀簿搴︽诞鐐规暟锛屽叾鈥滅簿纭�濇槸鐩稿鐨勶紝鍙楅檺浜� 15 浣嶆湁鏁堟暟瀛楃殑绮惧害鍜屼簩杩涘埗娴偣鏁版湰韬殑琛ㄧず闄愬埗銆�
+
+閭d箞鐢↗ava澶勭悊excel鐨勬暟鎹椂锛屼负浜嗕繚鎸佷笌excel鐨勮绠楃粨鏋滀竴鑷达紝鏁版嵁搴撳拰Java涓浣曞瓨鍌ㄥ拰璁$畻鍛�
+鐢ㄦ埛鎯崇煡閬撳浣曞湪Java涓鐞咵xcel鏁版嵁锛屼互淇濇寔涓嶦xcel璁$畻缁撴灉涓�鑷达紝娑夊強鏁版嵁搴撳拰Java涓殑瀛樺偍涓庤绠椼�傛垜闇�瑕佹彁渚涘叧浜庢暟鎹被鍨嬮�夋嫨銆佽绠楃瓥鐣ヤ互鍙婂彲鑳介亣鍒扮殑绮惧害闂鏂归潰鐨勫缓璁�備负浜嗗叏闈㈣幏鍙栦俊鎭紝鎴戝皢鍚屾椂杩涜澶氶」鎼滅储锛岃鐩朖ava澶勭悊Excel绮惧害銆佹暟鎹簱瀛樺偍绛栫暐銆丅igDecimal浣跨敤銆丄pache POI澶勭悊鏂瑰紡浠ュ強IEEE 754鍏煎鎬х瓑鍏抽敭鏂归潰銆�
+
+鎼滅储鍒� 36 涓綉椤�
+鎼滅储缁撴灉鏄剧ず浜嗕竴浜涚浉鍏宠祫婧愩�備负浜嗗叏闈㈣幏鍙栦俊鎭紝鎴戜滑闇�瑕佸悓鏃舵墦寮�杩欎簺缁撴灉銆�
+
+娴忚 4 涓〉闈�
+NumberComparer (POI API Documentation)
+EasyExcel璇诲叆鏁板瓧绫诲瀷鏁版嵁鏃跺嚭鐜板皬鏁颁綅澧為暱鐜拌薄 - EasyExcel璇诲叆鏁板瓧绫诲瀷鏁版嵁鏃跺嚭鐜板皬鏁颁綅澧為暱鐜拌薄
+銆愬畬缇庤В鍐砅OI璇诲彇Excel鏂囨。鏁板�肩被鍨嬬己澶辩簿搴﹂棶棰樸��
+MySQL :: MySQL 9.0 鍙冭�冩墜鍐� :: 13.1.3 瀹氶粸椤炲瀷锛堢簿纰哄�硷級- DECIMAL銆丯UMERIC
+杩欎簺璧勬枡鎻愪緵浜嗗叧浜庣簿搴﹂棶棰樸�丄pache POI鐨凬umberToTextConverter銆丒xcel鐨�15浣嶇簿搴﹂檺鍒朵互鍙婃暟鎹簱DECIMAL绫诲瀷鐨勪俊鎭�傛垜鐨勫洖绛斿皢娑电洊Java涓殑瀛樺偍涓庤绠椼�佹暟鎹簱涓殑瀛樺偍銆佷互鍙婂浣曚繚鎸佺粨鏋滀竴鑷淬�傚洖绛斾細寮曠敤杩欎簺璧勬枡銆傜幇鍦ㄥ紑濮嬬粍缁囧洖绛斻��
+
+瑕佸湪Java涓鐞咵xcel鏁版嵁骞朵繚鎸佺粨鏋滀竴鑷达紝鏍稿績鎬濊矾鏄皢Excel鐨�15浣嶆湁鏁堟暟瀛楃簿搴︿綔涓哄熀鍑嗭紝鍦↗ava鍜屾暟鎹簱涓噰鐢ㄦ洿楂樼簿搴︽垨绮剧‘绫诲瀷杩涜瀛樺偍锛屽苟缁熶竴璁$畻涓庤垗鍏ヨ鍒欍��
+
+馃捑 绗竴姝ワ細鍦↗ava涓浣曞瓨鍌ㄤ笌璇诲彇
+Java鐨� double 绫诲瀷鍚屾牱閬靛惊 IEEE 754 鏍囧噯锛屼絾鍏舵湁鏁堟暟瀛楀彲杈�15-17浣嶏紝鑰孍xcel涓ユ牸闄愬埗涓�15浣嶏紝杩欐槸宸紓鐨勬牴婧愩��
+
+璇诲彇鏃讹細鎺ㄨ崘鐢� BigDecimal 鎴� String
+
+鐩存帴鐢� double 鎺ユ敹 Excel 鏁板�间細寮曞叆棰濆鐨勪簩杩涘埗娴偣璇樊銆備緥濡傦紝Excel 涓殑 9699.1 璇绘垚 double 鍙兘鍙樻垚 9699.0999999999999996銆�
+
+鎺ㄨ崘鏂规锛�
+
+璇诲彇涓� BigDecimal锛氬湪瀹炰綋绫讳腑锛屽皢閲戦銆佹暟閲忕瓑鏁板�煎瓧娈靛0鏄庝负 BigDecimal 绫诲瀷锛岃�岄潪 double 鎴� float銆�
+
+浣跨敤 POI 鐨� NumberToTextConverter锛欰pache POI 鎻愪緵浜� NumberToTextConverter.toText(double) 宸ュ叿绫伙紝鍙互灏� Excel 鐨勫師濮嬫暟鍊肩簿纭湴杞崲涓哄瓧绗︿覆锛屽啀鐢卞瓧绗︿覆鏋勯�� BigDecimal锛岄伩鍏嶄腑闂寸簿搴︽崯澶便��
+
+java
+import org.apache.poi.ss.util.NumberToTextConverter;
+import java.math.BigDecimal;
+
+if (cell.getCellType() == CellType.NUMERIC) {
+ String exactText = NumberToTextConverter.toText(cell.getNumericCellValue());
+ BigDecimal value = new BigDecimal(exactText);
+ // 姝ゆ椂 value 鏄� Excel 涓瓨鍌ㄧ殑绮剧‘鍊硷紙15浣嶆湁鏁堟暟瀛楋級
+}
+馃梽锔� 绗簩姝ワ細鍦ㄦ暟鎹簱涓浣曞瓨鍌�
+鏁版嵁搴撳瓧娈靛垏蹇屼娇鐢� FLOAT 鎴� DOUBLE锛屽洜涓哄畠浠悓鏍峰瓨鍦ㄤ簩杩涘埗娴偣璇樊锛屼細瀵艰嚧涓嶦xcel缁撴灉鐨勫亸宸��
+
+鎺ㄨ崘鏂规锛氫娇鐢� DECIMAL 绫诲瀷
+
+DECIMAL锛堟垨 NUMERIC锛夋槸瀹氱偣绮剧‘绫诲瀷锛屽畠鎸夋暣鏁颁綅鍜屽皬鏁颁綅瀛樺偍锛岃兘绮剧‘琛ㄧず鍗佽繘鍒跺皬鏁般��
+
+鏁版嵁搴� 鎺ㄨ崘绫诲瀷 璇存槑
+MySQL DECIMAL(p, s) p 涓烘�荤簿搴︼紝s 涓哄皬鏁颁綅銆侲xcel 鏈夋晥浣�15锛屽缓璁� p 鑷冲皯涓� 15锛屽啀鏍规嵁灏忔暟浣嶉渶姹傝瀹氥�備緥濡� DECIMAL(18, 6)銆�
+PostgreSQL NUMERIC(p, s) 鍚屼笂锛屾爣鍑哠QL绫诲瀷銆�
+SQL Server DECIMAL(p, s) 鍚屼笂銆�
+Oracle NUMBER(p, s) NUMBER 绫诲瀷鍙瓨鍌ㄥ畾鐐规暟锛屾晥鏋滅瓑鍚屻��
+JDBC 鏄犲皠锛氫娇鐢� JDBC 璇诲啓鏃讹紝DECIMAL 瀛楁浼氱洿鎺ユ槧灏勪负 Java 鐨� BigDecimal锛屼繚璇佷簡绮惧害鍦ㄤ紶閫掕繃绋嬩腑涓嶄涪澶便��
+
+馃М 绗笁姝ワ細Java涓殑璁$畻涓庤垗鍏ヨ鍒�
+鍗充娇瀛樺偍绮剧‘锛岃绠楅�昏緫涔熷繀椤绘ā鎷� Excel 鐨勮涓恒��
+
+1. 濮嬬粓浣跨敤 BigDecimal 杩涜璁$畻
+
+BigDecimal 鐨勫姞鍑忎箻闄わ紙add, subtract, multiply, divide锛夐兘鏄簿纭绠楋紝涓嶄細寮曞叆浜岃繘鍒惰宸�傚垏鍕垮皢 BigDecimal 杞洖 double 鍚庡啀璁$畻銆�
+
+2. 妯℃嫙 Excel 鐨�15浣嶆湁鏁堟暟瀛楅檺鍒�
+
+Excel 鍦ㄦ瘡娆″皢涓棿缁撴灉鍐欏叆鍗曞厓鏍兼椂锛岄兘浼氬皢缁撴灉鑸嶅叆鍒�15浣嶆湁鏁堟暟瀛椼�傚湪 Java 涓紝闇�瑕佹ā鎷熻繖涓�琛屼负銆�
+
+java
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+
+// 鍒涘缓涓�涓簿搴︿负15浣嶆湁鏁堟暟瀛楋紝鑸嶅叆妯″紡涓衡�滃洓鑸嶄簲鍏モ�濈殑涓婁笅鏂�
+MathContext EXCEL_MC = new MathContext(15, RoundingMode.HALF_UP);
+
+public BigDecimal excelRound(BigDecimal value) {
+ // 搴旂敤Excel鐨勮垗鍏ヨ鍒�
+ return value.round(EXCEL_MC);
+}
+
+// 绀轰緥锛氬湪姣忎竴姝ュ叧閿绠楀悗璋冪敤
+BigDecimal intermediate = a.add(b);
+intermediate = excelRound(intermediate); // 妯℃嫙鍐欏叆鍗曞厓鏍�
+BigDecimal result = intermediate.multiply(c);
+result = excelRound(result);
+娉ㄦ剰锛欵xcel 鐨勮垗鍏ユā寮忚繎浼间簬鈥滃洓鑸嶄簲鍏モ�濓紝浣嗗瓨鍦ㄥ井濡欑殑鈥滈摱琛屽鑸嶅叆鈥濊涓恒�俁oundingMode.HALF_UP 鏄渶鎺ヨ繎鐨勮繎浼硷紝浣嗗湪鏋佸皯鏁拌竟缂樻儏鍐典笅浠嶆湁宸紓銆傚浜庤储鍔$郴缁燂紝寤鸿鍦ㄦ渶缁堢粨鏋滀笂鎸変笟鍔¤鍒欙紙濡� RoundingMode.HALF_UP锛夋樉寮忎繚鐣欏浐瀹氬皬鏁颁綅銆�
+
+3. 姣旇緝杩愮畻闇�鐗规畩澶勭悊
+
+Excel 鍦ㄦ瘮杈冩暟瀛楁椂鏈夐澶栫殑瀹瑰樊閫昏緫銆備緥濡傦紝(0.06-0.01)=0.05 鍦� Excel 涓负 TRUE锛屼絾鍦� Java 鐨� double 姣旇緝涓负 false銆侫pache POI 鎻愪緵浜� NumberComparer.compare(double a, double b) 鏉ユā鎷熻繖绉嶆瘮杈冭涓恒�傚浜� BigDecimal锛屽缓璁厛鎸�15浣嶆湁鏁堟暟瀛楄垗鍏ュ悗鍐嶇敤 compareTo() 姣旇緝銆�
+
+馃拵 鎬荤粨
+瑕佸湪 Java 鐢熸�佷腑澶嶇幇 Excel 鐨勮绠楃粨鏋滐紝鍏抽敭鍦ㄤ簬锛�
+
+鍏ュ彛绮剧‘锛氱敤 NumberToTextConverter + BigDecimal 璇诲彇锛岄伩鍏� double 姹℃煋銆�
+
+瀛樺偍绮剧‘锛氭暟鎹簱浣跨敤 DECIMAL 绫诲瀷锛孞ava 瀛楁浣跨敤 BigDecimal銆�
+
+璁$畻绮剧‘锛氬叏绋嬩娇鐢� BigDecimal 杩愮畻銆�
+
+鑸嶅叆瀵归綈锛氬湪姣忔鍙兘浜х敓涓棿缁撴灉鐨勫湴鏂癸紝鐢� MathContext(15, RoundingMode.HALF_UP) 妯℃嫙 Excel 鐨�15浣嶆湁鏁堟暟瀛楁埅鏂��
+
+姣旇緝瀹瑰樊锛氫娇鐢ㄦā鎷� Excel 琛屼负鐨勬瘮杈冮�昏緫锛岃�岄潪鐩存帴姣旇緝銆�
+
+杩欐牱灏辫兘鏈�澶х▼搴﹀湴淇濊瘉 Java 涓� Excel 鐨勮绠楃粨鏋滀竴鑷淬��
diff --git a/docs/database.md b/docs/database.md
index 2282345..042b269 100644
--- a/docs/database.md
+++ b/docs/database.md
@@ -487,3 +487,68 @@
| /api/report/export/investFiveYearLogistics | "鍗佷簲浜�"鐗╂祦椤圭洰杩涘睍 | 鍥哄畾鍚嶅崟杩囨护锛屽競宸炲皬鑺�+椤圭洰琛� |
| /api/report/export/investBillion | 浜垮厓鎶曡祫椤圭洰 | 鎶曡祫鎬昏〃 + 瑙勪笂椤圭洰琛ㄤ袱sheet锛屼嚎鍏冨悕鍗曡繃婊� |
| /api/report/export/investCounty | 缁忔祹寮哄幙鎶曡祫缁熻 | 缁忔祹寮哄幙鍚嶅崟杩囨护 |
+
+
+## 涔濄�佷笓椤圭粺璁�-閲嶈鑺傚亣鏃ユā鍧�
+
+> 鏁版嵁鏉ユ簮涓庢ā鏉匡細`docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鏁版嵁.xlsx`锛涘揩鎶ユā鏉匡細`docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ユā鏉�.docx`銆傞娆″惎鍔ㄤ細浠庡伐浣滅翱鍒濆鍖� 2024/2025 骞村崄涓�瀹㈡祦銆佽溅娴佹暟鎹��2024-2026 骞撮搧璺�/姘戣埅鍓嶄竴鏃ユ暟鎹��2019 骞撮亾璺暟鎹紝骞跺垵濮嬪寲 2024-2026 骞村杩愮郴鏁般��
+
+### holiday_calendar 鑺傚亣鏃ユ棩鏈熼厤缃�
+| 瀛楁 | 绫诲瀷 | 璇存槑 |
+|------|------|------|
+| id | bigint | PK |
+| year | int | 骞翠唤 |
+| holiday_type | varchar(32) | 鏄ヨ妭 SPRING_FESTIVAL / 浜斾竴 LABOR_DAY / 鍗佷竴 NATIONAL_DAY |
+| holiday_date | date | 鑺傚亣鏃ュ叿浣撴棩鏈� |
+| day_index | int | 0=鍓嶄竴鏃ワ紝1..N=鍋囨湡绗� N 鏃� |
+| created_at / updated_at | datetime | 鍒涘缓/鏇存柊鏃堕棿 |
+
+鍞竴閿細`(year, holiday_type, day_index)`銆乣(year, holiday_type, holiday_date)`銆備粎 admin 鍙�氳繃绯荤粺閰嶇疆鑿滃崟缁存姢锛沗day_index=0` 鐢ㄤ簬閾佽矾銆佹皯鑸殑鍓嶄竴鏃ョ幆姣斿熀鏁般��
+
+### holiday_passenger_flow 鑺傚亣鏃ョ患鍚堣繍杈撹惀涓氭�у娴�
+| 瀛楁 | 绫诲瀷 | 璇存槑 |
+|------|------|------|
+| id | bigint | PK |
+| year | int | 骞翠唤 |
+| flow_date | date | 鏃ユ湡 |
+| transport_mode | varchar(32) | 閾佽矾 RAIL / 姘戣埅 CIVIL_AVIATION / 閬撹矾 ROAD / 姘磋矾 WATER |
+| passenger_count | decimal(30,15) | 鍙戦�佹梾瀹㈡暟锛堜竾浜烘锛� |
+| created_at / updated_at | datetime | 鍒涘缓/鏇存柊鏃堕棿 |
+
+鍞竴閿細`(year, flow_date, transport_mode)`銆�
+
+### holiday_vehicle_flow 鑺傚亣鏃ョ患鍚堣繍杈撻潪钀ヤ笟鎬ц溅娴�
+| 瀛楁 | 绫诲瀷 | 璇存槑 |
+|------|------|------|
+| id | bigint | PK |
+| year | int | 骞翠唤 |
+| flow_date | date | 鏃ユ湡 |
+| road_category | varchar(32) | 楂橀�熷叕璺� EXPRESSWAY / 鏅�氬浗鐪侀亾 NATIONAL_PROVINCIAL |
+| vehicle_type | varchar(32) | 灏忓杞� SMALL_PASSENGER / 涓皬瀹㈣溅 MEDIUM_SMALL_PASSENGER |
+ | vehicle_count | decimal(30,15) | 杞︽祦閲忥紙涓囪締娆★級 |
+ | comparison_base | decimal(30,15) | 鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼汉宸ュ鐢ㄥ�硷紝浠� 2026 骞村惎鐢ㄣ�佷笉鍙備笌璁$畻锛� |
+ | created_at / updated_at | datetime | 鍒涘缓/鏇存柊鏃堕棿 |
+
+鍞竴閿細`(year, flow_date, road_category, vehicle_type)`銆�
+
+### holiday_passenger_coefficient 瀹㈣繍绯绘暟閰嶇疆
+| 瀛楁 | 绫诲瀷 | 璇存槑 |
+|------|------|------|
+| id | bigint | PK |
+| year | int | 骞翠唤 |
+| date_category | varchar(32) | 鑺傚亣鏃� HOLIDAY / 闈炶妭鍋囨棩 NON_HOLIDAY |
+| coefficient | decimal(30,15) | 瀹㈣繍绯绘暟 |
+| created_at / updated_at | datetime | 鍒涘缓/鏇存柊鏃堕棿 |
+
+鍞竴閿細`(year, date_category)`銆備粎 admin 鍙�氳繃绯荤粺閰嶇疆鑿滃崟缁存姢銆�
+
+### 璁$畻涓庡鍏ュ鍑哄彛寰�
+ - 闈炶惀涓氭�т汉鍛樻祦鍔ㄩ噺 =锛堥珮閫熻溅娴� + 鏅�氬浗鐪侀亾杞︽祦锛壝� 褰撳勾鑺傚亣鏃ュ杩愮郴鏁般��
+ - 鏅�氬浗鐪侀亾鐜瘮鍥哄畾鎸夆�滃綋鏃� / 鍓嶄竴鏃� - 1鈥濊绠楋紱2024/2025/2026 鍚勫勾涓�鑷淬�俙comparison_base`锛圗xcel 鏅�氬浗鐪侀亾琛� C 鍒椻�滅幆姣斿熀鏈熲�濓級鑷� 2026-10-05 璧蜂粎浣滀汉宸ュ鐢細瀵煎叆瀵煎嚭鍘熸牱淇濆瓨/鍥炲啓锛屼笉鍙備笌鐜瘮銆佹姌绠楁垨绱锛涘鍑烘椂 C 鍒楃収甯稿啓鍑猴紝E 鍒椻�滅幆姣斺�濆叕寮忓缁堝紩鐢ㄤ笂涓�琛屽悓骞� D 鍒椼��
+ - 鍏矾浜哄憳娴佸姩閲� = 閬撹矾钀ヤ笟鎬у杩愰噺 + 闈炶惀涓氭�т汉鍛樻祦鍔ㄩ噺銆�
+- 鎬讳汉鍛樻祦鍔ㄩ噺 = 鍏矾 + 閾佽矾 + 姘戣埅 + 姘磋矾銆�
+- 瀵煎叆鎸夆�滃勾浠� + 鑺傚亣鏃ュ垎绫烩�濊鍙� `holiday_calendar` 涓� `day_index>=1` 鐨勬棩鏈燂紝灏嗗伐浣滅翱鈥滅N鏃モ�濊瑕嗙洊鍐欏叆褰撳勾瀹㈡祦/杞︽祦锛涘悓鏃惰鍙栧綋鍓嶅勾浠介搧璺�佹皯鑸殑鈥滃墠涓�鏃モ�濊锛屽啓鍏� `day_index=0` 瀵瑰簲鐨勬棩鏈熴��
+- 鏃ュ父瀵煎叆鍙鍙栨墍閫夊勾浠界殑褰撳勾鍒楋紙閬撹矾琛ㄧ殑 2019/2024/2025 鍒楀潎涓嶈鍙栵級锛�2019 骞撮亾璺暟鎹拰 2024/2025/2026 骞村墠涓�鏃ラ搧璺�佹皯鑸暟鎹敱鍚姩鍒濆鍖栫洿鎺ュ啓鍏ユ暟鎹簱銆�2019 骞� 10 鏈� 1-8 鏃ョ殑 `holiday_calendar` 閰嶇疆鐢ㄤ簬鍦ㄧ郴缁熼厤缃拰鑺傚亣鏃ョ粺璁¢〉鏌ョ湅璇ュ巻鍙插熀绾裤��
+- 瀵煎叆鍓嶆鏌ユ瘮鏈鏈�鏂板鍏ユ棩鏇存棭鐨勫巻鍙叉棩鏈熸槸鍚﹀彂鐢熷彉鍖栵紱濡傛湁鍙樺寲鍏堜笉鍐欏簱锛岃繑鍥炲樊寮傛竻鍗曪紝鐢ㄦ埛纭鍚庢墠鍏佽瑕嗙洊銆�
+- 璁板綍鈥滃墠涓�鏃モ�濈殑閾佽矾銆佹皯鑸暟鎹悗锛屽亣鏈熺 1 鏃ョ殑閾佽矾銆佹皯鑸幆姣旀寜璇ユ暟鎹绠楋紱鍏矾銆佹按璺�佹�讳汉鍛樻祦鍔ㄩ噺鍦ㄧ 1 鏃ヤ粛涓嶈绠楃幆姣斻��
+- 瀵煎嚭浠ユ簮宸ヤ綔绨夸负搴曠锛屽洖濉簱鍐呮暟鎹笌瀹㈣繍绯绘暟锛涘揩鎶ユ寜鏃ユ湡鐢熸垚 Word锛屽悓姣斿浐瀹氫娇鐢� 2025銆�2024 涓ゅ勾锛屼笉浣跨敤 2019 鏁版嵁銆�
diff --git a/docs/init.sql b/docs/init.sql
index 7edbe73..7d79383 100644
--- a/docs/init.sql
+++ b/docs/init.sql
@@ -619,3 +619,84 @@
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_type_period (report_type, report_period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鎶ヨ〃鏈熷鏍搁�氳繃鏍囪';
+
+
+-- ============================================
+-- 鍗佷竴銆佷笓椤圭粺璁�-閲嶈鑺傚亣鏃�
+-- ============================================
+
+-- 鑺傚亣鏃ユ棩鏈熼厤缃�
+DROP TABLE IF EXISTS holiday_calendar;
+CREATE TABLE holiday_calendar (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ holiday_type VARCHAR(32) NOT NULL COMMENT '鑺傚亣鏃ュ垎绫伙細SPRING_FESTIVAL/LABOR_DAY/NATIONAL_DAY',
+ holiday_date DATE NOT NULL COMMENT '鑺傚亣鏃ュ叿浣撴棩鏈�',
+ day_index INT NOT NULL COMMENT '0=鍓嶄竴鏃ワ紝1..N=鍋囨湡绗琋鏃�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
+ UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
+ KEY idx_holiday_calendar_date (holiday_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='閲嶈鑺傚亣鏃ユ棩鏈熼厤缃�';
+
+-- 鑺傚亣鏃ョ患鍚堣繍杈撹惀涓氭�у娴�
+DROP TABLE IF EXISTS holiday_passenger_flow;
+CREATE TABLE holiday_passenger_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ flow_date DATE NOT NULL COMMENT '鏃ユ湡',
+ transport_mode VARCHAR(32) NOT NULL COMMENT '杩愯緭鏂瑰紡锛歊AIL/CIVIL_AVIATION/ROAD/WATER',
+ passenger_count DECIMAL(30,15) NOT NULL COMMENT '鍙戦�佹梾瀹㈡暟锛堜竾浜烘锛�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
+ KEY idx_holiday_passenger_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鑺傚亣鏃ョ患鍚堣繍杈撹惀涓氭�у娴佹儏鍐�';
+
+-- 鑺傚亣鏃ョ患鍚堣繍杈撻潪钀ヤ笟鎬ц溅娴�
+DROP TABLE IF EXISTS holiday_vehicle_flow;
+CREATE TABLE holiday_vehicle_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ flow_date DATE NOT NULL COMMENT '鏃ユ湡',
+ road_category VARCHAR(32) NOT NULL COMMENT '閬撹矾鍒嗙被锛欵XPRESSWAY/NATIONAL_PROVINCIAL',
+ vehicle_type VARCHAR(32) NOT NULL COMMENT '杞﹀瀷鍒嗙被锛歋MALL_PASSENGER/MEDIUM_SMALL_PASSENGER',
+ vehicle_count DECIMAL(30,15) NOT NULL COMMENT '杞︽祦閲忥紙涓囪締娆★級',
+ comparison_base DECIMAL(30,15) NULL COMMENT '鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼粎褰撳勾浣跨敤锛�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
+ KEY idx_holiday_vehicle_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鑺傚亣鏃ョ患鍚堣繍杈撻潪钀ヤ笟鎬ц溅娴佹儏鍐�';
+
+-- 寰�骞村浗鐪侀亾杞︽祦閲忚皟鏁�
+DROP TABLE IF EXISTS holiday_vehicle_adjustment;
+CREATE TABLE holiday_vehicle_adjustment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ flow_date DATE NOT NULL COMMENT '鏃ユ湡',
+ adjusted_value DECIMAL(30,15) NOT NULL COMMENT '璋冩暣鍚庢櫘閫氬浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級',
+ reason VARCHAR(500) NOT NULL COMMENT '璋冩暣鍘熷洜',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
+ KEY idx_holiday_vehicle_adjustment_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='寰�骞村浗鐪侀亾杞︽祦閲忚皟鏁�';
+
+-- 瀹㈣繍绯绘暟閰嶇疆
+DROP TABLE IF EXISTS holiday_passenger_coefficient;
+CREATE TABLE holiday_passenger_coefficient (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT '骞翠唤',
+ date_category VARCHAR(32) NOT NULL COMMENT '鏃ユ湡鍒嗙被锛欻OLIDAY/NON_HOLIDAY',
+ coefficient DECIMAL(30,15) NOT NULL COMMENT '瀹㈣繍绯绘暟',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_coefficient (year, date_category)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='瀹㈣繍绯绘暟閰嶇疆';
diff --git a/docs/sql_holiday.sql b/docs/sql_holiday.sql
new file mode 100644
index 0000000..da2ccc1
--- /dev/null
+++ b/docs/sql_holiday.sql
@@ -0,0 +1,66 @@
+-- Holiday statistics module. Run against the traffic_audit database.
+CREATE TABLE IF NOT EXISTS holiday_calendar (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ holiday_type VARCHAR(32) NOT NULL,
+ holiday_date DATE NOT NULL,
+ day_index INT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_calendar_seq (year, holiday_type, day_index),
+ UNIQUE KEY uk_holiday_calendar_date (year, holiday_type, holiday_date),
+ KEY idx_holiday_calendar_date (holiday_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_passenger_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ flow_date DATE NOT NULL,
+ transport_mode VARCHAR(32) NOT NULL,
+ passenger_count DECIMAL(30,15) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_passenger (year, flow_date, transport_mode),
+ KEY idx_holiday_passenger_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_vehicle_flow (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ flow_date DATE NOT NULL,
+ road_category VARCHAR(32) NOT NULL,
+ vehicle_type VARCHAR(32) NOT NULL,
+ vehicle_count DECIMAL(30,15) NOT NULL,
+ comparison_base DECIMAL(30,15) NULL COMMENT '鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼粎褰撳勾浣跨敤锛�',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle (year, flow_date, road_category, vehicle_type),
+ KEY idx_holiday_vehicle_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ flow_date DATE NOT NULL,
+ adjusted_value DECIMAL(30,15) NOT NULL,
+ reason VARCHAR(500) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
+ KEY idx_holiday_vehicle_adjustment_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS holiday_passenger_coefficient (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL,
+ date_category VARCHAR(32) NOT NULL,
+ coefficient DECIMAL(30,15) NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_coefficient (year, date_category)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/docs/sql_holiday_comparison_base_migration.sql b/docs/sql_holiday_comparison_base_migration.sql
new file mode 100644
index 0000000..6c73353
--- /dev/null
+++ b/docs/sql_holiday_comparison_base_migration.sql
@@ -0,0 +1,6 @@
+-- Add the optional ordinary national/provincial road comparison base.
+-- The field is used only for the 2026 holiday data.
+ALTER TABLE holiday_vehicle_flow
+ ADD COLUMN comparison_base DECIMAL(30,15) NULL
+ COMMENT '鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛堜竾杈嗘锛屼粎褰撳勾浣跨敤锛�'
+ AFTER vehicle_count;
diff --git a/docs/sql_holiday_decimal_migration.sql b/docs/sql_holiday_decimal_migration.sql
new file mode 100644
index 0000000..6235c17
--- /dev/null
+++ b/docs/sql_holiday_decimal_migration.sql
@@ -0,0 +1,16 @@
+-- Convert holiday statistics numeric columns from binary floating point to exact decimal storage.
+-- Run once against the traffic_audit database.
+ALTER TABLE holiday_passenger_flow
+ MODIFY COLUMN passenger_count DECIMAL(30,15) NOT NULL COMMENT '鍙戦�佹梾瀹㈡暟锛堜竾浜烘锛�';
+
+ALTER TABLE holiday_vehicle_flow
+ MODIFY COLUMN vehicle_count DECIMAL(30,15) NOT NULL COMMENT '杞︽祦閲忥紙涓囪締娆★級';
+
+ALTER TABLE holiday_passenger_coefficient
+ MODIFY COLUMN coefficient DECIMAL(30,15) NOT NULL COMMENT '瀹㈣繍绯绘暟';
+
+-- The seeded coefficient was averaged from repeated 2.4 cells through a Java double.
+-- Normalize the migrated value to the exact Excel decimal.
+UPDATE holiday_passenger_coefficient
+SET coefficient = 2.4
+WHERE year IN (2024, 2025, 2026);
diff --git a/docs/sql_holiday_previous_day_migration.sql b/docs/sql_holiday_previous_day_migration.sql
new file mode 100644
index 0000000..3fcb16c
--- /dev/null
+++ b/docs/sql_holiday_previous_day_migration.sql
@@ -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());
diff --git a/docs/sql_holiday_vehicle_adjustment_migration.sql b/docs/sql_holiday_vehicle_adjustment_migration.sql
new file mode 100644
index 0000000..3c98eb6
--- /dev/null
+++ b/docs/sql_holiday_vehicle_adjustment_migration.sql
@@ -0,0 +1,14 @@
+-- Add the historical national/provincial road adjustment table.
+-- Run against the traffic_audit database on upgrade.
+CREATE TABLE IF NOT EXISTS holiday_vehicle_adjustment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ year INT NOT NULL COMMENT 'year',
+ flow_date DATE NOT NULL COMMENT 'holiday date',
+ adjusted_value DECIMAL(30,15) NOT NULL COMMENT 'adjusted national/provincial vehicle flow (10k vehicle trips)',
+ reason VARCHAR(500) NOT NULL COMMENT 'adjustment reason',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_holiday_vehicle_adjustment (year, flow_date),
+ KEY idx_holiday_vehicle_adjustment_date (flow_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='historical national/provincial vehicle flow adjustments';
diff --git a/docs/sql_observation_station.sql b/docs/sql_observation_station.sql
new file mode 100644
index 0000000..01c638d
--- /dev/null
+++ b/docs/sql_observation_station.sql
@@ -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;
diff --git "a/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\345\201\207\346\234\237\345\205\250\347\234\201\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\351\207\217\350\265\260\345\212\277\345\233\276.docx" "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\345\201\207\346\234\237\345\205\250\347\234\201\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\351\207\217\350\265\260\345\212\277\345\233\276.docx"
new file mode 100644
index 0000000..d1d3a91
--- /dev/null
+++ "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\345\201\207\346\234\237\345\205\250\347\234\201\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\351\207\217\350\265\260\345\212\277\345\233\276.docx"
Binary files differ
diff --git "a/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\346\225\260\346\215\256.xlsx" "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\346\225\260\346\215\256.xlsx"
new file mode 100644
index 0000000..4b57043
--- /dev/null
+++ "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/2026\345\271\264\345\215\201\344\270\200\346\225\260\346\215\256.xlsx"
Binary files differ
diff --git "a/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\345\244\207\344\273\275/2026\345\271\264\345\215\201\344\270\200\346\225\260\346\215\256.xlsx" "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\345\244\207\344\273\275/2026\345\271\264\345\215\201\344\270\200\346\225\260\346\215\256.xlsx"
new file mode 100644
index 0000000..cc214e3
--- /dev/null
+++ "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\345\244\207\344\273\275/2026\345\271\264\345\215\201\344\270\200\346\225\260\346\215\256.xlsx"
Binary files differ
diff --git "a/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\345\244\207\344\273\275/\346\271\226\345\214\227\347\234\201\345\233\275\345\272\206\345\201\207\346\234\237\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\345\277\253\346\212\245\346\250\241\346\235\277.docx" "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\345\244\207\344\273\275/\346\271\226\345\214\227\347\234\201\345\233\275\345\272\206\345\201\207\346\234\237\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\345\277\253\346\212\245\346\250\241\346\235\277.docx"
new file mode 100644
index 0000000..26794b6
--- /dev/null
+++ "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\345\244\207\344\273\275/\346\271\226\345\214\227\347\234\201\345\233\275\345\272\206\345\201\207\346\234\237\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\345\277\253\346\212\245\346\250\241\346\235\277.docx"
Binary files differ
diff --git "a/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\346\271\226\345\214\227\347\234\201\345\233\275\345\272\206\345\201\207\346\234\237\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\345\277\253\346\212\245\346\250\241\346\235\277.docx" "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\346\271\226\345\214\227\347\234\201\345\233\275\345\272\206\345\201\207\346\234\237\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\345\277\253\346\212\245\346\250\241\346\235\277.docx"
new file mode 100644
index 0000000..e988487
--- /dev/null
+++ "b/docs/\344\270\223\351\241\271\347\273\237\350\256\241/\351\207\215\350\246\201\350\212\202\345\201\207\346\227\245/\346\271\226\345\214\227\347\234\201\345\233\275\345\272\206\345\201\207\346\234\237\347\273\274\345\220\210\350\277\220\350\276\223\345\256\242\346\265\201\345\277\253\346\212\245\346\250\241\346\235\277.docx"
Binary files differ
diff --git "a/docs/\346\211\223\345\214\205\351\203\250\347\275\262\350\257\264\346\230\216.md" "b/docs/\346\211\223\345\214\205\351\203\250\347\275\262\350\257\264\346\230\216.md"
index 9535176..bf2e978 100644
--- "a/docs/\346\211\223\345\214\205\351\203\250\347\275\262\350\257\264\346\230\216.md"
+++ "b/docs/\346\211\223\345\214\205\351\203\250\347\275\262\350\257\264\346\230\216.md"
@@ -1,6 +1,6 @@
# 浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� 鈥� 鎵撳寘涓庨儴缃茶鏄�
-> 閫傜敤锛歮ain 鍒嗘敮锛堟渶鏂版洿鏂� 2026-09-13锛夈�傚悗绔� Spring Boot 2.7锛圝ava 8锛夛紝鍓嶇 Vue 2 + Element UI銆�
+> 閫傜敤锛歮ain 鍒嗘敮锛堟渶鏂版洿鏂� 2026-10-05锛夈�傚悗绔� Spring Boot 2.7锛圝ava 8锛夛紝鍓嶇 Vue 2 + Element UI銆�
> 鏈枃妗i潰鍚戝紑鍙戜笌閮ㄧ讲鍚屼簨锛屽懡浠ゅ潎鍦� Windows 涓嬮獙璇併��
## 涓�銆侀」鐩粨鏋�
@@ -34,8 +34,8 @@
1. `traffic-audit-web` 涓� `npm run build` 鈫� `dist`锛�
2. 鎶� `dist` 澶嶅埗杩� `traffic-audit-server/src/main/resources/static`锛屼娇 **jar 鑷甫鍓嶇**锛屽啀 `mvn clean package -DskipTests`锛堣窇瀹岃嚜鍔ㄦ竻绌� static锛屼繚鎸佷粨搴撳共鍑�锛夛紱
-3. 缁勮 `deploy/traffic-audit-deploy-YYYYMMDD/`锛�
-4. 鍘嬬缉涓� `traffic-audit-deploy-YYYYMMDD.zip`锛堢害 70 MB锛夈��
+3. 缁勮 `deploy/traffic-audit-deploy-YYYYMMDD-v1.0.0/`锛�
+4. 鍘嬬缉涓� `traffic-audit-deploy-YYYYMMDD-v1.0.0.zip`锛堢害 70 MB锛夈��
鎵嬪伐绛変环鍛戒护锛堜笉鎯崇敤鑴氭湰鏃讹級锛�
@@ -47,15 +47,51 @@
> 娉ㄦ剰锛氭墜宸ユ柟寮忚窇瀹岃寰楀垹鎺� `traffic-audit-server/src/main/resources/static` 涓嬬殑鍐呭锛屽惁鍒欎細姹℃煋婧愮爜鐩綍銆�
+### 鐗堟湰绠$悊涓庡彂甯冿紙2026-10-05 璧凤級
+
+閲囩敤鏂规 A锛氬悗绔� jar 浣跨敤鍥哄畾鏂囦欢鍚嶏紝鐗堟湰鍙风敱 `pom.xml` 鍗曚竴鏉ユ簮鐢熸垚锛屽彂甯冨寘鍚嶅拰 git tag 甯︾増鏈��
+
+- 鍗曚竴鐗堟湰婧愶細`traffic-audit-server/pom.xml` 鐨� `<version>`锛屼緥濡� `1.0.0`
+- 鏋勫缓浜х墿鍥哄畾涓� `traffic-audit-server/target/traffic-audit-server.jar`
+- 鏋勫缓鏃惰嚜鍔ㄦ妸鐗堟湰鍐欒繘锛�
+ - `BOOT-INF/classes/app-version.properties`
+ - `META-INF/MANIFEST.MF` 鐨� `Implementation-Version`
+- 鍙戝竷鍖呭懡鍚嶏細`traffic-audit-deploy-YYYYMMDD-v1.0.0(.zip)`
+- git tag 鍛藉悕锛歚v1.0.0`
+- 椤甸潰鐗堟湰锛氬悗绔� `GET /api/version`锛岀櫥褰曢〉鍜岄〉澶存樉绀� `V1.0.0`
+- `pack-deploy.ps1` 浼氭牎楠� `pom.xml`銆乯ar 鍐� `app-version.properties`銆乯ar 娓呭崟涓夎�呯増鏈竴鑷达紝涓嶄竴鑷寸洿鎺ヤ腑姝㈡墦鍖�
+
+鍙戞柊鐗堟湰娴佺▼锛�
+
+1. 鍏堟敼 `traffic-audit-server/pom.xml` 鐨� `<version>`锛屼緥濡� `1.0.1`
+2. 杩愯 `pack-deploy.ps1` 鎵撳寘
+3. 鎻愪氦浠g爜骞舵墦 git tag锛歚git tag -a v1.0.1 -m "v1.0.1"`
+4. 鏈嶅姟鍣ㄥ仠姝㈡湇鍔★紝鏇挎崲 `traffic-audit-server.jar`锛屽啀鍚姩鏈嶅姟
+
+鏃ф湇鍔″櫒棣栨鍗囩骇锛堝彧闇�鍋氫竴娆★級锛氬凡鏈� `trafficAudit.xml` 閫氬父浠嶅啓姝绘棫 jar 鍚� `traffic-audit-server-1.0.0-SNAPSHOT.jar`锛屾妸 `<arguments>` 鏀规垚鍥哄畾鏂囦欢鍚嶏細
+
+```xml
+<arguments>-Dfile.encoding=UTF-8 -jar "%BASE%\traffic-audit-server.jar"</arguments>
+```
+
+浠ュ悗姣忔鍙戝竷鍙浛鎹� jar 骞堕噸鍚湇鍔★紝涓嶅啀淇敼 `trafficAudit.xml`銆�
+
## 鍥涖�侀儴缃插寘鍐呭
```
-traffic-audit-deploy-YYYYMMDD/
+traffic-audit-deploy-YYYYMMDD-v1.0.0/
鈹溾攢 backend/
-鈹� 鈹溾攢 traffic-audit-server-1.0.0-SNAPSHOT.jar 鍚庣 + 鍐呭祵鍓嶇锛堢害 55 MB锛�
+鈹� 鈹溾攢 traffic-audit-server.jar 鍚庣 + 鍐呭祵鍓嶇锛堢害 55 MB锛屽浐瀹氭枃浠跺悕锛�
鈹� 鈹溾攢 docs/ 妯℃澘涓庤繍琛屾暟鎹紙蹇呴渶锛屽嬁鍒狅級
鈹� 鈹溾攢 application.yml 澶栭儴閰嶇疆锛堟敼杩欓噷鍗冲彲锛屾棤闇�閲嶆墦鍖咃級
-鈹� 鈹斺攢 start-backend.cmd / start-backend.sh 鍚姩鑴氭湰
+鈹� 鈹溾攢 start-backend.cmd / start-backend.sh 鍚姩鑴氭湰锛堜汉宸ュ弻鍑�/鍛戒护琛岋級
+鈹� 鈹溾攢 trafficAudit.exe WinSW 鏈嶅姟鍖呰鍣紙2.12.0 x64锛岃嚜鍖呭惈 .NET 6锛�
+鈹� 鈹溾攢 trafficAudit.xml WinSW 鏈嶅姟閰嶇疆锛堣嚜鍔�/寤惰繜鍚姩銆佸け璐ラ噸鍚�佹棩蹇楁粴鍔級
+鈹� 鈹溾攢 install-service.cmd 鍙屽嚮鍏ュ彛锛氭敞鍐屽紑鏈鸿嚜鍚湇鍔★紙鑷姩璇锋眰绠$悊鍛樻潈闄愶級
+鈹� 鈹溾攢 install-service.ps1 涓婇潰閭d釜 cmd 瀹為檯璋冪敤鐨勮剼鏈紙娉ㄥ唽/鏌ョ湅/閲嶅惎/鍒犻櫎锛�
+鈹� 鈹溾攢 start-backend-service.cmd 澶囬�夛細闈炰氦浜掑惎鍔ㄥ櫒锛堟棤 pause銆佸啓 app.log锛�
+鈹� 鈹溾攢 install-autostart.ps1 澶囬�夛細娉ㄥ唽/鏌ョ湅/鍒犻櫎 Windows 寮�鏈鸿嚜鍚鍒掍换鍔�
+鈹� 鈹斺攢 web/ 鍓嶇闈欐�佹枃浠讹紙鐢� dist 鑷姩鍚屾锛�8090 鍗曠鍙fā寮忓繀闇�锛�
鈹溾攢 frontend/
鈹� 鈹溾攢 dist/ 鍓嶇浜х墿锛堜粎 nginx 鏂规闇�瑕侊級
鈹� 鈹斺攢 nginx.conf.example nginx 閰嶇疆绀轰緥
@@ -63,7 +99,13 @@
鈹� 鈹溾攢 init.sql 寤哄簱寤鸿〃 + 棰勭疆璐﹀彿锛堝繀椤绘墽琛岋級
鈹� 鈹溾攢 sql_city_bus.sql 鍩庡競瀹㈣繍-鍏氦鏈堝害琛紙蹇呴』鎵ц锛�
鈹� 鈹溾攢 sql_city_taxi.sql 鍩庡競瀹㈣繍-鍑虹鏈堝害琛�/鎺堟潈琛紙蹇呴』鎵ц锛�
-鈹� 鈹斺攢 sql_wyc.sql 缃戠害杞﹀叏鐪佹�婚噺/璁㈠崟鍒嗗競宸炶〃锛堝繀椤绘墽琛岋級
+鈹� 鈹溾攢 sql_wyc.sql 缃戠害杞﹀叏鐪佹�婚噺/璁㈠崟鍒嗗競宸炶〃锛堝繀椤绘墽琛岋級
+鈹� 鈹溾攢 sql_holiday.sql 涓撻」缁熻-閲嶈鑺傚亣鏃ヨ〃锛堟棫搴撹ˉ寤烘椂鎵ц锛�
+鈹� 鈹溾攢 sql_holiday_decimal_migration.sql 鑺傚亣鏃ユ暟鍊煎垪绮惧害杩佺Щ锛堟棫搴撳崌绾э級
+鈹� 鈹溾攢 sql_holiday_comparison_base_migration.sql 鑺傚亣鏃ョ幆姣斿熀鏈熷瓧娈佃縼绉伙紙鏃у簱鍗囩骇锛�
+鈹� 鈹溾攢 sql_holiday_previous_day_migration.sql 鑺傚亣鏃ュ墠涓�鏃ラ厤缃縼绉伙紙鏃у簱鍗囩骇锛�
+鈹� 鈹溾攢 sql_holiday_vehicle_adjustment_migration.sql 寰�骞村浗鐪侀亾鏁版嵁璋冩暣琛紙鏃у簱鍗囩骇锛�
+鈹� 鈹斺攢 sql_observation_station.sql 瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹〃锛堟棫搴撹ˉ寤烘椂鎵ц锛�
鈹斺攢 README-閮ㄧ讲璇存槑.txt
```
@@ -77,14 +119,15 @@
```bash
cd db
-mysql --default-character-set=utf8mb4 -u root -p < init.sql # 寤哄簱 + 24 寮犲熀纭�琛� + 棰勭疆璐﹀彿
+mysql --default-character-set=utf8mb4 -u root -p < init.sql # 寤哄簱 + 28 寮犲熀纭�琛� + 棰勭疆璐﹀彿
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_city_bus.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_city_taxi.sql
mysql --default-character-set=utf8mb4 -u root -p -D traffic_audit < sql_wyc.sql
```
> 鈿狅笍 **`init.sql` 鏄牬鍧忔�х殑**锛氶噷闈㈢殑鍩虹琛ㄩ兘甯� `DROP TABLE IF EXISTS`銆�**宸叉湁鏁版嵁鐨勫簱涓婂崈涓囦笉瑕佸啀璺� init.sql**锛屽惁鍒欒〃鏁版嵁浼氳娓呯┖閲嶅缓銆�
-> 4 涓剼鏈叡瑕嗙洊 29 寮犺〃锛坕nit.sql 24 寮� + 鍩庡競瀹㈣繍 3 寮� + 缃戠害杞� 2 寮狅級銆�
+> 鏃╂湡鐗堟湰搴撹嫢娌℃湁鑺傚亣鏃ョ浉鍏宠〃锛屾墽琛� `sql_holiday.sql`锛涜嫢宸叉湁鑺傚亣鏃ヨ〃浣嗙己绮惧害銆佺幆姣斿熀鏈熷瓧娈点�佸墠涓�鏃ラ厤缃�佸線骞村浗鐪侀亾璋冩暣琛ㄦ垨瑙傛祴绔欒〃锛屾寜闇�鎵ц瀵瑰簲鐨勮縼绉昏剼鏈�傝縼绉昏剼鏈潎鍙噸澶嶆墽琛屻��
+> 4 涓剼鏈叡瑕嗙洊 33 寮犺〃锛坕nit.sql 28 寮狅紝鍏朵腑鍚妭鍋囨棩 4 寮� + 鍩庡競瀹㈣繍 3 寮� + 缃戠害杞� 2 寮狅級銆�
#### 璺嚎 B锛氭妸鐜扮綉/寮�鍙戞満鐨勬暟鎹暣濂楁惉杩囧幓锛堟帹鑽愶紝涓嶇敤璺戣剼鏈級
@@ -107,9 +150,9 @@
- `--databases` 浼氭妸 `CREATE DATABASE` + `USE` 涓�璧峰啓杩涘浠斤紝鎵�浠ョ洰鏍囨満鍣�**涓嶇敤鍏堝缓搴撱�佷篃涓嶇敤璺戦偅 4 涓剼鏈�**銆�
- 澶囦唤閲屽寘鍚� `sys_user`锛岃处鍙峰瘑鐮佷笌婧愭満鍣ㄥ畬鍏ㄤ竴鑷达紙鏈満鏄� `admin / 123456`锛夛紝涓嶄細琚噸缃垚 `admin/admin123`銆�
-- 鈿狅笍 澶囦唤閲屾瘡寮犺〃閮藉甫 `DROP TABLE IF EXISTS`锛堝叡 29 寮狅級锛�**鐩爣搴撹鏄凡鏈夋暟鎹紝浼氳鐩存帴瑕嗙洊**銆傝杩樺師鍒扮┖搴擄紝鎴栧厛鑷繁澶囦唤鐩爣搴撱��
+- 鈿狅笍 澶囦唤閲屾瘡寮犺〃閮藉甫 `DROP TABLE IF EXISTS`锛堜互瀹為檯澶囦唤涓哄噯锛屾渶鏂颁负 33 寮狅級锛�**鐩爣搴撹鏄凡鏈夋暟鎹紝浼氳鐩存帴瑕嗙洊**銆傝杩樺師鍒扮┖搴擄紝鎴栧厛鑷繁澶囦唤鐩爣搴撱��
- 鏈宸茬敓鎴愬苟**楠岃瘉杩�**澶囦唤锛歚traffic_audit_full_20260913.sql`锛�45.5 MB锛�8.0.29 瀹㈡埛绔鍑猴級銆�
- 楠岃瘉鏂瑰紡锛氭妸澶囦唤搴撳悕鏀规垚涓存椂搴撳鍏ワ紝閫愯〃姣斿 `SHOW CREATE TABLE`锛堣〃缁撴瀯锛変笌 `COUNT(*)`锛堣鏁帮級鈥斺��**29 寮犺〃缁撴瀯涓庤鏁板潎 0 宸紓**锛屾娊鏌� `wyc_order_monthly` 2026-08 鏁版嵁涓�鑷达紝楠岃瘉鍚庡凡鍒犻櫎涓存椂搴撱��
+ 楠岃瘉鏂瑰紡锛氭妸澶囦唤搴撳悕鏀规垚涓存椂搴撳鍏ワ紝閫愯〃姣斿 `SHOW CREATE TABLE`锛堣〃缁撴瀯锛変笌 `COUNT(*)`锛堣鏁帮級鈥斺��**29 寮犺〃缁撴瀯涓庤鏁板潎 0 宸紓锛堟柊澧炶妭鍋囨棩妯″潡鍚庝负 33 寮狅級**锛屾娊鏌� `wyc_order_monthly` 2026-08 鏁版嵁涓�鑷达紝楠岃瘉鍚庡凡鍒犻櫎涓存椂搴撱��
#### 璺嚎 B2锛氱敤 Navicat 鐨勩�屽浠� / 杩樺師澶囦唤銆嶏紙.nb3锛屾渶璐磋繎鏃ュ父涔犳儻锛�
@@ -149,7 +192,7 @@
```powershell
# Windows锛氬弻鍑� start-backend.cmd锛涙垨
-cd backend ; java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
+cd backend ; java -jar traffic-audit-server.jar
```
```bash
@@ -159,7 +202,43 @@
鎴愬姛鏍囧織锛氭棩蹇楀嚭鐜� `Started TrafficAuditApplication` 涓� `Tomcat started on port(s): 8090`銆�
-### 5.4 璁块棶
+### 5.4 Windows 寮�鏈鸿嚜鍚紙鎺ㄨ崘锛氭敞鍐屾垚 Windows 鏈嶅姟锛�
+
+鍦� `backend` 鐩綍閲�**鍙抽敭 `install-service.cmd` 鈫掋�屼互绠$悊鍛樿韩浠借繍琛屻��**鍗冲彲锛堢洿鎺ュ弻鍑讳篃琛岋紝瀹冧細鑷繁寮� UAC锛夈�傚畠瀹為檯璋冪敤鐨勫氨鏄悓鐩綍鐨� `install-service.ps1`锛岀瓑浠峰懡浠わ細
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
+```
+
+鑴氭湰浼氳嚜鍔ㄥ畾浣� java銆佹敞鍐屾湇鍔°�佸惎鍔紝鐒跺悗鎺㈡祴绔彛鎵撳嵃缁撴灉锛涜捣涓嶆潵鏃剁洿鎺ユ墦鍗� `logs\*.log` 鐨勬渶鍚� 15 琛屻��
+
+| 椤� | 鍊� |
+|----|----|
+| 鏈嶅姟鍚� | `TrafficAuditBackend`锛坰ervices.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`锛圝AVA_HOME 鈫� PATH 鈫� JavaSoft 娉ㄥ唽琛� 鈫� Program Files锛夊苟鎶�**缁濆璺緞**鍐欒繘 `trafficAudit.xml`锛圫YSTEM 鏈嶅姟鐨� PATH 涓庣櫥褰曠敤鎴蜂笉鍚岋紝鍐欑粷瀵硅矾寰勬洿绋筹級銆傞噸澶嶆墽琛屾槸骞傜瓑鐨勶細鍏堝仠鏈嶅姟鍐嶉噸鏂版敞鍐屽苟鍚姩銆�
+
+> 鍚屼竴鍙版満**涓嶈**鍚屾椂鍚敤璁″垝浠诲姟鏂瑰紡锛坄install-autostart.ps1`锛夛紝涓や釜瀹夸富浼氭姠 8090 绔彛銆�
+
+#### 鎿嶄綔绯荤粺鍏煎鎬�
+
+鏈嶅姟鏈韩锛堣嚜鍔ㄥ惎鍔� / 寤惰繜鍚姩 / 澶辫触閲嶅惎锛変粠 Win7 SP1 鍒� Win11銆丼erver 2008 R2 鍒� Server 2022/2025 鏈哄埗涓�鑷达紝宸埆鍙湪鍖呰鍣ㄤ緷璧栫殑杩愯鏃讹細
+
+| 鐩爣绯荤粺 | 鐢ㄥ摢涓寘瑁呭櫒 | 棰濆渚濊禆 |
+|----------|--------------|----------|
+| Win10 / Win11 / Server 2016 鍙婁互涓婏紙鎺ㄨ崘锛� | 鍖呭唴 `trafficAudit.exe`锛圵inSW 2.12.0 x64 鑷寘鍚級 | 鏃狅紙鑷甫 .NET 6 杩愯鏃讹級 |
+| Win7 SP1 / Server 2008 R2 / Server 2012(R2) | 鍏堣瘯鍖呭唴鑷寘鍚増锛圵in7 闇� 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://<閮ㄧ讲鏈篒P>:8090`锛堝墠绔凡鍐呭祵锛岄〉闈笌鎺ュ彛鍚屾簮锛屾棤闇� nginx锛夛紝璐﹀彿 `admin / admin123`銆�
@@ -204,8 +283,13 @@
| 瀵煎嚭鎶ャ�屾湭鎵惧埌姹囨�昏〃姣嶇増銆奩YYY骞碝鏈堚�︺�嬨�� | `docs/鐢熸垚姹囨�诲ぇ琛╜ 缂鸿鏈堟瘝鐗� |
| 鐧诲綍鎺ュ彛 500 | `jwt.secret` 濉簡闈炴硶瀛楃锛堝涓枃锛夛紱鏀规垚 32 浣嶅崄鍏繘鍒朵覆 |
| 椤甸潰鑳藉紑浣嗘帴鍙� 404 | 璧� nginx 鏂规鏃舵病閰� `/api` 鍙嶄唬锛涙敼鐢� jar 鍐呭祵璁块棶 8090 |
-| 鏁版嵁搴撹繛鎺ュけ璐� | 妫�鏌� `application.yml`锛涚‘璁� 4 涓� SQL 鑴氭湰宸叉墽琛� |
+| 鏁版嵁搴撹繛鎺ュけ璐� | 妫�鏌� `application.yml`锛涚‘璁ゅ垵濮嬪寲鑴氭湰鍜屽搴斿崌绾ц縼绉昏剼鏈凡鎵ц |
+| 鍚姩鎶� `Access denied ... (using password: NO)` | 鍚庣鎷垮埌鐨勫簱鍙d护鏄�**绌轰覆**锛歫ar 鍐呯疆 `${TRAFFIC_DB_PASSWORD:}` 鏈澶栫疆閰嶇疆瑕嗙洊銆傛妸 `backend/application.yml` 鏀惧湪 jar 鍚岀骇骞跺~ `spring.datasource.password`锛屾垨璁剧幆澧冨彉閲� `TRAFFIC_DB_PASSWORD`锛涙敼瀹岄噸鍚紝鏃犻渶閲嶆墦鍖� |
| 绔彛鍗犵敤 | 鏀� `server.port`锛涘墠绔紑鍙戠鍙� 8080 |
+| 鏈嶅姟宸叉敞鍐屼絾 8090 鎵撲笉寮� | 鐪� `backend\logs\trafficAudit.err.log`锛涙湇鍔℃柟寮忚鐨勬槸 `backend\application.yml`锛宍password` 蹇呴』鏄湡瀹炲彛浠� |
+| 鏈嶅姟鎶モ�滄嫆缁濊闂��/鍐欎笉浜嗘棩蹇� | 閮ㄧ讲鐩綍鏉冮檺涓嶈冻锛氭崲鍒� `D:\trafficAudit` 杩欑被绠�鍗曡矾寰勶紝鎴栫粰 LocalSystem 鎺堝啓鏉冮檺 |
+| 涓嶆兂寮�鏈鸿嚜鍚絾淇濈暀鏈嶅姟 | `Set-Service TrafficAuditBackend -StartupType Manual` |
+| Win7/2008R2 涓婃湇鍔″惎涓嶄簡锛堟姤 `api-ms-win-*.dll`锛� | 瑙� 5.4銆屾搷浣滅郴缁熷吋瀹规�с�嶏細鎶� `WinSW.NET461.exe` 鏀瑰悕鎴� `trafficAudit.exe` 鏇挎崲 |
## 鍗併�佸紑鍙戠幆澧冩棩甯稿惎鍔�
@@ -217,6 +301,8 @@
## 鍗佷竴銆佸彉鏇磋褰�
+- **2026-10-05**锛氱増鏈鐞嗛噰鐢ㄦ柟妗� A銆俙pom.xml` 涓哄敮涓�鐗堟湰婧愶紝jar 鍥哄畾鍚嶄负 `traffic-audit-server.jar`锛屾瀯寤鸿嚜鍔ㄦ敞鍏� `app-version.properties` 涓� `MANIFEST.MF`锛涘彂甯冨寘鍚嶅甫鐗堟湰锛屾柊澧� `/api/version` 椤甸潰鏄剧ず锛岄娆″崌绾ф棫鏈嶅姟鍣ㄩ渶涓�娆℃�т慨鏀� `trafficAudit.xml`锛屼互鍚庡彧鏇挎崲 jar 骞堕噸鍚��
+- **2026-10-03**锛歚pack-deploy.ps1` 琛ラ綈澶栫疆 `application.yml`/`docs`/`db`/鍚姩鑴氭湰骞跺悓姝� `backend\web`锛堜慨 `Access denied ... (using password: NO)`锛夛紱妯℃澘绔彛 8080鈫�8090锛涙柊澧� Windows 鏈嶅姟鏂瑰紡寮�鏈鸿嚜鍚紙WinSW 2.12.0 `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`锛岃嚜鍔�/寤惰繜鍚姩銆佸け璐ラ噸鍚�佹棩蹇楁粴鍔級锛岃鍒掍换鍔℃柟寮忛檷涓哄閫夛紱琛ユ搷浣滅郴缁熷吋瀹规�ц鏄庛��
- **2026-09-13**锛氭柊澧炰竴閿墦鍖呰剼鏈� `package-deploy.py`锛沯ar 鏀逛负鍐呭祵鍓嶇锛堥儴缃叉満涓嶅啀闇�瑕� nginx锛夛紱鍒犻櫎 Redis 鐩稿叧璇存槑锛堜唬鐮佹湭浣跨敤锛夛紱琛� `sql_wyc.sql` 涓� `summary.template-dir`锛涜ˉ `jwt.secret` 鍙栧�艰鍒欙紱琛ラ儴缃插悗楠岃瘉娓呭崟銆�
- 2026-08-27锛氶鐗堬紙zip 閮ㄧ讲鍖� + nginx 鏂规锛夈��
@@ -252,7 +338,7 @@
# 1) 纭杩� jar 鐨勯厤缃病鏈夌湡瀹炲瘑閽ワ紙搴斿彧鐪嬪埌 ${...}锛�
Select-String -Path traffic-audit-server\src\main\resources\application.yml -Pattern "password:|api-key:|secret:"
# 2) 鎵撳畬鍖呭悗鍐嶉獙璇佷竴娆� jar 鍐呴儴
-python -c "import zipfile;print(zipfile.ZipFile('traffic-audit-server/target/traffic-audit-server-1.0.0-SNAPSHOT.jar').read('BOOT-INF/classes/application.yml').decode('utf-8'))" | Select-String "password:|api-key:"
+python -c "import zipfile;print(zipfile.ZipFile('traffic-audit-server/target/traffic-audit-server.jar').read('BOOT-INF/classes/application.yml').decode('utf-8'))" | Select-String "password:|api-key:"
```
@@ -264,12 +350,20 @@
```
鑴氭湰鍋氬洓浠朵簨锛�
-1. `mvn clean package -DskipTests` 鏋勫缓鍚庣 jar锛�
+1. 鐢� `deploy-templates/jar-application.yml`锛堝叏鍗犱綅绗︼級涓存椂鏇挎崲婧愮爜 `application.yml`锛屽啀 `mvn clean package -DskipTests`锛屾瀯寤哄悗鑷姩杩樺師寮�鍙戞満鐪熷疄閰嶇疆锛�
2. **瑙e帇 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 鍗曠鍙fā寮忥級锛屼互鍙� Windows 鏈嶅姟涓変欢濂� `trafficAudit.exe` + `trafficAudit.xml` + `install-service.ps1`锛岃緭鍑哄埌 `<OutRoot>\traffic-audit-deploy-<yyyyMMdd>-v<version>`锛�
+ WinSW 浼樺厛鐢� `deploy-templates\tools\winsw.exe` 缂撳瓨锛堟牎楠� SHA-256锛夛紝缂撳瓨缂哄け鎴栧搱甯屼笉绗︽椂鑷姩浠� GitHub 瀹樻柟鍙戝竷椤典笅杞� v2.12.0 x64锛堣浜х墿鏈仛 Authenticode 绛惧悕锛屽洜姝や互鍥哄畾 SHA-256 浣滃畬鏁存�ф牎楠岋級锛�
+5. 瀵圭粍瑁呭悗鐨勬暣鍖呭啀鎵竴閬嶆槑鏂囧瘑閽ャ��
-妯℃澘浠剁粺涓�鏀惧湪 **`deploy-templates/`**锛堝凡鍏ュ簱锛夛細澶栭儴 `application.yml`銆乣start-backend.cmd/.sh`銆乣nginx.conf.example`銆乣db\{init.sql,sql_city_bus,sql_city_taxi,sql_wyc}.sql`銆乣README-閮ㄧ讲璇存槑.txt`銆傛敼妯℃澘鍗虫敼鎵撳寘浜х墿銆�
+鍙�夊弬鏁帮細`-OutRoot` 鎸囧畾杈撳嚭鐩綍锛堥粯璁や粨搴� `deploy\`锛夈�乣-SkipFrontend` 澶嶇敤鐜版湁 dist銆乣-SkipBackend` 澶嶇敤鐜版湁 jar锛堝彧閲嶇粍鍖呫�佷笉閲嶇紪璇戯紝鐢ㄤ簬蹇�熻ˉ鍖咃級銆乣-SkipServiceWrapper` 涓嶆妸 WinSW 鏈嶅姟鍖呰鍣ㄦ墦杩涘寘锛堝嵆涓嶈鈥滄敞鍐屾垚 Windows 鏈嶅姟鈥濈殑寮�鏈鸿嚜鍚級銆�
+
+> 璁板綍锛�2026-10-03锛夛細姝ゅ墠鎵嬪伐浜у嚭鐨� 10-02 鍖呭彧鏈� jar+web+dist锛岀己 `application.yml`/`docs`/`db`/鍚姩鑴氭湰锛岄儴缃叉満鍚姩鎶� `Access denied ... (using password: NO)`銆傜幇宸茬敱鑴氭湰鑷姩琛ラ綈杩欎簺鏂囦欢骞跺湪鎵撳寘鍓嶆浛鎹㈠崰浣嶇閰嶇疆銆�
+
+妯℃澘浠剁粺涓�鏀惧湪 **`deploy-templates/`**锛堝凡鍏ュ簱锛夛細澶栭儴 `application.yml`銆乣start-backend.cmd/.sh`銆乣trafficAudit.xml`銆乣install-service.ps1`銆乣start-backend-service.cmd`銆乣install-autostart.ps1`銆乣nginx.conf.example`銆乣db\{init.sql,sql_city_bus,sql_city_taxi,sql_wyc}.sql`銆乣README-閮ㄧ讲璇存槑.txt`銆傛敼妯℃澘鍗虫敼鎵撳寘浜х墿銆�
+
+> `deploy-templates\tools\`锛圵inSW 浜岃繘鍒讹紝17.4MB锛�**涓嶅叆搴�**锛堝凡鍔� `.gitignore`锛夛紝鐢辨墦鍖呰剼鏈寜闇�涓嬭浇骞舵牎楠� SHA-256銆�
### 杞崲瀵嗛挜鍚庡悓姝ユ湰鏈�
@@ -282,7 +376,7 @@
### 閮ㄧ讲鏈轰笂鍔�/鎹� DeepSeek key
1. 鎵撳寘涓婁紶鍚庯紝缂栬緫 **jar 鍚岀骇**鐨� `backend/application.yml`锛歚deepseek.api-key: "浣犵殑鏂発ey"`锛堢暀绌哄垯椤甸潰 AI 鍒嗘瀽涓嶅彲鐢紝鍏朵綑鍔熻兘姝e父锛夛紱
-2. 閲嶅惎鍚庣锛歚start-backend.cmd`锛堟垨 `java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar`锛夛紱
+2. 閲嶅惎鍚庣锛歚start-backend.cmd`锛堟垨 `java -jar traffic-audit-server.jar`锛夛紱
3. 椤甸潰閲岀偣涓�娆� AI 鍒嗘瀽纭鍙敤锛�
4. **涓嶈**鍘绘敼 jar 鍐呴儴鐨勯厤缃紙鏀逛簡涓嬫鍙戝寘灏变涪锛夛紝涔熶笉瑕佹妸 key 鎻愪氦杩涗换浣曚粨搴撴枃浠躲��
diff --git a/pack-deploy.ps1 b/pack-deploy.ps1
index 42cc00d..68074de 100644
--- a/pack-deploy.ps1
+++ b/pack-deploy.ps1
@@ -1,34 +1,88 @@
锘�<#
- 涓�閿墦鍖呴儴缃插寘锛堝惈"鍘诲瘑鑷"锛夆�斺�� 2026-09-27 鏂板
+ 涓�閿墦鍖呴儴缃插寘锛堝惈"鍘诲瘑鑷"锛夆�斺�� 2026-09-27 鏂板锛�2026-10-03 淇
鐢ㄦ硶锛�
powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -OutRoot "E:\璇曢獙" -SkipFrontend
- 鍋氬洓浠朵簨锛�
- 1) mvn clean package 鏋勫缓鍚庣 jar
+ powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper
+ 鈥斺�� 鍗曠鍙e彂甯冿細涓嶇敓鎴愮嫭绔嬬殑 frontend\dist 鐩綍锛堜粛鏋勫缓骞跺悓姝� backend\web锛屽崟绔彛蹇呴渶锛夛紱
+ 鏈嶅姟鍣ㄥ凡瑁呮湇鍔℃椂閰嶅悎 -SkipServiceWrapper 鍘绘帀绾� 17 MB 鐨� WinSW锛屽彲鏄捐憲鍑忓皬鍖呬綋绉��
+
+ 鍋氫簲浠朵簨锛�
+ 1) 鐢� deploy-templates\jar-application.yml锛堝叏鍗犱綅绗︼級涓存椂鏇挎崲婧愮爜 application.yml锛�
+ 鎵ц mvn clean package锛涙棤璁烘垚璐ラ兘杩樺師寮�鍙戞満鐪熷疄閰嶇疆
2) 瑙e帇 jar 妫�鏌ュ唴缃� application.yml锛氬繀椤诲叏鏄崰浣嶇锛堢姝� sk- 瀵嗛挜 / 鏄庢枃鍙d护锛夛紝涓嶅悎鏍肩洿鎺ヤ腑姝�
- 3) npm run build 鏋勫缓鍓嶇 dist锛堝彲 -SkipFrontend锛�
- 4) 缁勮 deploy 鐩綍缁撴瀯锛坆ackend / frontend / db / README-閮ㄧ讲璇存槑.txt锛夛紝杈撳嚭鍒� <OutRoot>\traffic-audit-deploy-<yyyyMMdd>
+ 3) npm run build 閲嶅缓鍓嶇 dist锛堝彲 -SkipFrontend锛�
+ 4) 缁勮瀹屾暣閮ㄧ讲鍖咃細backend(jar + application.yml + 鍚姩鑴氭湰 + 鏈嶅姟鍖呰鍣� + docs + web) / frontend/dist(鍙�夛紝-NoFrontendDist 涓嶆墦) / db / README
+ Windows 鏈嶅姟鍖呰鍣� WinSW锛氫紭鍏堢敤 deploy-templates\tools\winsw.exe 缂撳瓨锛堟牎楠� SHA-256锛夛紝
+ 缂哄け鏃惰嚜鍔ㄤ笅杞� v2.12.0 x64 鍒拌缂撳瓨鐩綍锛涢殢鍖呰緭鍑轰负 backend\trafficAudit.exe
+ 5) 瀵圭粍瑁呭悗鐨勬暣鍖呭啀鎵竴閬嶆槑鏂囧瘑閽�
+
+ 淇鑳屾櫙锛�2026-10-03锛夛細2026-10-02 鎵嬪伐浜у嚭鐨勫寘缂� application.yml / docs / db / 鍚姩鑴氭湰锛�
+ 涓旀病鏈夋妸 dist 鍚屾鍒� backend\web锛岄儴缃叉満鍚姩鐩存帴鎶�
+ "java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)"銆�
+ 鏈増鎶婁笂杩版枃浠跺叏閮ㄨˉ榻愶紝骞舵寜 jar 鍐呯疆鍗犱綅绗﹂厤缃噸鏂版瀯寤猴紝閬垮厤鍐嶆鍑虹幇銆�
#>
param(
- [string]$OutRoot = 'E:\璇曢獙',
- [switch]$SkipFrontend
+ [string]$OutRoot = '',
+ [switch]$SkipFrontend,
+ [switch]$NoFrontendDist, # 鍗曠鍙e彂甯冿細涓嶆墦鐙珛鐨� frontend\dist 鐩綍锛屼絾浠嶆瀯寤�/鍚屾 backend\web锛堝崟绔彛蹇呴渶锛�
+ [switch]$SkipBackend, # 澶嶇敤宸插瓨鍦ㄧ殑 jar锛堜笉閲嶇紪璇戙�佷笉鍔ㄦ簮鐮侀厤缃級锛屼粎鐢ㄤ簬琛ュ叏/閲嶇粍鍙戝竷鍖�
+ [switch]$SkipServiceWrapper # 涓嶆妸 WinSW 鏈嶅姟鍖呰鍣ㄦ墦杩涘寘锛堝嵆涓嶆彁渚涒�滄敞鍐屾垚 Windows 鏈嶅姟鈥濈殑寮�鏈鸿嚜鍚級
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
-$tplDir = Join-Path $root 'deploy-templates' # 鎵撳寘妯℃澘浠讹紙宸插叆搴擄紝2026-09-27 璧凤級
-$jarRel = 'traffic-audit-server\target\traffic-audit-server-1.0.0-SNAPSHOT.jar'
-$jar = Join-Path $root $jarRel
+$pomPath = Join-Path $root 'traffic-audit-server\pom.xml'
+if (-not (Test-Path $pomPath)) { throw "Missing Maven project: $pomPath" }
+[xml]$pom = Get-Content -LiteralPath $pomPath -Raw -Encoding UTF8
+$productVersion = [string]$pom.project.version
+if (-not $productVersion) { throw 'Missing <version> in traffic-audit-server\pom.xml' }
+if ($productVersion -match '(?i)-SNAPSHOT$') { throw "Release builds must not use a SNAPSHOT version: $productVersion" }
+if (-not $OutRoot) { $OutRoot = Join-Path $root 'deploy' } # 榛樿杈撳嚭鍒颁粨搴� deploy\锛堝凡 gitignore锛�
+$tplDir = Join-Path $root 'deploy-templates' # 鎵撳寘妯℃澘浠讹紙宸插叆搴擄級
+$tplJarCfg = Join-Path $tplDir 'jar-application.yml' # 杩� jar 鐨勫崰浣嶇閰嶇疆
+$srcCfg = Join-Path $root 'traffic-audit-server\src\main\resources\application.yml'
+$jarRel = 'traffic-audit-server\target\traffic-audit-server.jar'
+$jar = Join-Path $root $jarRel
+$webDist = Join-Path $root 'traffic-audit-web\dist'
-Write-Host '[1/5] 鏋勫缓鍚庣锛坢vn 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] 鏋勫缓鍚庣锛坢vn 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] 鍘诲瘑鑷锛歫ar 鍐呯疆閰嶇疆蹇呴』鍏ㄦ槸鍗犱綅绗� ----------
Write-Host '[2/5] 鍘诲瘑鑷锛氳В鍘� jar 妫�鏌� BOOT-INF/classes/application.yml ...'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($jar)
@@ -38,45 +92,138 @@
$sr = New-Object System.IO.StreamReader($entry.Open())
$text = $sr.ReadToEnd()
$sr.Close()
+
+ $versionEntry = $zip.Entries | Where-Object { $_.FullName -eq 'BOOT-INF/classes/app-version.properties' }
+ if (-not $versionEntry) { throw 'jar 鍐呯己灏� BOOT-INF/classes/app-version.properties' }
+ $vr = New-Object System.IO.StreamReader($versionEntry.Open())
+ $versionText = $vr.ReadToEnd()
+ $vr.Close()
+
+ $manifestEntry = $zip.Entries | Where-Object { $_.FullName -eq 'META-INF/MANIFEST.MF' }
+ if (-not $manifestEntry) { throw 'jar 鍐呯己灏� META-INF/MANIFEST.MF' }
+ $mr = New-Object System.IO.StreamReader($manifestEntry.Open())
+ $manifestText = $mr.ReadToEnd()
+ $mr.Close()
} finally { $zip.Dispose() }
-$bad = @()
-if ($text -match 'sk-[A-Za-z0-9]{16,}') { $bad += '鐤戜技鏄庢枃 API key锛坰k-鈥︼級' }
-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 ' 璀﹀憡锛歫ar 鍐呯疆 password 涓嶆槸 TRAFFIC_DB_PASSWORD 鍗犱綅绗﹀舰寮忥紝璇风‘璁ゆā鏉裤��'
}
Write-Host ' 閫氳繃锛歫ar 鍐呯疆閰嶇疆鍏ㄩ儴涓哄崰浣嶇锛屾棤鏄庢枃瀵嗛挜/鍙d护銆�'
+$builtVersion = ''
+if ($versionText -match '(?m)^\s*version\s*=\s*(.+?)\s*$') { $builtVersion = $matches[1].Trim() }
+if ($builtVersion -ne $productVersion) {
+ throw "鐗堟湰涓嶄竴鑷达細pom.xml=$productVersion, app-version.properties=$builtVersion"
+}
+
+$manifestVersion = ''
+if ($manifestText -match '(?m)^Implementation-Version:\s*(.+?)\s*$') { $manifestVersion = $matches[1].Trim() }
+if ($manifestVersion -ne $productVersion) {
+ throw "鐗堟湰涓嶄竴鑷达細pom.xml=$productVersion, MANIFEST.MF=$manifestVersion"
+}
+Write-Host " 鐗堟湰鑷閫氳繃锛�$productVersion (pom / app-version.properties / MANIFEST.MF)"
+
+# ---------- [3/5] 鍓嶇鏋勫缓 ----------
+# 鍗曠鍙i儴缃插繀椤绘湁 backend\web锛屾墍浠ュ嵆浣� -NoFrontendDist 涔熼渶瑕� dist锛涘彧鏈� -SkipFrontend 鎵嶅鐢ㄧ幇鏈� dist銆�
Write-Host '[3/5] 鏋勫缓鍓嶇锛坣pm 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锛圵indows 鏈嶅姟鍖呰鍣級鍑嗗锛氭湰鍦扮紦瀛樹紭鍏堬紝缂哄け鏃朵粠瀹樻柟鍙戝竷椤典笅杞藉苟鏍¢獙 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 (' 璀﹀憡锛歐inSW 缂撳瓨 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 灏辩华锛圫HA-256 宸叉牎楠岋級锛歿0:N1} MB' -f ((Get-Item -LiteralPath $winswSrc).Length / 1MB))
+}
+
+# ---------- [4/5] 缁勮瀹屾暣閮ㄧ讲鐩綍 ----------
Write-Host '[4/5] 缁勮閮ㄧ讲鐩綍...'
$stamp = Get-Date -Format 'yyyyMMdd'
-$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp)
+$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion)
$n = 2
-while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-" + $n); $n++ }
-New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
+while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion + "-" + $n); $n++ }
+if ($NoFrontendDist) {
+ New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'db') | Out-Null
+} else {
+ New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
+}
Copy-Item $jar (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\application.yml') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend.cmd') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend.sh') (Join-Path $dest 'backend') -Force
-Copy-Item (Join-Path $root 'traffic-audit-web\dist') (Join-Path $dest 'frontend') -Recurse -Force
-Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
+Copy-Item (Join-Path $tplDir 'backend\start-backend-service.cmd') (Join-Path $dest 'backend') -Force
+Copy-Item (Join-Path $tplDir 'backend\install-autostart.ps1') (Join-Path $dest 'backend') -Force
+if (-not $SkipServiceWrapper) {
+ # WinSW 瑕佹眰閰嶇疆鏂囦欢鍚嶄笌 exe 鍚屽悕锛歵rafficAudit.exe 璇诲悓鐩綍鐨� trafficAudit.xml
+ Copy-Item -LiteralPath $winswSrc (Join-Path $dest 'backend\trafficAudit.exe') -Force
+ Copy-Item (Join-Path $tplDir 'backend\trafficAudit.xml') (Join-Path $dest 'backend') -Force
+ Copy-Item (Join-Path $tplDir 'backend\install-service.ps1') (Join-Path $dest 'backend') -Force
+ Copy-Item (Join-Path $tplDir 'backend\install-service.cmd') (Join-Path $dest 'backend') -Force
+}
+if (-not $NoFrontendDist) {
+ Copy-Item $webDist (Join-Path $dest 'frontend') -Recurse -Force
+ Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
+}
Copy-Item (Join-Path $tplDir 'db\*.sql') (Join-Path $dest 'db') -Force
+
+# 鍗曠鍙� 8090 妯″紡锛氬悗绔粠 jar 鍚岀骇 web\ 璇诲墠绔潤鎬佹枃浠讹紝蹇呴』鍜� dist 鍐呭涓�鑷淬��
+# 鍗曠鍙e彂甯冿紙-NoFrontendDist锛夊悓鏍峰繀椤诲悓姝ワ紝鍚﹀垯椤甸潰 404銆�
+Copy-Item $webDist (Join-Path $dest 'backend\web') -Recurse -Force
# docs锛氳繍琛屾椂妯℃澘 + 鐢ㄦ埛鏂囨。锛涙帓闄ゅ紑鍙戣繃绋嬫枃妗d笌缂撳瓨/澶囦唤锛堝噺灏忎綋绉�侀伩鍏嶆妸鍐呴儴娓呭崟甯﹀嚭鍘伙級
$docsDest = Join-Path $dest 'backend\docs'
@@ -84,15 +231,31 @@
cmd /c "robocopy `"$root\docs`" `"$docsDest`" /E /NFL /NDL /NJH /NJS /NP /XD _cache 宸ヤ綔鏃ョ粨 鍔熻兘娴嬭瘯鎶ュ憡 闂姹囨�� /XF 鐢熸垚_*.xlsx _bak_* >nul"
if ($LASTEXITCODE -ge 8) { throw "docs 澶嶅埗澶辫触锛坮obocopy 杩斿洖 $LASTEXITCODE锛�" }
# db 鑴氭湰涔熸斁涓�浠藉埌 docs锛堜笌鏃у寘涓�鑷达細docs/init.sql 绛夛級
-Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql') $docsDest -Force -ErrorAction SilentlyContinue
+Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_vehicle_adjustment_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
-# README锛氱敤鏃у寘妯℃澘骞舵浛鎹㈡棩鏈熷崰浣�
+# README锛氱敤妯℃澘骞舵浛鎹㈡棩鏈熷崰浣�
$readme = Get-Content (Join-Path $tplDir 'README-閮ㄧ讲璇存槑.txt') -Raw -Encoding UTF8
-$readme = $readme.Replace('{stamp}', $stamp)
+$readme = $readme.Replace('{stamp}', $stamp).Replace('{version}', $productVersion)
Set-Content -LiteralPath (Join-Path $dest 'README-閮ㄧ讲璇存槑.txt') -Value $readme -Encoding UTF8
-Write-Host '[5/5] 姹囨��...'
+# ---------- [5/5] 鏁村寘鏄庢枃瀵嗛挜鎵弿 + 姹囨�� ----------
+Write-Host '[5/5] 鏁村寘鎵弿 + 姹囨��...'
+$leak = @()
+foreach ($f in (Get-ChildItem (Join-Path $dest 'backend') -File | Where-Object { $_.Extension -in '.yml', '.yaml', '.cmd', '.sh', '.txt' })) {
+ $t = Get-Content $f.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
+ if ($t -and $t -match 'sk-[A-Za-z0-9]{16,}') { $leak += $f.Name + '(鐤戜技鏄庢枃 API key)' }
+}
+if ($leak.Count -gt 0) { throw ('鏁村寘鎵弿鏈�氳繃锛�' + ($leak -join '锛�')) }
+
$files = Get-ChildItem $dest -Recurse -File
+$mb = (($files | Measure-Object Length -Sum).Sum / 1MB)
Write-Host (" 杈撳嚭鐩綍锛歿0}" -f $dest)
-Write-Host (" 鏂囦欢鏁帮細{0}銆�鍚堣锛歿1:N2} MB" -f $files.Count, (($files | Measure-Object Length -Sum).Sum / 1MB))
-Write-Host ' 鎻愰啋锛氭妸鍖呮嫹鍒伴儴缃叉満鍚庯紝鍙渶鏀� backend\application.yml 閲岀殑鏁版嵁搴撲笁澶� + 闇�瑕佹椂濉� deepseek.api-key銆�'
+Write-Host (" 鏂囦欢鏁帮細{0} 鍚堣锛歿1:N2} MB" -f $files.Count, $mb)
+
+$zipPath = $dest + '.zip'
+if (Get-Command Compress-Archive -ErrorAction SilentlyContinue) {
+ if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
+ Compress-Archive -Path (Join-Path $dest '*') -DestinationPath $zipPath -CompressionLevel Optimal
+ Write-Host (" 鍘嬬缉鍖咃細{0}锛坽1:N2} MB锛�" -f $zipPath, ((Get-Item $zipPath).Length / 1MB))
+}
+Write-Host ' 鎻愰啋锛氭嫹鍒伴儴缃叉満鍚庯紝鍔″繀鍏堟敼 backend\application.yml 鐨� spring.datasource锛坲rl/username/password锛夊啀鍚姩銆�'
diff --git a/package-deploy.py b/package-deploy.py
index 41d0c19..2e2149c 100644
--- a/package-deploy.py
+++ b/package-deploy.py
@@ -2,16 +2,21 @@
"""TrafficAudit 涓�閿墦鍖呰剼鏈細鍓嶇鏋勫缓 -> 鍐呭祵杩涘悗绔� jar -> 缁勮閮ㄧ讲鍖� -> 鎵� zip
鐢ㄦ硶锛歱ython package-deploy.py [--no-build]
"""
-import io, os, sys, shutil, zipfile, subprocess, datetime
+import io, os, sys, shutil, zipfile, subprocess, datetime, xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.abspath(__file__))
WEB = os.path.join(ROOT, "traffic-audit-web")
SERVER = os.path.join(ROOT, "traffic-audit-server")
STATIC = os.path.join(SERVER, "src", "main", "resources", "static")
+_pom_tree = ET.parse(os.path.join(SERVER, "pom.xml"))
+_pom_ns = {"m": "http://maven.apache.org/POM/4.0.0"}
+PRODUCT_VERSION = (_pom_tree.getroot().findtext("m:version", namespaces=_pom_ns) or "").strip()
+if not PRODUCT_VERSION:
+ raise SystemExit("Missing <version> in traffic-audit-server/pom.xml")
STAMP = datetime.datetime.now().strftime("%Y%m%d")
-STAGE = os.path.join(ROOT, "deploy", "traffic-audit-deploy-" + STAMP)
-ZIP = os.path.join(ROOT, "traffic-audit-deploy-" + STAMP + ".zip")
-JAR_NAME = "traffic-audit-server-1.0.0-SNAPSHOT.jar"
+STAGE = os.path.join(ROOT, "deploy", "traffic-audit-deploy-" + STAMP + "-v" + PRODUCT_VERSION)
+ZIP = os.path.join(ROOT, "traffic-audit-deploy-" + STAMP + "-v" + PRODUCT_VERSION + ".zip")
+JAR_NAME = "traffic-audit-server.jar"
SKIP_DOC_DIRS = {"ai_demo", "usage_screenshots", "鍔熻兘娴嬭瘯鎶ュ憡", "宸ヤ綔鏃ョ粨"}
def rmtree_force(path):
@@ -97,7 +102,7 @@
io.open(os.path.join(be, "start-backend.cmd"), "w", encoding="utf-8", newline="\r\n").write(START_CMD)
io.open(os.path.join(be, "start-backend.sh"), "w", encoding="utf-8", newline="\n").write(START_SH)
io.open(os.path.join(STAGE, "frontend", "nginx.conf.example"), "w", encoding="utf-8", newline="\n").write(NGINX)
- io.open(os.path.join(STAGE, "README-閮ㄧ讲璇存槑.txt"), "w", encoding="utf-8", newline="\r\n").write(README)
+ io.open(os.path.join(STAGE, "README-閮ㄧ讲璇存槑.txt"), "w", encoding="utf-8", newline="\r\n").write(README.replace("{version}", PRODUCT_VERSION))
print("[5/6] 鎵� zip ...")
if os.path.isfile(ZIP):
@@ -183,14 +188,14 @@
echo 鍓嶇宸插唴宓岋紝鐩存帴璁块棶 8090 鍗冲彲锛堜笉闇�瑕� nginx锛�
echo 鏃ュ織鏄剧ず鍦ㄦ湰绐楀彛锛屽叧闂獥鍙e嵆鍋滄鏈嶅姟
echo ============================================
-java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar
+java -jar traffic-audit-server.jar
pause
"""
START_SH = u"""#!/bin/sh
cd "$(dirname "$0")" || exit 1
echo "TrafficAudit 鍚姩涓細http://localhost:8090 锛圕trl+C 鍋滄锛�"
-java -jar traffic-audit-server-1.0.0-SNAPSHOT.jar 2>&1 | tee app.log
+java -jar traffic-audit-server.jar 2>&1 | tee app.log
"""
NGINX = u"""# 鍓嶇 nginx 閰嶇疆绀轰緥锛堝彧鏈夊湪涓嶆兂鐢ㄣ�宩ar 鍐呭祵鍓嶇銆嶆椂鎵嶉渶瑕侊級
@@ -210,12 +215,12 @@
}
"""
-README = u"""TrafficAudit 浜ら�氳揣杩愮粺璁″鏍哥郴缁� 鈥斺�� 閮ㄧ讲鍖咃紙{stamp}锛�
+README = u"""TrafficAudit 浜ら�氳揣杩愮粺璁″鏍哥郴缁� 鈥斺�� 閮ㄧ讲鍖咃紙{stamp}锛岀増鏈� v{version}锛�
========================================================
銆愮洰褰曠粨鏋勩��
backend/
- traffic-audit-server-1.0.0-SNAPSHOT.jar 鍚庣绋嬪簭锛堝惈鍏ㄩ儴渚濊禆锛�**宸插唴宓屽墠绔�**锛�
+ traffic-audit-server.jar 鍚庣绋嬪簭锛堝惈鍏ㄩ儴渚濊禆锛�**宸插唴宓屽墠绔�**锛屽浐瀹氭枃浠跺悕锛�
docs/ 鎶ヨ〃妯℃澘涓庤繍琛屾暟鎹洰褰曪紙蹇呴渶锛屽嬁鍒狅級
application.yml 澶栭儴閰嶇疆锛堟敼杩欓噷鍗冲彲锛屾棤闇�閲嶆柊鎵撳寘锛�
start-backend.cmd / start-backend.sh 鍚姩鑴氭湰
diff --git a/traffic-audit-server/pom.xml b/traffic-audit-server/pom.xml
index c747c5d..dea28f5 100644
--- a/traffic-audit-server/pom.xml
+++ b/traffic-audit-server/pom.xml
@@ -10,7 +10,7 @@
</parent>
<groupId>com.trafficaudit</groupId>
<artifactId>traffic-audit-server</artifactId>
- <version>1.0.0-SNAPSHOT</version>
+ <version>1.0.0</version>
<packaging>jar</packaging>
<name>traffic-audit-server</name>
<properties>
@@ -19,6 +19,7 @@
<hutool.version>5.4.0</hutool.version>
<easyexcel.version>4.0.3</easyexcel.version>
<jjwt.version>0.9.1</jjwt.version>
+ <jfreechart.version>1.5.4</jfreechart.version>
</properties>
<dependencies>
<dependency>
@@ -59,6 +60,11 @@
<version>${hutool.version}</version>
</dependency>
<dependency>
+ <groupId>org.jfree</groupId>
+ <artifactId>jfreechart</artifactId>
+ <version>${jfreechart.version}</version>
+ </dependency>
+ <dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
@@ -70,8 +76,36 @@
</dependency>
</dependencies>
<build>
+ <finalName>traffic-audit-server</finalName>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <filtering>false</filtering>
+ <excludes>
+ <exclude>app-version.properties</exclude>
+ </excludes>
+ </resource>
+ <resource>
+ <directory>src/main/resources</directory>
+ <filtering>true</filtering>
+ <includes>
+ <include>app-version.properties</include>
+ </includes>
+ </resource>
+ </resources>
<plugins>
<plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <archive>
+ <manifest>
+ <addDefaultImplementationEntries>true</addDefaultImplementationEntries>
+ </manifest>
+ </archive>
+ </configuration>
+ </plugin>
+ <plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java
new file mode 100644
index 0000000..10ccab4
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayConstants.java
@@ -0,0 +1,116 @@
+package com.trafficaudit.holiday;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+public final class HolidayConstants {
+ public static final int COMPARISON_BASE_YEAR = 2026;
+
+ public static final String TYPE_SPRING_FESTIVAL = "SPRING_FESTIVAL";
+
+ public static final String TYPE_LABOR_DAY = "LABOR_DAY";
+ public static final String TYPE_NATIONAL_DAY = "NATIONAL_DAY";
+
+ public static final String CATEGORY_HOLIDAY = "HOLIDAY";
+ public static final String CATEGORY_NON_HOLIDAY = "NON_HOLIDAY";
+
+ public static final String MODE_RAIL = "RAIL";
+ public static final String MODE_CIVIL_AVIATION = "CIVIL_AVIATION";
+ public static final String MODE_ROAD = "ROAD";
+ public static final String MODE_WATER = "WATER";
+
+ public static final String ROAD_EXPRESSWAY = "EXPRESSWAY";
+ public static final String ROAD_NATIONAL_PROVINCIAL = "NATIONAL_PROVINCIAL";
+
+ public static final String VEHICLE_SMALL_PASSENGER = "SMALL_PASSENGER";
+ public static final String VEHICLE_MEDIUM_SMALL_PASSENGER = "MEDIUM_SMALL_PASSENGER";
+
+ public static final Map<String, String> HOLIDAY_TYPE_LABELS;
+ public static final Map<String, String> DATE_CATEGORY_LABELS;
+ public static final Map<String, String> TRANSPORT_MODE_LABELS;
+ public static final Map<String, String> ROAD_CATEGORY_LABELS;
+ public static final Map<String, String> VEHICLE_TYPE_LABELS;
+
+ private static final Set<String> HOLIDAY_TYPES;
+ private static final Set<String> DATE_CATEGORIES;
+ private static final Set<String> TRANSPORT_MODES;
+ private static final Set<String> ROAD_CATEGORIES;
+ private static final Set<String> VEHICLE_TYPES;
+
+ static {
+ Map<String, String> holidayTypes = new LinkedHashMap<>();
+ holidayTypes.put(TYPE_SPRING_FESTIVAL, "鏄ヨ妭");
+ holidayTypes.put(TYPE_LABOR_DAY, "浜斾竴");
+ holidayTypes.put(TYPE_NATIONAL_DAY, "鍗佷竴");
+ HOLIDAY_TYPE_LABELS = Collections.unmodifiableMap(holidayTypes);
+ HOLIDAY_TYPES = new LinkedHashSet<>(holidayTypes.keySet());
+
+ Map<String, String> dateCategories = new LinkedHashMap<>();
+ dateCategories.put(CATEGORY_HOLIDAY, "鑺傚亣鏃�");
+ dateCategories.put(CATEGORY_NON_HOLIDAY, "闈炶妭鍋囨棩");
+ DATE_CATEGORY_LABELS = Collections.unmodifiableMap(dateCategories);
+ DATE_CATEGORIES = new LinkedHashSet<>(dateCategories.keySet());
+
+ Map<String, String> transportModes = new LinkedHashMap<>();
+ transportModes.put(MODE_RAIL, "閾佽矾");
+ transportModes.put(MODE_CIVIL_AVIATION, "姘戣埅");
+ transportModes.put(MODE_ROAD, "閬撹矾");
+ transportModes.put(MODE_WATER, "姘磋矾");
+ TRANSPORT_MODE_LABELS = Collections.unmodifiableMap(transportModes);
+ TRANSPORT_MODES = new LinkedHashSet<>(transportModes.keySet());
+
+ Map<String, String> roadCategories = new LinkedHashMap<>();
+ roadCategories.put(ROAD_EXPRESSWAY, "楂橀�熷叕璺�");
+ roadCategories.put(ROAD_NATIONAL_PROVINCIAL, "鏅�氬浗鐪侀亾");
+ ROAD_CATEGORY_LABELS = Collections.unmodifiableMap(roadCategories);
+ ROAD_CATEGORIES = new LinkedHashSet<>(roadCategories.keySet());
+
+ Map<String, String> vehicleTypes = new LinkedHashMap<>();
+ vehicleTypes.put(VEHICLE_SMALL_PASSENGER, "灏忓杞�");
+ vehicleTypes.put(VEHICLE_MEDIUM_SMALL_PASSENGER, "涓皬瀹㈣溅");
+ VEHICLE_TYPE_LABELS = Collections.unmodifiableMap(vehicleTypes);
+ VEHICLE_TYPES = new LinkedHashSet<>(vehicleTypes.keySet());
+ }
+
+ private HolidayConstants() {
+ }
+
+ public static boolean supportsComparisonBase(int year) {
+ return year == COMPARISON_BASE_YEAR;
+ }
+
+ public static boolean isValidHolidayType(String value) {
+ return HOLIDAY_TYPES.contains(value);
+ }
+
+ public static boolean isValidDateCategory(String value) {
+ return DATE_CATEGORIES.contains(value);
+ }
+
+ public static boolean isValidTransportMode(String value) {
+ return TRANSPORT_MODES.contains(value);
+ }
+
+ public static boolean isValidRoadCategory(String value) {
+ return ROAD_CATEGORIES.contains(value);
+ }
+
+ public static boolean isValidVehicleType(String value) {
+ return VEHICLE_TYPES.contains(value);
+ }
+
+ public static Map<String, Object> optionMap() {
+ Map<String, Object> options = new LinkedHashMap<>();
+ options.put("holidayTypes", HOLIDAY_TYPE_LABELS);
+ options.put("dateCategories", DATE_CATEGORY_LABELS);
+ options.put("transportModes", TRANSPORT_MODE_LABELS);
+ options.put("roadCategories", ROAD_CATEGORY_LABELS);
+ options.put("vehicleTypes", VEHICLE_TYPE_LABELS);
+ options.put("years", Arrays.asList(2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030));
+ return options;
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java
new file mode 100644
index 0000000..fb0ea58
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/HolidayNumberUtils.java
@@ -0,0 +1,70 @@
+package com.trafficaudit.holiday;
+
+import org.apache.poi.ss.util.NumberToTextConverter;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+
+public final class HolidayNumberUtils {
+
+ public static final MathContext EXCEL_MC = new MathContext(15, RoundingMode.HALF_UP);
+ public static final BigDecimal ZERO = BigDecimal.ZERO;
+
+ private HolidayNumberUtils() {
+ }
+
+ public static BigDecimal excelValue(double value) {
+ return normalize(new BigDecimal(NumberToTextConverter.toText(value)));
+ }
+
+ public static BigDecimal decimalValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return normalize(new BigDecimal(value.toString().trim()));
+ }
+
+ public static BigDecimal normalize(BigDecimal value) {
+ if (value == null) return null;
+ BigDecimal rounded = value.round(EXCEL_MC);
+ return rounded.signum() == 0 ? ZERO : rounded.stripTrailingZeros();
+ }
+
+ public static BigDecimal valueOrZero(BigDecimal value) {
+ return value == null ? ZERO : value;
+ }
+
+ public static BigDecimal sumNullable(BigDecimal left, BigDecimal right) {
+ if (left == null && right == null) return null;
+ return normalize(valueOrZero(left).add(valueOrZero(right)));
+ }
+
+ public static BigDecimal multiply(BigDecimal left, BigDecimal right) {
+ if (left == null || right == null) return null;
+ return normalize(left.multiply(right));
+ }
+
+ public static BigDecimal divide(BigDecimal dividend, BigDecimal divisor) {
+ if (dividend == null || divisor == null || divisor.signum() == 0) return null;
+ return normalize(dividend.divide(divisor, EXCEL_MC));
+ }
+
+ public static boolean sameValue(BigDecimal left, BigDecimal right) {
+ if (left == null || right == null) return false;
+ return normalize(left).compareTo(normalize(right)) == 0;
+ }
+
+ public static String changeText(BigDecimal value) {
+ if (value == null) return "";
+ return normalize(value).toPlainString();
+ }
+
+ public static String format1(BigDecimal value) {
+ if (value == null) return "";
+ return value.setScale(1, RoundingMode.HALF_UP).toPlainString();
+ }
+
+ public static String format2(BigDecimal value) {
+ if (value == null) return "";
+ return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java
new file mode 100644
index 0000000..63c5a07
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayConfigController.java
@@ -0,0 +1,95 @@
+package com.trafficaudit.holiday.controller;
+
+import com.trafficaudit.common.Result;
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
+import com.trafficaudit.holiday.service.HolidayConfigService;
+import com.trafficaudit.security.utils.JwtUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/holiday/config")
+public class HolidayConfigController {
+
+ @Resource
+ private HolidayConfigService configService;
+ @Resource
+ private JwtUtils jwtUtils;
+
+ @GetMapping("/options")
+ public Result<Map<String, Object>> options() {
+ return Result.ok(HolidayConstants.optionMap());
+ }
+
+ @GetMapping("/holidays")
+ public Result<List<HolidayCalendar>> holidays(@RequestParam(value = "year", required = false) Integer year,
+ @RequestParam(value = "holidayType", required = false) String holidayType) {
+ return Result.ok(configService.listHolidays(year, holidayType));
+ }
+
+ @PostMapping("/holidays/save")
+ public Result<HolidayCalendar> saveHoliday(@RequestHeader(value = "Authorization", required = false) String auth,
+ @RequestBody Map<String, Object> params) {
+ if (!isAdmin(auth)) return Result.error(403, "浠� admin 鍙互绠$悊鑺傚亣鏃ラ厤缃�");
+ try {
+ return Result.ok(configService.saveHoliday(params));
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @PostMapping("/holidays/delete")
+ public Result<Void> deleteHoliday(@RequestHeader(value = "Authorization", required = false) String auth,
+ @RequestParam("id") Long id) {
+ if (!isAdmin(auth)) return Result.error(403, "浠� admin 鍙互绠$悊鑺傚亣鏃ラ厤缃�");
+ try {
+ configService.deleteHoliday(id);
+ return Result.ok();
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @GetMapping("/coefficients")
+ public Result<List<HolidayPassengerCoefficient>> coefficients(@RequestParam(value = "year", required = false) Integer year) {
+ return Result.ok(configService.listCoefficients(year));
+ }
+
+ @PostMapping("/coefficients/save")
+ public Result<HolidayPassengerCoefficient> saveCoefficient(@RequestHeader(value = "Authorization", required = false) String auth,
+ @RequestBody Map<String, Object> params) {
+ if (!isAdmin(auth)) return Result.error(403, "浠� admin 鍙互绠$悊瀹㈣繍绯绘暟");
+ try {
+ return Result.ok(configService.saveCoefficient(params));
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @PostMapping("/coefficients/delete")
+ public Result<Void> deleteCoefficient(@RequestHeader(value = "Authorization", required = false) String auth,
+ @RequestParam("id") Long id) {
+ if (!isAdmin(auth)) return Result.error(403, "浠� admin 鍙互绠$悊瀹㈣繍绯绘暟");
+ try {
+ configService.deleteCoefficient(id);
+ return Result.ok();
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ private boolean isAdmin(String auth) {
+ return "admin".equals(jwtUtils.resolveUsername(auth));
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java
new file mode 100644
index 0000000..63a0c42
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayStatController.java
@@ -0,0 +1,98 @@
+package com.trafficaudit.holiday.controller;
+
+import com.trafficaudit.common.Result;
+import com.trafficaudit.holiday.service.HolidayBulletinService;
+import com.trafficaudit.holiday.service.HolidayTrendService;
+import com.trafficaudit.holiday.service.HolidayStatService;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.net.URLEncoder;
+import java.time.LocalDate;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/holiday")
+public class HolidayStatController {
+
+ @Resource
+ private HolidayStatService statService;
+ @Resource
+ private HolidayBulletinService bulletinService;
+
+ @Resource
+ private HolidayTrendService trendService;
+
+ @GetMapping("/overview")
+ public Result<Map<String, Object>> overview(@RequestParam("year") int year,
+ @RequestParam("holidayType") String holidayType) {
+ try {
+ return Result.ok(statService.overview(year, holidayType));
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @PostMapping("/import")
+ public Result<Map<String, Object>> importWorkbook(@RequestParam(value = "file", required = false) MultipartFile file,
+ @RequestParam("year") int year,
+ @RequestParam("holidayType") String holidayType,
+ @RequestParam(value = "allowHistoricalOverwrite", defaultValue = "false") boolean allowHistoricalOverwrite) {
+ try {
+ return Result.ok(statService.importWorkbook(file, year, holidayType, allowHistoricalOverwrite));
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @GetMapping("/export")
+ public ResponseEntity<byte[]> exportWorkbook(@RequestParam("year") int year,
+ @RequestParam("holidayType") String holidayType) throws Exception {
+ byte[] data = statService.exportWorkbook(year, holidayType);
+ String fileName = year + "骞�" + holidayTypeFileLabel(holidayType) + "鏁版嵁.xlsx";
+ String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+ .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .body(data);
+ }
+
+ @GetMapping("/bulletin")
+ public ResponseEntity<byte[]> bulletin(@RequestParam(value = "date", required = false) String date) throws Exception {
+ LocalDate targetDate = (date == null || date.trim().isEmpty())
+ ? LocalDate.now().minusDays(1) : LocalDate.parse(date);
+ byte[] data = bulletinService.generate(targetDate);
+ String fileName = String.format("婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ワ紙10鏈�%d鏃ワ級.docx", targetDate.getDayOfMonth());
+ String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+ .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
+ .body(data);
+ }
+
+ @GetMapping("/trend")
+ public ResponseEntity<byte[]> trend(@RequestParam("year") int year,
+ @RequestParam("holidayType") String holidayType) throws Exception {
+ byte[] data = trendService.generate(year, holidayType);
+ String fileName = year + "骞�" + holidayTypeFileLabel(holidayType) + "鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx";
+ String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
+ .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
+ .body(data);
+ }
+
+ private String holidayTypeFileLabel(String holidayType) {
+ if ("SPRING_FESTIVAL".equals(holidayType)) return "鏄ヨ妭";
+ if ("LABOR_DAY".equals(holidayType)) return "浜斾竴";
+ return "鍗佷竴";
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java
new file mode 100644
index 0000000..932b9a3
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/controller/HolidayVehicleAdjustmentController.java
@@ -0,0 +1,71 @@
+package com.trafficaudit.holiday.controller;
+
+import com.trafficaudit.common.Result;
+import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
+import com.trafficaudit.holiday.service.HolidayVehicleAdjustmentService;
+import com.trafficaudit.security.utils.JwtUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/holiday/adjustments")
+public class HolidayVehicleAdjustmentController {
+
+ @Resource
+ private HolidayVehicleAdjustmentService adjustmentService;
+ @Resource
+ private JwtUtils jwtUtils;
+
+ @GetMapping
+ public Result<List<HolidayVehicleAdjustment>> list(@RequestParam(value = "year", required = false) Integer year) {
+ return Result.ok(adjustmentService.list(year));
+ }
+
+ @GetMapping("/source-value")
+ public Result<BigDecimal> sourceValue(@RequestParam("year") Integer year,
+ @RequestParam("flowDate") String flowDate) {
+ try {
+ return Result.ok(adjustmentService.findOriginalValue(year, LocalDate.parse(flowDate)));
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @PostMapping("/save")
+ public Result<HolidayVehicleAdjustment> save(@RequestHeader(value = "Authorization", required = false) String auth,
+ @RequestBody Map<String, Object> params) {
+ if (!isAdmin(auth)) return Result.error(403, "浠� admin 鍙互缁存姢寰�骞村浗鐪侀亾鏁版嵁璋冩暣");
+ try {
+ return Result.ok(adjustmentService.save(params));
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ @PostMapping("/delete")
+ public Result<Void> delete(@RequestHeader(value = "Authorization", required = false) String auth,
+ @RequestParam("id") Long id) {
+ if (!isAdmin(auth)) return Result.error(403, "浠� admin 鍙互缁存姢寰�骞村浗鐪侀亾鏁版嵁璋冩暣");
+ try {
+ adjustmentService.delete(id);
+ return Result.ok();
+ } catch (Exception e) {
+ return Result.error(400, e.getMessage());
+ }
+ }
+
+ private boolean isAdmin(String auth) {
+ return "admin".equals(jwtUtils.resolveUsername(auth));
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java
new file mode 100644
index 0000000..3676039
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayCalendar.java
@@ -0,0 +1,22 @@
+package com.trafficaudit.holiday.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("holiday_calendar")
+public class HolidayCalendar {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Integer year;
+ private String holidayType;
+ private LocalDate holidayDate;
+ private Integer dayIndex;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java
new file mode 100644
index 0000000..cd09b3c
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerCoefficient.java
@@ -0,0 +1,21 @@
+package com.trafficaudit.holiday.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("holiday_passenger_coefficient")
+public class HolidayPassengerCoefficient {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Integer year;
+ private String dateCategory;
+ private BigDecimal coefficient;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java
new file mode 100644
index 0000000..4aacf85
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayPassengerFlow.java
@@ -0,0 +1,23 @@
+package com.trafficaudit.holiday.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("holiday_passenger_flow")
+public class HolidayPassengerFlow {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Integer year;
+ private LocalDate flowDate;
+ private String transportMode;
+ private BigDecimal passengerCount;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java
new file mode 100644
index 0000000..ffbcfd3
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleAdjustment.java
@@ -0,0 +1,26 @@
+package com.trafficaudit.holiday.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("holiday_vehicle_adjustment")
+public class HolidayVehicleAdjustment {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Integer year;
+ private LocalDate flowDate;
+ private BigDecimal adjustedValue;
+ @TableField(exist = false)
+ private BigDecimal originalValue;
+ private String reason;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java
new file mode 100644
index 0000000..c9dd32e
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/entity/HolidayVehicleFlow.java
@@ -0,0 +1,28 @@
+package com.trafficaudit.holiday.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldStrategy;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("holiday_vehicle_flow")
+public class HolidayVehicleFlow {
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Integer year;
+ private LocalDate flowDate;
+ private String roadCategory;
+ private String vehicleType;
+ private BigDecimal vehicleCount;
+ @TableField(updateStrategy = FieldStrategy.IGNORED)
+ private BigDecimal comparisonBase;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java
new file mode 100644
index 0000000..26b1149
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayCalendarMapper.java
@@ -0,0 +1,9 @@
+package com.trafficaudit.holiday.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HolidayCalendarMapper extends BaseMapper<HolidayCalendar> {
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java
new file mode 100644
index 0000000..faac8c3
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerCoefficientMapper.java
@@ -0,0 +1,9 @@
+package com.trafficaudit.holiday.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HolidayPassengerCoefficientMapper extends BaseMapper<HolidayPassengerCoefficient> {
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java
new file mode 100644
index 0000000..543e691
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayPassengerFlowMapper.java
@@ -0,0 +1,9 @@
+package com.trafficaudit.holiday.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HolidayPassengerFlowMapper extends BaseMapper<HolidayPassengerFlow> {
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java
new file mode 100644
index 0000000..986090a
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleAdjustmentMapper.java
@@ -0,0 +1,9 @@
+package com.trafficaudit.holiday.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HolidayVehicleAdjustmentMapper extends BaseMapper<HolidayVehicleAdjustment> {
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java
new file mode 100644
index 0000000..a99a00c
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/mapper/HolidayVehicleFlowMapper.java
@@ -0,0 +1,9 @@
+package com.trafficaudit.holiday.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HolidayVehicleFlowMapper extends BaseMapper<HolidayVehicleFlow> {
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
new file mode 100644
index 0000000..77f85f9
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayBulletinService.java
@@ -0,0 +1,554 @@
+package com.trafficaudit.holiday.service;
+
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.annotation.Resource;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.file.Files;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+@Slf4j
+@Service
+public class HolidayBulletinService {
+
+ private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ private static final BigDecimal EPSILON = new BigDecimal("0.0000001");
+ private static final int EXCEL_ONE_DECIMAL_PERCENT = 1;
+ private static final int EXCEL_TWO_DECIMAL_PERCENT = 2;
+ private static final int EXCEL_SEVEN_DECIMAL_YI = 7;
+ private static final BigDecimal TEN_THOUSAND = new BigDecimal("10000");
+
+ @Value("${holiday.bulletin-template:docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ユā鏉�.docx}")
+ private String bulletinTemplatePath;
+
+ private final HolidayStatService statService;
+ @Resource
+ private HolidayConfigService configService;
+ private static final ThreadLocal<Document> CURRENT_DOCUMENT = new ThreadLocal<>();
+
+ public HolidayBulletinService(HolidayStatService statService) {
+ this.statService = statService;
+ }
+
+ public byte[] generate(LocalDate date) {
+ DailyMetrics current = statService.findMetricsByDate(date);
+ if (current == null) {
+ throw new IllegalArgumentException("鎵�閫夋棩鏈熶笉鍦ㄥ凡閰嶇疆鐨勮妭鍋囨棩鑼冨洿鍐�");
+ }
+ DailyMetrics previousDay = current.getDayIndex() > 1
+ ? statService.ringRatioBaseMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex()) : null;
+ HolidayCalendar previousDayConfig = configService.findPreviousDay(current.getYear(), current.getHolidayType());
+ LocalDate previousDayDate = previousDayConfig == null || previousDayConfig.getHolidayDate() == null
+ ? current.getDate().minusDays(1) : previousDayConfig.getHolidayDate();
+ DailyMetrics previousCalendarDay = previousDay != null
+ ? previousDay : statService.dailyMetricsByDate(previousDayDate);
+ DailyMetrics previousYear = statService.dailyMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
+ DailyMetrics twoYearsAgo = statService.dailyMetrics(current.getYear() - 2, current.getHolidayType(), current.getDayIndex());
+ DailyMetrics currentCumulative = statService.cumulativeMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex());
+ DailyMetrics previousYearCumulative = statService.cumulativeMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex());
+ DailyMetrics twoYearsAgoCumulative = statService.cumulativeMetrics(current.getYear() - 2, current.getHolidayType(), current.getDayIndex());
+
+ if (current.getTotalPersonFlow() == null) {
+ throw new IllegalArgumentException("鎵�閫夋棩鏈熺殑鍏ㄧぞ浼氳法鍖哄煙浜哄憳娴佸姩閲忔病鏈夎冻澶熸暟鎹紝鏃犳硶鐢熸垚蹇姤");
+ }
+
+ String currentTotal = formatNumber(current.getTotalPersonFlow());
+ String ringRatioClause = previousDay == null ? "" : "锛岀幆姣�" + ratioPhrase(
+ current.getTotalPersonFlow(), previousDay.getTotalPersonFlow(), EXCEL_TWO_DECIMAL_PERCENT);
+ String paragraph1 = String.format("%d鏈�%d鏃ワ紙鍋囨湡绗�%d澶╋級锛屽叏鐪佸叏绀句細璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺%s涓囦汉娆�%s锛屾瘮2025骞村悓鏈�%s锛屾瘮2024骞村悓鏈�%s銆�",
+ date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal,
+ ringRatioClause,
+ ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(), EXCEL_ONE_DECIMAL_PERCENT),
+ ratioPhrase(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(), EXCEL_ONE_DECIMAL_PERCENT));
+ String paragraph2 = cumulativeParagraph(currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
+
+ File template = resolveTemplateFile();
+ if (!template.exists() || !template.isFile()) {
+ throw new IllegalArgumentException("蹇姤妯℃澘鏂囦欢涓嶅瓨鍦�: " + template.getAbsolutePath());
+ }
+
+ try {
+ byte[] templateBytes;
+ try (InputStream in = new FileInputStream(template)) {
+ templateBytes = readAll(in);
+ }
+ byte[] documentXml = readZipEntry(templateBytes, "word/document.xml");
+ Document document = parseDocument(documentXml);
+ CURRENT_DOCUMENT.set(document);
+ try {
+ replaceParagraph(document, text -> text.startsWith("10鏈�") && text.contains("鍋囨湡绗�"), paragraph1);
+ replaceParagraph(document, text -> text.startsWith("鍋囨湡浠ユ潵"), paragraph2);
+ fillTable(document, current, previousDay, previousCalendarDay, previousYear, twoYearsAgo,
+ currentCumulative, previousYearCumulative, twoYearsAgoCumulative);
+ byte[] replacedXml = serializeDocument(document);
+ return replaceZipEntry(templateBytes, "word/document.xml", replacedXml);
+ } finally {
+ CURRENT_DOCUMENT.remove();
+ }
+ } catch (Exception e) {
+ throw new IllegalArgumentException("蹇姤鐢熸垚澶辫触: " + e.getMessage(), e);
+ }
+ }
+
+ private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay,
+ DailyMetrics previousCalendarDay, DailyMetrics previousYear, DailyMetrics twoYearsAgo,
+ DailyMetrics currentCumulative, DailyMetrics previousYearCumulative,
+ DailyMetrics twoYearsAgoCumulative) {
+ DailyMetrics firstDayRailBase = previousDay == null ? previousCalendarDay : previousDay;
+ DailyMetrics firstDayCivilAviationBase = previousDay == null ? previousCalendarDay : previousDay;
+ Element table = firstElement(document, "tbl");
+ if (table == null) {
+ throw new IllegalArgumentException("蹇姤妯℃澘涓壘涓嶅埌鏁版嵁琛�");
+ }
+ DailyMetrics previousYearCivilCumulative = comparableCumulative(
+ current, previousYear, DailyMetrics::getCivilAviation);
+ DailyMetrics twoYearsAgoCivilCumulative = comparableCumulative(
+ current, twoYearsAgo, DailyMetrics::getCivilAviation);
+ writeTableRow(table, 2, new String[]{
+ number(current.getNonBusinessPersonFlow()),
+ ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(current.getNonBusinessPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getNonBusinessPersonFlow(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ numberCumulative(currentCumulative.getNonBusinessPersonFlow()),
+ ratio(currentCumulative.getNonBusinessPersonFlow(),
+ previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratio(currentCumulative.getNonBusinessPersonFlow(),
+ twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getNonBusinessPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT)
+ });
+ writeTableRow(table, 3, new String[]{
+ number(current.getRoad()),
+ ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad(), EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(current.getRoad(), twoYearsAgo == null ? null : twoYearsAgo.getRoad(), EXCEL_TWO_DECIMAL_PERCENT),
+ number(currentCumulative.getRoad()),
+ ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(currentCumulative.getRoad(), twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoad(),
+ EXCEL_ONE_DECIMAL_PERCENT)
+ });
+ writeTableRow(table, 4, new String[]{
+ number(current.getRoadPersonFlow()),
+ ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(current.getRoadPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getRoadPersonFlow(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ numberCumulative(currentCumulative.getRoadPersonFlow()),
+ ratio(currentCumulative.getRoadPersonFlow(),
+ previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratio(currentCumulative.getRoadPersonFlow(),
+ twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRoadPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT)
+ });
+ writeTableRow(table, 5, transportRow(current.getRail(),
+ firstDayRailBase == null ? null : firstDayRailBase.getRail(),
+ previousYear == null ? null : previousYear.getRail(),
+ twoYearsAgo == null ? null : twoYearsAgo.getRail(),
+ currentCumulative.getRail(),
+ previousYearCumulative == null ? null : previousYearCumulative.getRail(),
+ twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getRail(),
+ EXCEL_ONE_DECIMAL_PERCENT));
+ writeTableRow(table, 6, transportRow(current.getCivilAviation(),
+ firstDayCivilAviationBase == null ? null : firstDayCivilAviationBase.getCivilAviation(),
+ previousYear == null ? null : previousYear.getCivilAviation(),
+ twoYearsAgo == null ? null : twoYearsAgo.getCivilAviation(),
+ currentCumulative.getCivilAviation(),
+ previousYearCivilCumulative == null ? null : previousYearCivilCumulative.getCivilAviation(),
+ twoYearsAgoCivilCumulative == null ? null : twoYearsAgoCivilCumulative.getCivilAviation(),
+ EXCEL_ONE_DECIMAL_PERCENT));
+ writeTableRow(table, 7, transportRow(current.getWater(),
+ previousDay == null ? null : previousDay.getWater(),
+ previousYear == null ? null : previousYear.getWater(),
+ twoYearsAgo == null ? null : twoYearsAgo.getWater(),
+ currentCumulative.getWater(),
+ previousYearCumulative == null ? null : previousYearCumulative.getWater(),
+ twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getWater(),
+ EXCEL_TWO_DECIMAL_PERCENT));
+ writeTableRow(table, 8, new String[]{
+ number(current.getTotalPersonFlow()),
+ ratio(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratio(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(current.getTotalPersonFlow(), twoYearsAgo == null ? null : twoYearsAgo.getTotalPersonFlow(),
+ EXCEL_ONE_DECIMAL_PERCENT),
+ numberCumulative(currentCumulative.getTotalPersonFlow()),
+ ratio(currentCumulative.getTotalPersonFlow(),
+ previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratio(currentCumulative.getTotalPersonFlow(),
+ twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT)
+ });
+ }
+
+ private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear,
+ BigDecimal twoYearsAgo, BigDecimal currentCumulative,
+ BigDecimal previousYearCumulative, BigDecimal twoYearsAgoCumulative,
+ int ringRatioExcelScale) {
+ return new String[]{
+ number(current),
+ ratio(current, previousDay, ringRatioExcelScale),
+ ratio(current, previousYear, EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(current, twoYearsAgo, EXCEL_ONE_DECIMAL_PERCENT),
+ number(currentCumulative),
+ ratio(currentCumulative, previousYearCumulative, EXCEL_ONE_DECIMAL_PERCENT),
+ ratio(currentCumulative, twoYearsAgoCumulative, EXCEL_ONE_DECIMAL_PERCENT)
+ };
+ }
+
+ private void writeTableRow(Element table, int rowIndex, String[] values) {
+ List<Element> rows = childElements(table, "tr");
+ if (rowIndex >= rows.size()) return;
+ Element row = rows.get(rowIndex);
+ List<Element> cells = cellRefs(row);
+ int logicalColumn = 0;
+ for (Element cell : cells) {
+ int span = gridSpan(cell);
+ if (logicalColumn >= 2) {
+ int valueIndex = logicalColumn - 2;
+ if (valueIndex < values.length) {
+ setCellText(cell, values[valueIndex]);
+ }
+ }
+ logicalColumn += span;
+ }
+ }
+
+ private List<Element> cellRefs(Element row) {
+ List<Element> cells = new ArrayList<>();
+ for (Element child : childElements(row, "tc")) cells.add(child);
+ return cells;
+ }
+
+ private int gridSpan(Element cell) {
+ Element tcPr = firstChildElement(cell, "tcPr");
+ if (tcPr == null) return 1;
+ Element span = firstChildElement(tcPr, "gridSpan");
+ if (span == null) return 1;
+ String value = span.getAttributeNS(WORD_NS, "val");
+ try {
+ return Integer.parseInt(value);
+ } catch (Exception e) {
+ return 1;
+ }
+ }
+
+ private void setCellText(Element cell, String text) {
+ Element tcPr = firstChildElement(cell, "tcPr");
+ Element templateParagraph = firstChildElement(cell, "p");
+ Element paragraph = createElement("p");
+ if (templateParagraph != null) {
+ Element pPr = firstChildElement(templateParagraph, "pPr");
+ if (pPr != null) paragraph.appendChild(pPr.cloneNode(true));
+ }
+ Element templateRun = templateParagraph == null ? null : firstChildElement(templateParagraph, "r");
+ Element rPr = templateRun == null ? null : firstChildElement(templateRun, "rPr");
+ Element run = createElement("r");
+ if (rPr != null) run.appendChild(rPr.cloneNode(true));
+ Element t = createElement("t");
+ t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
+ t.setTextContent(text == null ? "" : text);
+ run.appendChild(t);
+ paragraph.appendChild(run);
+
+ List<Node> removals = new ArrayList<>();
+ Node child = cell.getFirstChild();
+ while (child != null) {
+ Node next = child.getNextSibling();
+ if (child instanceof Element && "p".equals(localName(child))) {
+ removals.add(child);
+ }
+ child = next;
+ }
+ for (Node node : removals) cell.removeChild(node);
+ if (tcPr != null && tcPr.getNextSibling() != null) {
+ cell.insertBefore(paragraph, tcPr.getNextSibling());
+ } else if (tcPr != null) {
+ cell.appendChild(paragraph);
+ } else {
+ cell.insertBefore(paragraph, cell.getFirstChild());
+ }
+ }
+
+ private void replaceParagraph(Document document, ParagraphMatcher matcher, String replacement) {
+ NodeList paragraphs = document.getElementsByTagNameNS(WORD_NS, "p");
+ for (int i = 0; i < paragraphs.getLength(); i++) {
+ Element paragraph = (Element) paragraphs.item(i);
+ String text = paragraphText(paragraph);
+ if (matcher.matches(text)) {
+ setParagraphText(paragraph, replacement);
+ return;
+ }
+ }
+ throw new IllegalArgumentException("蹇姤妯℃澘缂哄皯鍙浛鎹㈡钀�: " + replacement);
+ }
+
+ private void setParagraphText(Element paragraph, String text) {
+ Element pPr = firstChildElement(paragraph, "pPr");
+ Element firstRun = firstChildElement(paragraph, "r");
+ Element rPr = firstRun == null ? null : firstChildElement(firstRun, "rPr");
+ List<Node> removals = new ArrayList<>();
+ Node child = paragraph.getFirstChild();
+ while (child != null) {
+ Node next = child.getNextSibling();
+ if (child != pPr) removals.add(child);
+ child = next;
+ }
+ for (Node node : removals) paragraph.removeChild(node);
+
+ Element run = createElement("r");
+ if (rPr != null) run.appendChild(rPr.cloneNode(true));
+ Element t = createElement("t");
+ t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
+ t.setTextContent(text);
+ run.appendChild(t);
+ paragraph.appendChild(run);
+ }
+
+ private String paragraphText(Element paragraph) {
+ StringBuilder builder = new StringBuilder();
+ NodeList texts = paragraph.getElementsByTagNameNS(WORD_NS, "t");
+ for (int i = 0; i < texts.getLength(); i++) {
+ builder.append(texts.item(i).getTextContent());
+ }
+ return builder.toString();
+ }
+
+ private String ratioPhrase(BigDecimal current, BigDecimal base, int excelPercentScale) {
+ BigDecimal percent = bulletinPercent(current, base, excelPercentScale);
+ if (percent == null) return "鏃犲彲姣旀暟鎹�";
+ if (percent.signum() == 0) return "鎸佸钩";
+ return (percent.signum() > 0 ? "澧為暱" : "涓嬮檷") + HolidayNumberUtils.format1(percent.abs()) + "%";
+ }
+
+ private DailyMetrics comparableCumulative(DailyMetrics current, DailyMetrics comparisonYear,
+ Function<DailyMetrics, BigDecimal> selector) {
+ if (current == null || comparisonYear == null) return null;
+ int coveredDay = 0;
+ for (int day = current.getDayIndex(); day >= 1; day--) {
+ DailyMetrics metrics = statService.dailyMetrics(current.getYear(), current.getHolidayType(), day);
+ if (metrics != null && selector.apply(metrics) != null) {
+ coveredDay = day;
+ break;
+ }
+ }
+ if (coveredDay == 0) return null;
+ return statService.cumulativeMetrics(comparisonYear.getYear(), current.getHolidayType(), coveredDay);
+ }
+
+ private String cumulativeParagraph(DailyMetrics currentCumulative,
+ DailyMetrics previousYearCumulative,
+ DailyMetrics twoYearsAgoCumulative) {
+ BigDecimal total = currentCumulative == null ? null : currentCumulative.getTotalPersonFlow();
+ return String.format("鍋囨湡浠ユ潵锛屽叏鐪佸叏绀句細璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺绱%s涓囦汉娆★紝姣�2025骞村悓鏈�%s锛屾瘮2024骞村悓鏈�%s銆�",
+ formatNumber(displayedCumulativeWan(total)),
+ ratioPhrase(total, previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT),
+ ratioPhrase(total, twoYearsAgoCumulative == null ? null : twoYearsAgoCumulative.getTotalPersonFlow(),
+ EXCEL_TWO_DECIMAL_PERCENT));
+ }
+
+ private String ratio(BigDecimal current, BigDecimal base, int excelPercentScale) {
+ BigDecimal percent = bulletinPercent(current, base, excelPercentScale);
+ if (percent == null) return "鈥�";
+ return percent.signum() == 0 ? "0%" : percent.toPlainString() + "%";
+ }
+
+ /**
+ * Mirrors the source Excel percentage cell format, then applies the bulletin's one-decimal rule.
+ * Excel stores full precision but displays 0.0% or 0.00%; reviewers compare against that display.
+ */
+ private BigDecimal bulletinPercent(BigDecimal current, BigDecimal base, int excelPercentScale) {
+ if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return null;
+ BigDecimal ratio = HolidayNumberUtils.divide(current, base);
+ if (ratio == null) return null;
+ BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100));
+ if (percent == null) return null;
+ BigDecimal displayed = percent.setScale(excelPercentScale, RoundingMode.HALF_UP);
+ return displayed.setScale(1, RoundingMode.HALF_UP);
+ }
+
+ private String number(BigDecimal value) {
+ return value == null ? "鈥�" : formatNumber(value);
+ }
+
+ private String numberCumulative(BigDecimal value) {
+ return value == null ? "鈥�" : formatNumber(displayedCumulativeWan(value));
+ }
+
+ /**
+ * Source cumulative cells U5/X5/AA5 are stored in 100-million units and displayed with seven decimals.
+ * Mirror that displayed precision before the bulletin converts the value to 10-thousand units.
+ */
+ private BigDecimal displayedCumulativeWan(BigDecimal cumulativeWan) {
+ if (cumulativeWan == null) return null;
+ BigDecimal yi = cumulativeWan.divide(TEN_THOUSAND, EXCEL_SEVEN_DECIMAL_YI, RoundingMode.HALF_UP);
+ return HolidayNumberUtils.normalize(yi.multiply(TEN_THOUSAND));
+ }
+
+ private String formatNumber(BigDecimal value) {
+ if (value == null) return "";
+ return HolidayNumberUtils.format2(value);
+ }
+
+ private File resolveTemplateFile() {
+ List<File> candidates = new ArrayList<>();
+ File configured = new File(bulletinTemplatePath);
+ candidates.add(configured);
+ if (!configured.isAbsolute()) {
+ candidates.add(new File(System.getProperty("user.dir"), bulletinTemplatePath));
+ candidates.add(new File(System.getProperty("user.dir"), "../" + bulletinTemplatePath));
+ }
+ for (File candidate : candidates) {
+ try {
+ if (candidate.exists() && candidate.isFile()) {
+ return candidate.getCanonicalFile();
+ }
+ } catch (Exception ignored) {
+ // Keep checking.
+ }
+ }
+ return configured;
+ }
+
+ private byte[] readZipEntry(byte[] zipBytes, String entryName) throws Exception {
+ try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
+ ZipEntry entry;
+ while ((entry = zin.getNextEntry()) != null) {
+ if (entryName.equals(entry.getName())) {
+ return readAll(zin);
+ }
+ }
+ }
+ throw new IllegalArgumentException("妯℃澘涓己灏� " + entryName);
+ }
+
+ private byte[] replaceZipEntry(byte[] zipBytes, String entryName, byte[] replacement) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes));
+ ZipOutputStream zout = new ZipOutputStream(out)) {
+ ZipEntry entry;
+ byte[] buffer = new byte[8192];
+ while ((entry = zin.getNextEntry()) != null) {
+ ZipEntry target = new ZipEntry(entry.getName());
+ zout.putNextEntry(target);
+ if (entryName.equals(entry.getName())) {
+ zout.write(replacement);
+ } else {
+ int length;
+ while ((length = zin.read(buffer)) > 0) {
+ zout.write(buffer, 0, length);
+ }
+ }
+ zout.closeEntry();
+ }
+ }
+ return out.toByteArray();
+ }
+
+ private Document parseDocument(byte[] xml) throws Exception {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
+ }
+
+ private byte[] serializeDocument(Document document) throws Exception {
+ Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+ transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ transformer.transform(new DOMSource(document), new StreamResult(out));
+ return out.toByteArray();
+ }
+
+ private Element firstElement(Document document, String localName) {
+ NodeList list = document.getElementsByTagNameNS(WORD_NS, localName);
+ return list.getLength() == 0 ? null : (Element) list.item(0);
+ }
+
+ private Element createElement(String localName) {
+ Document document = CURRENT_DOCUMENT.get();
+ if (document == null) {
+ throw new IllegalStateException("Word document is not initialized");
+ }
+ return document.createElementNS(WORD_NS, "w:" + localName);
+ }
+
+ private List<Element> childElements(Element parent, String localName) {
+ List<Element> result = new ArrayList<>();
+ Node child = parent.getFirstChild();
+ while (child != null) {
+ if (child instanceof Element && localName.equals(localName(child))) {
+ result.add((Element) child);
+ }
+ child = child.getNextSibling();
+ }
+ return result;
+ }
+
+ private Element firstChildElement(Element parent, String localName) {
+ Node child = parent.getFirstChild();
+ while (child != null) {
+ if (child instanceof Element && localName.equals(localName(child))) {
+ return (Element) child;
+ }
+ child = child.getNextSibling();
+ }
+ return null;
+ }
+
+ private String localName(Node node) {
+ return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName();
+ }
+
+ private byte[] readAll(InputStream in) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int length;
+ while ((length = in.read(buffer)) > 0) {
+ out.write(buffer, 0, length);
+ }
+ return out.toByteArray();
+ }
+
+ private interface ParagraphMatcher {
+ boolean matches(String text);
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java
new file mode 100644
index 0000000..455faca
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayConfigService.java
@@ -0,0 +1,211 @@
+package com.trafficaudit.holiday.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
+import com.trafficaudit.holiday.mapper.HolidayCalendarMapper;
+import com.trafficaudit.holiday.mapper.HolidayPassengerCoefficientMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class HolidayConfigService {
+
+ @Resource
+ private HolidayCalendarMapper holidayCalendarMapper;
+
+ @Resource
+ private HolidayPassengerCoefficientMapper coefficientMapper;
+
+ public List<HolidayCalendar> listHolidays(Integer year, String holidayType) {
+ LambdaQueryWrapper<HolidayCalendar> wrapper = new LambdaQueryWrapper<HolidayCalendar>()
+ .eq(year != null, HolidayCalendar::getYear, year)
+ .eq(holidayType != null && !holidayType.trim().isEmpty(), HolidayCalendar::getHolidayType, holidayType)
+ .orderByAsc(HolidayCalendar::getYear)
+ .orderByAsc(HolidayCalendar::getHolidayType)
+ .orderByAsc(HolidayCalendar::getDayIndex);
+ return holidayCalendarMapper.selectList(wrapper);
+ }
+
+ public List<HolidayCalendar> findHolidayDays(int year, String holidayType) {
+ return holidayCalendarMapper.selectList(new LambdaQueryWrapper<HolidayCalendar>()
+ .eq(HolidayCalendar::getYear, year)
+ .eq(HolidayCalendar::getHolidayType, holidayType)
+ .ge(HolidayCalendar::getDayIndex, 1)
+ .orderByAsc(HolidayCalendar::getDayIndex));
+ }
+
+ public HolidayCalendar findPreviousDay(int year, String holidayType) {
+ return holidayCalendarMapper.selectOne(new LambdaQueryWrapper<HolidayCalendar>()
+ .eq(HolidayCalendar::getYear, year)
+ .eq(HolidayCalendar::getHolidayType, holidayType)
+ .eq(HolidayCalendar::getDayIndex, 0)
+ .last("limit 1"));
+ }
+
+ public HolidayCalendar findHolidayByDate(LocalDate date) {
+ return holidayCalendarMapper.selectOne(new LambdaQueryWrapper<HolidayCalendar>()
+ .eq(HolidayCalendar::getHolidayDate, date)
+ .orderByAsc(HolidayCalendar::getDayIndex)
+ .last("limit 1"));
+ }
+
+ @Transactional
+ public HolidayCalendar saveHoliday(Map<String, Object> params) {
+ Long id = longValue(params.get("id"));
+ Integer year = intValue(params.get("year"));
+ String holidayType = stringValue(params.get("holidayType"));
+ LocalDate holidayDate = dateValue(params.get("holidayDate"));
+ Integer dayIndex = intValue(params.get("dayIndex"));
+ if (year == null || year < 2000 || year > 2100) {
+ throw new IllegalArgumentException("骞翠唤涓嶆纭�");
+ }
+ if (!HolidayConstants.isValidHolidayType(holidayType)) {
+ throw new IllegalArgumentException("鑺傚亣鏃ュ垎绫讳笉姝g‘");
+ }
+ if (holidayDate == null || holidayDate.getYear() != year) {
+ throw new IllegalArgumentException("鏃ユ湡蹇呴』灞炰簬鎵�閫夊勾浠�");
+ }
+ if (dayIndex == null || dayIndex < 0) {
+ throw new IllegalArgumentException("鏃ユ湡搴忓彿涓嶈兘灏忎簬 0");
+ }
+ LambdaQueryWrapper<HolidayCalendar> duplicate = new LambdaQueryWrapper<HolidayCalendar>()
+ .eq(HolidayCalendar::getYear, year)
+ .eq(HolidayCalendar::getHolidayType, holidayType)
+ .and(w -> w.eq(HolidayCalendar::getHolidayDate, holidayDate)
+ .or().eq(HolidayCalendar::getDayIndex, dayIndex));
+ if (id != null) {
+ duplicate.ne(HolidayCalendar::getId, id);
+ }
+ if (holidayCalendarMapper.selectCount(duplicate) > 0) {
+ throw new IllegalArgumentException("鍚屼竴鑺傚亣鏃ヤ笅鐨勬棩鏈熸垨绗嚑鏃ュ凡瀛樺湪");
+ }
+
+ HolidayCalendar entity = id == null ? new HolidayCalendar() : holidayCalendarMapper.selectById(id);
+ if (entity == null) {
+ throw new IllegalArgumentException("鑺傚亣鏃ヨ褰曚笉瀛樺湪");
+ }
+ if (id == null) {
+ entity.setCreatedAt(LocalDateTime.now());
+ }
+ entity.setYear(year);
+ entity.setHolidayType(holidayType);
+ entity.setHolidayDate(holidayDate);
+ entity.setDayIndex(dayIndex);
+ entity.setUpdatedAt(LocalDateTime.now());
+ if (id == null) {
+ holidayCalendarMapper.insert(entity);
+ } else {
+ holidayCalendarMapper.updateById(entity);
+ }
+ return entity;
+ }
+
+ @Transactional
+ public void deleteHoliday(Long id) {
+ if (id == null) {
+ throw new IllegalArgumentException("缂哄皯璁板綍 id");
+ }
+ holidayCalendarMapper.deleteById(id);
+ }
+
+ public List<HolidayPassengerCoefficient> listCoefficients(Integer year) {
+ return coefficientMapper.selectList(new LambdaQueryWrapper<HolidayPassengerCoefficient>()
+ .eq(year != null, HolidayPassengerCoefficient::getYear, year)
+ .orderByAsc(HolidayPassengerCoefficient::getYear)
+ .orderByAsc(HolidayPassengerCoefficient::getDateCategory));
+ }
+
+ public BigDecimal coefficientOf(int year, String dateCategory) {
+ HolidayPassengerCoefficient row = coefficientMapper.selectOne(
+ new LambdaQueryWrapper<HolidayPassengerCoefficient>()
+ .eq(HolidayPassengerCoefficient::getYear, year)
+ .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory)
+ .last("limit 1"));
+ return row == null ? null : row.getCoefficient();
+ }
+
+ @Transactional
+ public HolidayPassengerCoefficient saveCoefficient(Map<String, Object> params) {
+ Long id = longValue(params.get("id"));
+ Integer year = intValue(params.get("year"));
+ String dateCategory = stringValue(params.get("dateCategory"));
+ BigDecimal coefficient = decimalValue(params.get("coefficient"));
+ if (year == null || year < 2000 || year > 2100) {
+ throw new IllegalArgumentException("骞翠唤涓嶆纭�");
+ }
+ if (!HolidayConstants.isValidDateCategory(dateCategory)) {
+ throw new IllegalArgumentException("鏃ユ湡鍒嗙被涓嶆纭�");
+ }
+ if (coefficient == null || coefficient.compareTo(BigDecimal.ZERO) <= 0) {
+ throw new IllegalArgumentException("瀹㈣繍绯绘暟蹇呴』澶т簬 0");
+ }
+ LambdaQueryWrapper<HolidayPassengerCoefficient> duplicate = new LambdaQueryWrapper<HolidayPassengerCoefficient>()
+ .eq(HolidayPassengerCoefficient::getYear, year)
+ .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory);
+ if (id != null) {
+ duplicate.ne(HolidayPassengerCoefficient::getId, id);
+ }
+ if (coefficientMapper.selectCount(duplicate) > 0) {
+ throw new IllegalArgumentException("璇ュ勾浠藉拰鏃ユ湡鍒嗙被鐨勫杩愮郴鏁板凡瀛樺湪");
+ }
+
+ HolidayPassengerCoefficient entity = id == null ? new HolidayPassengerCoefficient() : coefficientMapper.selectById(id);
+ if (entity == null) {
+ throw new IllegalArgumentException("瀹㈣繍绯绘暟璁板綍涓嶅瓨鍦�");
+ }
+ if (id == null) {
+ entity.setCreatedAt(LocalDateTime.now());
+ }
+ entity.setYear(year);
+ entity.setDateCategory(dateCategory);
+ entity.setCoefficient(coefficient);
+ entity.setUpdatedAt(LocalDateTime.now());
+ if (id == null) {
+ coefficientMapper.insert(entity);
+ } else {
+ coefficientMapper.updateById(entity);
+ }
+ return entity;
+ }
+
+ @Transactional
+ public void deleteCoefficient(Long id) {
+ if (id == null) {
+ throw new IllegalArgumentException("缂哄皯璁板綍 id");
+ }
+ coefficientMapper.deleteById(id);
+ }
+
+ private Long longValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return Long.valueOf(value.toString());
+ }
+
+ private Integer intValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return Integer.valueOf(value.toString());
+ }
+
+ private BigDecimal decimalValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return com.trafficaudit.holiday.HolidayNumberUtils.decimalValue(value);
+ }
+
+ private String stringValue(Object value) {
+ return value == null ? null : value.toString().trim();
+ }
+
+ private LocalDate dateValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return LocalDate.parse(value.toString());
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java
new file mode 100644
index 0000000..fcec0f8
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayReferenceDataInitializer.java
@@ -0,0 +1,21 @@
+package com.trafficaudit.holiday.service;
+
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+@Component
+@Order(100)
+public class HolidayReferenceDataInitializer implements ApplicationRunner {
+
+ @Resource
+ private HolidayWorkbookService workbookService;
+
+ @Override
+ public void run(ApplicationArguments args) {
+ workbookService.seedReferenceDataIfNeeded();
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
new file mode 100644
index 0000000..2309466
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayStatService.java
@@ -0,0 +1,369 @@
+package com.trafficaudit.holiday.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.trafficaudit.dataimport.entity.ImportBatch;
+import com.trafficaudit.dataimport.mapper.ImportBatchMapper;
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
+import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
+import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
+import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
+import lombok.Data;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class HolidayStatService {
+
+ @Resource
+ private HolidayConfigService configService;
+ @Resource
+ private HolidayWorkbookService workbookService;
+ @Resource
+ private HolidayPassengerFlowMapper passengerFlowMapper;
+ @Resource
+ private HolidayVehicleFlowMapper vehicleFlowMapper;
+ @Resource
+ private HolidayVehicleAdjustmentMapper vehicleAdjustmentMapper;
+ @Resource
+ private ImportBatchMapper importBatchMapper;
+
+ public Map<String, Object> overview(int year, String holidayType) {
+ List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("year", year);
+ result.put("holidayType", holidayType);
+ result.put("holidayTypeLabel", HolidayConstants.HOLIDAY_TYPE_LABELS.get(holidayType));
+ result.put("days", calendar);
+ result.put("rows", buildOverviewRows(year, holidayType, calendar));
+ result.put("templateFileName", workbookService.referenceFileName());
+ return result;
+ }
+
+ @Transactional
+ public Map<String, Object> importWorkbook(MultipartFile file, int year, String holidayType, boolean allowHistoricalOverwrite) {
+ HolidayWorkbookService.ImportSummary summary = workbookService.importWorkbook(file, year, holidayType, allowHistoricalOverwrite);
+ if (summary.isConfirmationRequired()) {
+ return importResult(summary);
+ }
+ ImportBatch batch = new ImportBatch();
+ batch.setFileName(file == null ? null : file.getOriginalFilename());
+ batch.setImportType("HOLIDAY_FLOW");
+ batch.setReportPeriod(year + "-" + holidayType);
+ batch.setTotalRows(summary.getSuccessRows() + summary.getFailRows());
+ batch.setSuccessRows(summary.getSuccessRows());
+ batch.setFailRows(summary.getFailRows());
+ batch.setErrorDetail(joinErrors(summary.getErrors()));
+ batch.setCreatedAt(LocalDateTime.now());
+ importBatchMapper.insert(batch);
+
+ return importResult(summary);
+ }
+
+ private Map<String, Object> importResult(HolidayWorkbookService.ImportSummary summary) {
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("passengerRows", summary.getPassengerRows());
+ result.put("vehicleRows", summary.getVehicleRows());
+ result.put("skippedRows", summary.getSkippedRows());
+ result.put("successRows", summary.getSuccessRows());
+ result.put("failRows", summary.getFailRows());
+ result.put("errors", summary.getErrors());
+ result.put("confirmationRequired", summary.isConfirmationRequired());
+ result.put("historicalChanges", summary.getHistoricalChanges());
+ return result;
+ }
+
+ public byte[] exportWorkbook(int year, String holidayType) {
+ return workbookService.exportWorkbook(year, holidayType);
+ }
+
+ public List<Map<String, Object>> buildOverviewRows(int year, String holidayType, List<HolidayCalendar> calendar) {
+ Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
+ Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
+ Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
+ BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
+ Map<Integer, HolidayCalendar> calendarByDay = new LinkedHashMap<>();
+ for (HolidayCalendar day : calendar) {
+ if (day.getDayIndex() != null) calendarByDay.put(day.getDayIndex(), day);
+ }
+ List<Map<String, Object>> rows = new ArrayList<>();
+ for (HolidayCalendar day : calendar) {
+ DailyMetrics current = buildDailyMetrics(year, holidayType, day.getDayIndex(), day.getHolidayDate(),
+ passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
+ DailyMetrics ringRatioBase = ringRatioBaseFromMaps(year, holidayType, day.getDayIndex(), calendarByDay,
+ passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
+ BigDecimal nationalProvincialRingRatio = ratio(current.getNationalProvincial(),
+ ringRatioBase == null ? null : ringRatioBase.getNationalProvincial());
+ Map<String, Object> row = new LinkedHashMap<>();
+ row.put("dayIndex", day.getDayIndex());
+ row.put("date", day.getHolidayDate());
+ row.put("rail", current.getRail());
+ row.put("civilAviation", current.getCivilAviation());
+ row.put("road", current.getRoad());
+ row.put("water", current.getWater());
+ row.put("expressway", current.getExpressway());
+ row.put("nationalProvincial", current.getNationalProvincial());
+ row.put("comparisonBase", current.getComparisonBase());
+ row.put("nationalProvincialRingRatio", nationalProvincialRingRatio);
+ row.put("coefficient", current.getCoefficient());
+ row.put("nonBusinessPersonFlow", current.getNonBusinessPersonFlow());
+ row.put("totalPersonFlow", current.getTotalPersonFlow());
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ public DailyMetrics dailyMetrics(int year, String holidayType, int dayIndex) {
+ List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
+ HolidayCalendar target = null;
+ for (HolidayCalendar day : calendar) {
+ if (day.getDayIndex() != null && day.getDayIndex() == dayIndex) {
+ target = day;
+ break;
+ }
+ }
+ if (target == null) return null;
+ return dailyMetrics(year, holidayType, dayIndex, target.getHolidayDate());
+ }
+
+ public DailyMetrics dailyMetricsByDate(LocalDate date) {
+ if (date == null) return null;
+ return dailyMetrics(date.getYear(), null, 0, date);
+ }
+
+ private DailyMetrics dailyMetrics(int year, String holidayType, int dayIndex, LocalDate date) {
+ Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
+ Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
+ Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
+ BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
+ return buildDailyMetrics(year, holidayType, dayIndex, date, passengerByDate, vehicleByDate,
+ comparisonBaseByDate, coefficient);
+ }
+
+ public DailyMetrics ringRatioBaseMetrics(int year, String holidayType, int dayIndex) {
+ if (dayIndex <= 1) return null;
+ List<HolidayCalendar> calendar = configService.findHolidayDays(year, holidayType);
+ Map<Integer, HolidayCalendar> calendarByDay = new LinkedHashMap<>();
+ for (HolidayCalendar day : calendar) {
+ if (day.getDayIndex() != null) calendarByDay.put(day.getDayIndex(), day);
+ }
+ Map<LocalDate, Map<String, BigDecimal>> passengerByDate = loadPassengerByDate(year);
+ Map<LocalDate, Map<String, BigDecimal>> vehicleByDate = loadVehicleByDate(year);
+ Map<LocalDate, BigDecimal> comparisonBaseByDate = loadComparisonBaseByDate(year);
+ BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
+ return ringRatioBaseFromMaps(year, holidayType, dayIndex, calendarByDay, passengerByDate,
+ vehicleByDate, comparisonBaseByDate, coefficient);
+ }
+
+ private DailyMetrics buildDailyMetrics(int year, String holidayType, int dayIndex, LocalDate date,
+ Map<LocalDate, Map<String, BigDecimal>> passengerByDate,
+ Map<LocalDate, Map<String, BigDecimal>> vehicleByDate,
+ Map<LocalDate, BigDecimal> comparisonBaseByDate,
+ BigDecimal coefficient) {
+ Map<String, BigDecimal> passenger = passengerByDate.getOrDefault(date, Collections.emptyMap());
+ Map<String, BigDecimal> vehicle = vehicleByDate.getOrDefault(date, Collections.emptyMap());
+ BigDecimal expressway = vehicle.get(HolidayConstants.ROAD_EXPRESSWAY);
+ BigDecimal nationalProvincial = vehicle.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL);
+ BigDecimal comparisonBase = comparisonBaseByDate.get(date);
+ BigDecimal nonBusiness = nonBusinessPersonFlow(expressway, nationalProvincial, coefficient);
+
+ DailyMetrics metrics = new DailyMetrics();
+ metrics.setYear(year);
+ metrics.setHolidayType(holidayType);
+ metrics.setDayIndex(dayIndex);
+ metrics.setDate(date);
+ metrics.setRail(passenger.get(HolidayConstants.MODE_RAIL));
+ metrics.setCivilAviation(passenger.get(HolidayConstants.MODE_CIVIL_AVIATION));
+ metrics.setRoad(passenger.get(HolidayConstants.MODE_ROAD));
+ metrics.setWater(passenger.get(HolidayConstants.MODE_WATER));
+ metrics.setExpressway(expressway);
+ metrics.setNationalProvincial(nationalProvincial);
+ metrics.setComparisonBase(comparisonBase);
+ metrics.setCoefficient(coefficient);
+ metrics.setNonBusinessPersonFlow(nonBusiness);
+ recalculateDerived(metrics);
+ return metrics;
+ }
+
+ private DailyMetrics ringRatioBaseFromMaps(int year, String holidayType, int dayIndex,
+ Map<Integer, HolidayCalendar> calendarByDay,
+ Map<LocalDate, Map<String, BigDecimal>> passengerByDate,
+ Map<LocalDate, Map<String, BigDecimal>> vehicleByDate,
+ Map<LocalDate, BigDecimal> comparisonBaseByDate,
+ BigDecimal coefficient) {
+ if (dayIndex <= 1) return null;
+ HolidayCalendar previous = calendarByDay.get(dayIndex - 1);
+ if (previous == null || previous.getHolidayDate() == null) return null;
+ DailyMetrics base = buildDailyMetrics(year, holidayType, previous.getDayIndex(), previous.getHolidayDate(),
+ passengerByDate, vehicleByDate, comparisonBaseByDate, coefficient);
+ BigDecimal comparisonBase = comparisonBaseByDate.get(previous.getHolidayDate());
+ if (comparisonBase != null) {
+ base.setNationalProvincial(comparisonBase);
+ recalculateDerived(base);
+ }
+ return base;
+ }
+
+ private void recalculateDerived(DailyMetrics metrics) {
+ BigDecimal nonBusiness = nonBusinessPersonFlow(metrics.getExpressway(), metrics.getNationalProvincial(),
+ metrics.getCoefficient());
+ metrics.setNonBusinessPersonFlow(nonBusiness);
+ metrics.setRoadPersonFlow(sumNullable(metrics.getRoad(), nonBusiness));
+ metrics.setTotalPersonFlow(totalPersonFlow(nonBusiness, metrics.getRoad(), metrics.getRail(),
+ metrics.getCivilAviation(), metrics.getWater()));
+ }
+
+ private BigDecimal nonBusinessPersonFlow(BigDecimal expressway, BigDecimal nationalProvincial,
+ BigDecimal coefficient) {
+ if (coefficient == null || (expressway == null && nationalProvincial == null)) return null;
+ return HolidayNumberUtils.multiply(
+ HolidayNumberUtils.sumNullable(expressway, nationalProvincial), coefficient);
+ }
+
+ public DailyMetrics cumulativeMetrics(int year, String holidayType, int dayIndex) {
+ DailyMetrics cumulative = new DailyMetrics();
+ cumulative.setYear(year);
+ cumulative.setHolidayType(holidayType);
+ cumulative.setDayIndex(dayIndex);
+ for (int day = 1; day <= dayIndex; day++) {
+ DailyMetrics item = dailyMetrics(year, holidayType, day);
+ if (item == null) continue;
+ cumulative.setRail(sumNullable(cumulative.getRail(), item.getRail()));
+ cumulative.setCivilAviation(sumNullable(cumulative.getCivilAviation(), item.getCivilAviation()));
+ cumulative.setRoad(sumNullable(cumulative.getRoad(), item.getRoad()));
+ cumulative.setWater(sumNullable(cumulative.getWater(), item.getWater()));
+ cumulative.setExpressway(sumNullable(cumulative.getExpressway(), item.getExpressway()));
+ cumulative.setNationalProvincial(sumNullable(cumulative.getNationalProvincial(), item.getNationalProvincial()));
+ cumulative.setNonBusinessPersonFlow(sumNullable(cumulative.getNonBusinessPersonFlow(), item.getNonBusinessPersonFlow()));
+ cumulative.setRoadPersonFlow(sumNullable(cumulative.getRoadPersonFlow(), item.getRoadPersonFlow()));
+ cumulative.setTotalPersonFlow(sumNullable(cumulative.getTotalPersonFlow(), item.getTotalPersonFlow()));
+ }
+ return cumulative;
+ }
+
+
+ public DailyMetrics findMetricsByDate(LocalDate date) {
+ HolidayCalendar calendar = configService.findHolidayByDate(date);
+ if (calendar == null) return null;
+ return dailyMetrics(calendar.getYear(), calendar.getHolidayType(), calendar.getDayIndex());
+ }
+
+ private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
+ Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
+ List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
+ for (HolidayPassengerFlow row : rows) {
+ result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
+ .put(row.getTransportMode(), row.getPassengerCount());
+ }
+ return result;
+ }
+
+ private Map<LocalDate, Map<String, BigDecimal>> loadVehicleByDate(int year) {
+ Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
+ List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>().eq(HolidayVehicleFlow::getYear, year));
+ for (HolidayVehicleFlow row : rows) {
+ result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
+ .put(row.getRoadCategory(), row.getVehicleCount());
+ }
+ Map<LocalDate, BigDecimal> adjustments = loadNationalProvincialAdjustmentsByDate(year);
+ for (Map.Entry<LocalDate, BigDecimal> entry : adjustments.entrySet()) {
+ result.computeIfAbsent(entry.getKey(), key -> new LinkedHashMap<>())
+ .put(HolidayConstants.ROAD_NATIONAL_PROVINCIAL, entry.getValue());
+ }
+ return result;
+ }
+
+ private Map<LocalDate, BigDecimal> loadNationalProvincialAdjustmentsByDate(int year) {
+ Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
+ List<HolidayVehicleAdjustment> rows = vehicleAdjustmentMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleAdjustment>().eq(HolidayVehicleAdjustment::getYear, year));
+ for (HolidayVehicleAdjustment row : rows) {
+ if (row.getFlowDate() != null && row.getAdjustedValue() != null) {
+ result.put(row.getFlowDate(), row.getAdjustedValue());
+ }
+ }
+ return result;
+ }
+
+ private Map<LocalDate, BigDecimal> loadComparisonBaseByDate(int year) {
+ Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
+ List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)
+ .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
+ for (HolidayVehicleFlow row : rows) {
+ if (row.getComparisonBase() != null) result.put(row.getFlowDate(), row.getComparisonBase());
+ }
+ return result;
+ }
+
+ private BigDecimal totalPersonFlow(BigDecimal nonBusinessPersonFlow, BigDecimal road, BigDecimal rail,
+ BigDecimal civilAviation, BigDecimal water) {
+ BigDecimal total = nonBusinessPersonFlow;
+ total = sumNullable(total, road);
+ total = sumNullable(total, rail);
+ total = sumNullable(total, civilAviation);
+ total = sumNullable(total, water);
+ return total;
+ }
+
+
+ private BigDecimal ratio(BigDecimal current, BigDecimal base) {
+ BigDecimal quotient = HolidayNumberUtils.divide(current, base);
+ return quotient == null ? null : HolidayNumberUtils.normalize(quotient.subtract(BigDecimal.ONE));
+ }
+
+ private BigDecimal sumNullable(BigDecimal left, BigDecimal right) {
+ return HolidayNumberUtils.sumNullable(left, right);
+ }
+
+ private String joinErrors(List<String> errors) {
+ if (errors == null || errors.isEmpty()) return null;
+ StringBuilder builder = new StringBuilder();
+ int limit = Math.min(errors.size(), 200);
+ for (int i = 0; i < limit; i++) {
+ if (i > 0) builder.append('\n');
+ builder.append(errors.get(i));
+ }
+ if (errors.size() > limit) {
+ builder.append("\n... ").append(errors.size() - limit).append(" more");
+ }
+ return builder.toString();
+ }
+
+ @Data
+ public static class DailyMetrics {
+ private int year;
+ private String holidayType;
+ private int dayIndex;
+ private LocalDate date;
+ private BigDecimal rail;
+ private BigDecimal civilAviation;
+ private BigDecimal road;
+ private BigDecimal water;
+ private BigDecimal expressway;
+ private BigDecimal nationalProvincial;
+ private BigDecimal comparisonBase;
+ private BigDecimal coefficient;
+ private BigDecimal nonBusinessPersonFlow;
+ private BigDecimal roadPersonFlow;
+ private BigDecimal totalPersonFlow;
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
new file mode 100644
index 0000000..47bd7ca
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendChartService.java
@@ -0,0 +1,1186 @@
+package com.trafficaudit.holiday.service;
+
+import lombok.Data;
+import org.jfree.chart.ChartFactory;
+import org.jfree.chart.JFreeChart;
+import org.jfree.chart.StandardChartTheme;
+import org.jfree.chart.axis.AxisLocation;
+import org.jfree.chart.axis.CategoryAxis;
+import org.jfree.chart.axis.NumberAxis;
+import org.jfree.chart.axis.SymbolAxis;
+import org.jfree.chart.axis.ValueAxis;
+import org.jfree.chart.labels.StandardPieSectionLabelGenerator;
+import org.jfree.chart.plot.CategoryPlot;
+import org.jfree.chart.plot.PiePlot;
+import org.jfree.chart.plot.Plot;
+import org.jfree.chart.plot.PieLabelRecord;
+import org.jfree.chart.plot.PiePlotState;
+import org.jfree.chart.text.TextFragment;
+import org.jfree.chart.plot.PlotOrientation;
+import org.jfree.chart.plot.XYPlot;
+import org.jfree.chart.renderer.category.StackedBarRenderer;
+import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
+import org.jfree.chart.ui.RectangleEdge;
+import org.jfree.chart.ui.RectangleInsets;
+import org.jfree.chart.ui.TextAnchor;
+import org.jfree.data.DefaultKeyedValues;
+import org.jfree.data.KeyedValues;
+import org.jfree.data.category.CategoryDataset;
+import org.jfree.data.category.DefaultCategoryDataset;
+import org.jfree.data.general.DefaultPieDataset;
+import org.jfree.data.xy.XYDataset;
+import org.jfree.data.xy.XYSeries;
+import org.jfree.data.xy.XYSeriesCollection;
+import org.springframework.stereotype.Service;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.Paint;
+import java.awt.Shape;
+import java.awt.image.BufferedImage;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class HolidayTrendChartService {
+
+ private static final Font BASE_FONT = new Font("Microsoft YaHei", Font.PLAIN, 20);
+ private static final Font TICK_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
+ private static final Font LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
+ private static final Font ITEM_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 20f);
+ private static final Font LEGEND_FONT = BASE_FONT.deriveFont(Font.BOLD, 22f);
+ private static final Font STACK_LABEL_FONT = BASE_FONT.deriveFont(Font.BOLD, 17f);
+
+ private static final Color AXIS_COLOR = Color.BLACK;
+ private static final Color MUTED_TEXT_COLOR = new Color(102, 102, 102);
+ private static final double UNIT_LABEL_BAND = 58.0;
+ private static final double RIGHT_LABEL_BAND = 74.0;
+ private static final double ITEM_LABEL_GAP = 10.0;
+ private static final float HALO_OUTLINE_WIDTH = 4.0f;
+ private static final int PERCENT_ITEM_LABEL_DECIMALS = 1;
+ private static final double LINE_CHART_HEIGHT_SCALE = 1.20;
+ private static final Color ITEM_LABEL_COLOR = new Color(55, 55, 55);
+ private static final Color[] SERIES_COLORS = {
+ new Color(192, 80, 77),
+ new Color(79, 129, 189),
+ new Color(155, 187, 89),
+ new Color(128, 100, 162),
+ new Color(75, 172, 198)
+ };
+
+ static {
+ System.setProperty("java.awt.headless", "true");
+ }
+
+ public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis) {
+ return lineChart(yAxisLabel, series, percentAxis, 1100, 590);
+ }
+
+ public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
+ int width, int height) {
+ return lineChart(yAxisLabel, series, percentAxis, 2, width, height);
+ }
+
+ public byte[] lineChart(String yAxisLabel, List<ChartSeries> series, boolean percentAxis,
+ int valueDecimals, int width, int height) {
+ XYSeriesCollection dataset = new XYSeriesCollection();
+ for (int seriesIndex = 0; seriesIndex < series.size(); seriesIndex++) {
+ ChartSeries item = series.get(seriesIndex);
+ XYSeries xySeries = new XYSeries(item.getName(), false, false);
+ for (int i = 0; i < item.getValues().size(); i++) {
+ BigDecimal value = item.getValues().get(i);
+ if (value != null) {
+ xySeries.add(i, displayValue(value, percentAxis, valueDecimals).doubleValue());
+ }
+ }
+ dataset.addSeries(xySeries);
+ }
+ JFreeChart chart = ChartFactory.createXYLineChart(
+ null, null, null, dataset, PlotOrientation.VERTICAL, true, false, false);
+ applyTheme(chart);
+ chart.setBackgroundPaint(Color.WHITE);
+ chart.setBorderVisible(false);
+ chart.getLegend().setPosition(RectangleEdge.BOTTOM);
+ chart.getLegend().setItemFont(LEGEND_FONT);
+ chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
+ chart.getLegend().setBackgroundPaint(Color.WHITE);
+ chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
+
+ XYPlot plot = chart.getXYPlot();
+ stylePlot(plot);
+ plot.setDomainGridlinesVisible(false);
+ plot.setRangeGridlinesVisible(false);
+ plot.setDomainTickBandPaint(null);
+ plot.setRangeTickBandPaint(null);
+ SymbolAxis domainAxis = new SymbolAxis(null, DAY_LABELS.toArray(new String[0]));
+ domainAxis.setGridBandPaint(new Color(0, 0, 0, 0));
+ domainAxis.setGridBandAlternatePaint(new Color(0, 0, 0, 0));
+ domainAxis.setLowerMargin(0.04);
+ domainAxis.setUpperMargin(0.04);
+ plot.setDomainAxis(domainAxis);
+ styleDomainAxis(plot.getDomainAxis());
+
+ NumberAxis rangeAxis = new NumberAxis();
+ rangeAxis.setAutoRangeIncludesZero(false);
+ rangeAxis.setLowerMargin(0.04);
+ rangeAxis.setUpperMargin(0.07);
+ rangeAxis.setTickLabelFont(TICK_FONT);
+ rangeAxis.setLabelFont(LABEL_FONT);
+ rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
+ rangeAxis.setLabelPaint(AXIS_COLOR);
+ rangeAxis.setAxisLinePaint(AXIS_COLOR);
+ rangeAxis.setTickMarkPaint(AXIS_COLOR);
+ if (percentAxis) {
+ rangeAxis.setRange(0.0, 1.0);
+ rangeAxis.setAutoRangeIncludesZero(true);
+ }
+ NumberFormat valueFormat = percentAxis
+ ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
+ valueFormat.setMaximumFractionDigits(percentAxis ? 0 : valueDecimals);
+ rangeAxis.setNumberFormatOverride(valueFormat);
+ plot.setRangeAxis(rangeAxis);
+ plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
+
+ XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(true, true);
+ renderer.setDefaultShapesVisible(true);
+ renderer.setDefaultShapesFilled(true);
+ renderer.setDefaultLinesVisible(true);
+ renderer.setDefaultItemLabelsVisible(false);
+ for (int i = 0; i < series.size(); i++) {
+ renderer.setSeriesPaint(i, seriesColor(i));
+ renderer.setSeriesFillPaint(i, seriesColor(i));
+ renderer.setSeriesShape(i, seriesShape(i));
+ renderer.setSeriesStroke(i, new BasicStroke(6.0f,
+ BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ }
+ plot.setRenderer(renderer);
+ return render(chart, width, scaleLineChartHeight(height),
+ yAxisLabel, true, percentAxis, valueDecimals);
+ }
+
+ public byte[] pieChart(String valueSuffix, List<ChartCategory> categories,
+ int width, int height) {
+ DefaultPieDataset<String> dataset = new DefaultPieDataset<>();
+ for (ChartCategory category : categories) {
+ dataset.setValue(category.getName(), category.getValue());
+ }
+ PiePlot<String> piePlot = new WaterRightPiePlot<>(dataset);
+ JFreeChart chart = new JFreeChart(null, JFreeChart.DEFAULT_TITLE_FONT, piePlot, true);
+ applyTheme(chart);
+ chart.setBackgroundPaint(Color.WHITE);
+ chart.setBorderVisible(false);
+ chart.getLegend().setPosition(RectangleEdge.BOTTOM);
+ chart.getLegend().setItemFont(LEGEND_FONT);
+ chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
+ chart.getLegend().setBackgroundPaint(Color.WHITE);
+ chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
+
+ PiePlot plot = piePlot;
+ plot.setBackgroundPaint(Color.WHITE);
+ plot.setOutlineVisible(false);
+ plot.setShadowPaint(null);
+ plot.setLabelFont(ITEM_LABEL_FONT);
+ plot.setLabelPaint(ITEM_LABEL_COLOR);
+ plot.setLabelBackgroundPaint(Color.WHITE);
+ plot.setLabelOutlinePaint(new Color(160, 160, 160));
+ plot.setLabelShadowPaint(null);
+ plot.setLabelLinkPaint(Color.BLACK);
+ plot.setLabelLinkStroke(new BasicStroke(2.0f));
+ plot.setLabelGap(0.02);
+ plot.setMaximumLabelWidth(0.24);
+ plot.setSimpleLabels(false);
+ plot.setInteriorGap(0.06);
+ plot.setStartAngle(90.0);
+ for (ChartCategory category : categories) {
+ plot.setSectionOutlinePaint(category.getName(), Color.WHITE);
+ plot.setSectionOutlineStroke(category.getName(), new BasicStroke(1.2f));
+ }
+ plot.setLabelGenerator(new StandardPieSectionLabelGenerator(
+ "{0} {2}", new DecimalFormat("0"), new DecimalFormat("0.00%")));
+ int index = 0;
+ for (ChartCategory category : categories) {
+ plot.setSectionPaint(category.getName(), seriesColor(index++));
+ }
+ return render(chart, width, height, valueSuffix, false, false, 2);
+ }
+
+ public byte[] stackedBarChart(String yAxisLabel, List<String> categories,
+ List<ChartSeries> series, boolean percentAxis,
+ int width, int height) {
+ DefaultCategoryDataset dataset = new DefaultCategoryDataset();
+ for (ChartSeries item : series) {
+ for (int i = 0; i < categories.size() && i < item.getValues().size(); i++) {
+ BigDecimal value = item.getValues().get(i);
+ if (value != null) {
+ dataset.addValue(value, item.getName(), categories.get(i));
+ }
+ }
+ }
+ JFreeChart chart = ChartFactory.createStackedBarChart(
+ null, null, null, dataset, PlotOrientation.HORIZONTAL, true, false, false);
+ applyTheme(chart);
+ chart.setBackgroundPaint(Color.WHITE);
+ chart.setBorderVisible(false);
+ chart.getLegend().setPosition(RectangleEdge.BOTTOM);
+ chart.getLegend().setItemFont(LEGEND_FONT);
+ chart.getLegend().setFrame(new org.jfree.chart.block.BlockBorder(0.0, 0.0, 0.0, 0.0));
+ chart.getLegend().setBackgroundPaint(Color.WHITE);
+ chart.getLegend().setItemPaint(MUTED_TEXT_COLOR);
+
+ CategoryPlot plot = chart.getCategoryPlot();
+ plot.setBackgroundPaint(Color.WHITE);
+ plot.setDomainGridlinesVisible(false);
+ plot.setRangeGridlinesVisible(false);
+ plot.setOutlineVisible(false);
+ plot.setAxisOffset(new RectangleInsets(2.0, 2.0, 2.0, 2.0));
+ // Horizontal stacked bars: value axis along the bottom, no vertical axis line.
+ plot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_LEFT);
+ CategoryAxis domainAxis = plot.getDomainAxis();
+ domainAxis.setAxisLineVisible(false);
+ domainAxis.setTickMarksVisible(false);
+ styleDomainAxis(domainAxis);
+ if (series.size() >= 3) {
+ RectangleInsets insets = plot.getInsets();
+ plot.setInsets(new RectangleInsets(insets.getTop(), insets.getLeft(),
+ insets.getBottom(), insets.getRight() + RIGHT_LABEL_BAND));
+ }
+ NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
+ rangeAxis.setTickLabelFont(TICK_FONT);
+ rangeAxis.setLabelFont(LABEL_FONT);
+ rangeAxis.setTickLabelPaint(MUTED_TEXT_COLOR);
+ rangeAxis.setLabelPaint(AXIS_COLOR);
+ rangeAxis.setAxisLinePaint(AXIS_COLOR);
+ rangeAxis.setTickMarkPaint(AXIS_COLOR);
+ rangeAxis.setAutoRangeIncludesZero(true);
+ rangeAxis.setLowerMargin(0.05);
+ rangeAxis.setUpperMargin(0.12);
+ if (percentAxis) {
+ rangeAxis.setRange(0.0, 1.0);
+ }
+ NumberFormat valueFormat = percentAxis
+ ? NumberFormat.getPercentInstance() : new DecimalFormat("#,##0.##");
+ valueFormat.setMaximumFractionDigits(percentAxis ? 0 : 2);
+ rangeAxis.setNumberFormatOverride(valueFormat);
+
+ StackedBarRenderer renderer = new StackedBarRenderer();
+ renderer.setDefaultItemLabelsVisible(false);
+ renderer.setBarPainter(new org.jfree.chart.renderer.category.StandardBarPainter());
+ renderer.setShadowVisible(false);
+ renderer.setItemMargin(0.05);
+ for (int i = 0; i < series.size(); i++) {
+ renderer.setSeriesPaint(i, seriesColor(i));
+ }
+ plot.setRenderer(renderer);
+ return render(chart, width, height, yAxisLabel, true, percentAxis, 2);
+ }
+
+ private static class WaterRightPiePlot<K extends Comparable<K>> extends PiePlot<K> {
+ private DefaultKeyedValues<K> waterKeys;
+
+ private WaterRightPiePlot(org.jfree.data.general.PieDataset<K> dataset) {
+ super(dataset);
+ }
+
+ @Override
+ protected void drawLeftLabels(KeyedValues<K> leftKeys, Graphics2D g2,
+ Rectangle2D plotArea, Rectangle2D linkArea,
+ float maxLabelWidth, PiePlotState state) {
+ DefaultKeyedValues<K> filtered = new DefaultKeyedValues<>();
+ waterKeys = new DefaultKeyedValues<>();
+ for (int i = 0; i < leftKeys.getItemCount(); i++) {
+ K key = leftKeys.getKey(i);
+ if ("姘磋矾".equals(String.valueOf(key))) {
+ waterKeys.addValue(key, leftKeys.getValue(i));
+ } else {
+ filtered.addValue(key, leftKeys.getValue(i));
+ }
+ }
+ super.drawLeftLabels(filtered, g2, plotArea, linkArea, maxLabelWidth, state);
+ }
+
+ @Override
+ protected void drawRightLabels(KeyedValues<K> rightKeys, Graphics2D g2,
+ Rectangle2D plotArea, Rectangle2D linkArea,
+ float maxLabelWidth, PiePlotState state) {
+ DefaultKeyedValues<K> merged = new DefaultKeyedValues<>();
+ if (waterKeys != null) {
+ for (int i = 0; i < waterKeys.getItemCount(); i++) {
+ merged.addValue(waterKeys.getKey(i), waterKeys.getValue(i));
+ }
+ }
+ for (int i = 0; i < rightKeys.getItemCount(); i++) {
+ merged.addValue(rightKeys.getKey(i), rightKeys.getValue(i));
+ }
+ super.drawRightLabels(merged, g2, plotArea, linkArea, maxLabelWidth, state);
+ }
+
+ @Override
+ protected void drawLeftLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) {
+ recolorLabel(record);
+ super.drawLeftLabel(g2, state, record);
+ }
+
+ @Override
+ protected void drawRightLabel(Graphics2D g2, PiePlotState state, PieLabelRecord record) {
+ recolorLabel(record);
+ super.drawRightLabel(g2, state, record);
+ }
+
+ private void recolorLabel(PieLabelRecord record) {
+ Paint paint = getSectionPaint(record.getKey());
+ Object label = record.getLabel();
+ if (paint == null || label == null) return;
+ recolorPaintFields(label, paint, new IdentityHashMap<Object, Boolean>());
+ }
+
+ private void recolorPaintFields(Object node, Paint paint,
+ Map<Object, Boolean> visited) {
+ if (node == null || visited.put(node, Boolean.TRUE) != null) return;
+ if (node instanceof Paint || node instanceof String || node instanceof Number
+ || node instanceof Font) {
+ return;
+ }
+ if (node instanceof Iterable) {
+ for (Object child : (Iterable<?>) node) {
+ recolorPaintFields(child, paint, visited);
+ }
+ return;
+ }
+ if (!node.getClass().getName().startsWith("org.jfree.chart.text")) return;
+ Class<?> type = node.getClass();
+ while (type != null && type != Object.class) {
+ for (Field field : type.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers())) continue;
+ try {
+ field.setAccessible(true);
+ Object value = field.get(node);
+ if (value instanceof Paint && node instanceof TextFragment) {
+ field.set(node, paint);
+ } else if (value instanceof Iterable
+ || value != null && value.getClass().getName()
+ .startsWith("org.jfree.chart.text")) {
+ recolorPaintFields(value, paint, visited);
+ }
+ } catch (IllegalAccessException ignored) {
+ // Keep the default label paint if a JFreeChart field cannot be read.
+ }
+ }
+ type = type.getSuperclass();
+ }
+ }
+ }
+
+ private Shape seriesShape(int seriesIndex) {
+ if (seriesIndex % 3 == 1) {
+ return new Rectangle2D.Double(-6.0, -6.0, 12.0, 12.0);
+ }
+ if (seriesIndex % 3 == 2) {
+ Path2D.Double diamond = new Path2D.Double();
+ diamond.moveTo(0.0, -7.0);
+ diamond.lineTo(7.0, 0.0);
+ diamond.lineTo(0.0, 7.0);
+ diamond.lineTo(-7.0, 0.0);
+ diamond.closePath();
+ return diamond;
+ }
+ return new Ellipse2D.Double(-6.0, -6.0, 12.0, 12.0);
+ }
+
+ private void stylePlot(XYPlot plot) {
+ plot.setBackgroundPaint(Color.WHITE);
+ plot.setOutlineVisible(false);
+ plot.setDomainGridlinesVisible(false);
+ plot.setRangeGridlinesVisible(false);
+ }
+
+ private void styleDomainAxis(org.jfree.chart.axis.CategoryAxis axis) {
+ axis.setTickLabelFont(TICK_FONT);
+ axis.setLabelFont(LABEL_FONT);
+ axis.setTickLabelPaint(MUTED_TEXT_COLOR);
+ axis.setLabelPaint(AXIS_COLOR);
+ axis.setAxisLinePaint(AXIS_COLOR);
+ axis.setTickMarkPaint(AXIS_COLOR);
+ axis.setCategoryMargin(0.05);
+ }
+
+ private void styleDomainAxis(org.jfree.chart.axis.ValueAxis axis) {
+ axis.setTickLabelFont(TICK_FONT);
+ axis.setLabelFont(LABEL_FONT);
+ axis.setTickLabelPaint(MUTED_TEXT_COLOR);
+ axis.setLabelPaint(AXIS_COLOR);
+ axis.setAxisLinePaint(AXIS_COLOR);
+ axis.setTickMarkPaint(AXIS_COLOR);
+ }
+
+ private void applyTheme(JFreeChart chart) {
+ StandardChartTheme theme = new StandardChartTheme("CN");
+ theme.setExtraLargeFont(BASE_FONT.deriveFont(Font.BOLD, 22f));
+ theme.setLargeFont(BASE_FONT.deriveFont(Font.BOLD, 18f));
+ theme.setRegularFont(BASE_FONT);
+ theme.setSmallFont(TICK_FONT);
+ theme.setChartBackgroundPaint(Color.WHITE);
+ theme.setPlotBackgroundPaint(Color.WHITE);
+ theme.setAxisLabelPaint(AXIS_COLOR);
+ theme.setTickLabelPaint(MUTED_TEXT_COLOR);
+ theme.setLegendItemPaint(MUTED_TEXT_COLOR);
+ theme.setLegendBackgroundPaint(Color.WHITE);
+ theme.apply(chart);
+ chart.setBackgroundPaint(Color.WHITE);
+ chart.setBorderVisible(false);
+ }
+
+ private Color seriesColor(int index) {
+ return SERIES_COLORS[index % SERIES_COLORS.length];
+ }
+
+ private byte[] render(JFreeChart chart, int width, int height, String unitLabel,
+ boolean axisChart, boolean percentItemLabels, int valueDecimals) {
+ try {
+ reserveUnitLabelBand(chart, axisChart, unitLabel);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ org.jfree.chart.ChartRenderingInfo info = new org.jfree.chart.ChartRenderingInfo();
+ java.awt.image.BufferedImage image =
+ chart.createBufferedImage(width, height, info);
+ Graphics2D graphics = image.createGraphics();
+ try {
+ graphics.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING,
+ java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
+ if (axisChart && isHorizontalStackedChart(chart)) {
+ drawUnitLabelTopRight(graphics, unitLabel, info.getPlotInfo().getDataArea());
+ drawStackedValueAxis(graphics, info.getPlotInfo().getDataArea());
+ } else if (axisChart) {
+ drawAxisArrows(graphics, info.getPlotInfo().getDataArea());
+ drawUnitLabel(graphics, unitLabel, info.getPlotInfo().getDataArea());
+ drawDateLabel(graphics, info.getPlotInfo().getDataArea());
+ }
+ drawXYItemLabels(graphics, image, chart,
+ info.getPlotInfo().getDataArea(), percentItemLabels, unitLabel, valueDecimals);
+ drawStackedBarLabels(graphics, image, chart,
+ info.getPlotInfo().getDataArea(), percentItemLabels);
+ } finally {
+ graphics.dispose();
+ }
+ javax.imageio.ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ } catch (Exception e) {
+ throw new IllegalArgumentException("璧板娍鍥惧浘鐗囩敓鎴愬け璐�: " + e.getMessage(), e);
+ }
+ }
+
+ private void drawAxisArrows(Graphics2D graphics, Rectangle2D dataArea) {
+ if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return;
+ graphics.setColor(AXIS_COLOR);
+ graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ int x = (int) Math.round(dataArea.getX());
+ int y = (int) Math.round(dataArea.getY());
+ int right = (int) Math.round(dataArea.getMaxX());
+ int bottom = (int) Math.round(dataArea.getMaxY());
+ graphics.drawLine(x, y, x, bottom);
+ graphics.drawLine(x, bottom, right, bottom);
+ drawArrowHead(graphics, right, bottom, 1, 0);
+ drawArrowHead(graphics, x, y, 0, -1);
+ }
+
+ private void drawArrowHead(Graphics2D graphics, int x, int y, int dx, int dy) {
+ int length = 11;
+ int width = 5;
+ if (dx != 0) {
+ graphics.drawLine(x, y, x - length, y - width);
+ graphics.drawLine(x, y, x - length, y + width);
+ } else {
+ graphics.drawLine(x, y, x - width, y + length);
+ graphics.drawLine(x, y, x + width, y + length);
+ }
+ }
+
+ private void drawUnitLabel(Graphics2D graphics, String unitLabel, Rectangle2D dataArea) {
+ if (unitLabel == null || unitLabel.isEmpty()) return;
+ graphics.setFont(LABEL_FONT);
+ graphics.setColor(MUTED_TEXT_COLOR);
+ java.awt.FontMetrics metrics = graphics.getFontMetrics();
+ int centerX = (int) Math.round(dataArea.getX());
+ int x = Math.max(4, centerX - metrics.stringWidth(unitLabel) / 2);
+ int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0));
+ graphics.drawString(unitLabel, x, y);
+ }
+
+ private void drawUnitLabelTopRight(Graphics2D graphics, String unitLabel,
+ Rectangle2D dataArea) {
+ if (unitLabel == null || unitLabel.isEmpty() || dataArea == null) return;
+ graphics.setFont(LABEL_FONT);
+ graphics.setColor(MUTED_TEXT_COLOR);
+ FontMetrics metrics = graphics.getFontMetrics();
+ int x = (int) Math.round(dataArea.getMaxX() - metrics.stringWidth(unitLabel));
+ int y = (int) Math.round(Math.max(metrics.getAscent() + 2.0, dataArea.getY() - 22.0));
+ graphics.drawString(unitLabel, x, y);
+ }
+
+ private void reserveUnitLabelBand(JFreeChart chart, boolean axisChart, String unitLabel) {
+ if (!axisChart || unitLabel == null || unitLabel.isEmpty()) return;
+ Plot plot = chart.getPlot();
+ RectangleInsets insets = plot.getInsets();
+ plot.setInsets(new RectangleInsets(Math.max(insets.getTop(), UNIT_LABEL_BAND),
+ insets.getLeft(), insets.getBottom(), insets.getRight()));
+ }
+
+ private boolean isHorizontalStackedChart(JFreeChart chart) {
+ return chart.getPlot() instanceof CategoryPlot
+ && PlotOrientation.HORIZONTAL.equals(
+ ((CategoryPlot) chart.getPlot()).getOrientation());
+ }
+
+ private void drawDateLabel(Graphics2D graphics, Rectangle2D dataArea) {
+ if (dataArea == null || dataArea.getWidth() <= 0) return;
+ graphics.setFont(LABEL_FONT);
+ graphics.setColor(MUTED_TEXT_COLOR);
+ int right = (int) Math.round(dataArea.getMaxX());
+ int bottom = (int) Math.round(dataArea.getMaxY());
+ graphics.drawString("鏃ユ湡", right - 50, bottom - 28);
+ }
+
+ private void drawStackedValueAxis(Graphics2D graphics, Rectangle2D dataArea) {
+ if (dataArea == null || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) return;
+ graphics.setColor(AXIS_COLOR);
+ graphics.setStroke(new BasicStroke(2.2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ int left = (int) Math.round(dataArea.getX());
+ int right = (int) Math.round(dataArea.getMaxX());
+ int bottom = (int) Math.round(dataArea.getMaxY());
+ graphics.drawLine(left, bottom, right, bottom);
+ }
+
+ private void drawStackedBarLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
+ Rectangle2D dataArea, boolean percentAxis) {
+ if (!(chart.getPlot() instanceof CategoryPlot) || dataArea == null
+ || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
+ return;
+ }
+ CategoryPlot plot = (CategoryPlot) chart.getPlot();
+ CategoryDataset dataset = plot.getDataset();
+ CategoryAxis domainAxis = plot.getDomainAxis();
+ ValueAxis rangeAxis = plot.getRangeAxis();
+ if (dataset == null || domainAxis == null || rangeAxis == null) return;
+
+ graphics.setFont(STACK_LABEL_FONT);
+ FontMetrics metrics = graphics.getFontMetrics();
+ int columns = dataset.getColumnCount();
+ int rows = dataset.getRowCount();
+ boolean horizontal = PlotOrientation.HORIZONTAL.equals(plot.getOrientation());
+ List<List<StackCallout>> callouts = new ArrayList<>();
+ for (int column = 0; column < columns; column++) callouts.add(new ArrayList<>());
+ List<HorizontalStackCallout> horizontalCallouts = new ArrayList<>();
+ List<Rectangle2D> occupied = new ArrayList<>();
+ for (int column = 0; column < columns; column++) {
+ double cumulative = 0.0;
+ for (int row = 0; row < rows; row++) {
+ Number number = dataset.getValue(row, column);
+ if (number == null) continue;
+ double value = number.doubleValue();
+ if (value == 0.0) continue;
+ String text = formatStackLabel(value, percentAxis);
+ if (horizontal) {
+ double startX = rangeAxis.valueToJava2D(cumulative, dataArea,
+ plot.getRangeAxisEdge());
+ double endX = rangeAxis.valueToJava2D(cumulative + value, dataArea,
+ plot.getRangeAxisEdge());
+ double segmentWidth = Math.abs(endX - startX);
+ double centerY = domainAxis.getCategoryMiddle(column, columns, dataArea,
+ plot.getDomainAxisEdge());
+ int width = metrics.stringWidth(text);
+ if (segmentWidth >= width + 4.0) {
+ int textX = (int) Math.round((startX + endX - width) / 2.0);
+ int baseline = (int) Math.round(centerY
+ + (metrics.getAscent() - metrics.getDescent()) / 2.0);
+ graphics.setColor(Color.WHITE);
+ graphics.drawString(text, textX, baseline);
+ occupied.add(new Rectangle2D.Double(textX, baseline - metrics.getAscent(),
+ width, metrics.getHeight()));
+ } else {
+ horizontalCallouts.add(new HorizontalStackCallout(Math.min(startX, endX),
+ Math.max(startX, endX), centerY, text, seriesColor(row)));
+ }
+ } else {
+ double top = rangeAxis.valueToJava2D(cumulative + value, dataArea,
+ plot.getRangeAxisEdge());
+ double bottom = rangeAxis.valueToJava2D(cumulative, dataArea,
+ plot.getRangeAxisEdge());
+ double segmentHeight = Math.abs(bottom - top);
+ double x = domainAxis.getCategoryMiddle(column, columns, dataArea,
+ plot.getDomainAxisEdge());
+ if (segmentHeight >= metrics.getHeight() + 4.0) {
+ int width = metrics.stringWidth(text);
+ int y = (int) Math.round(Math.min(top, bottom)
+ + (segmentHeight - metrics.getHeight()) / 2.0
+ + metrics.getAscent());
+ graphics.setColor(Color.WHITE);
+ graphics.drawString(text, (int) Math.round(x - width / 2.0), y);
+ occupied.add(new Rectangle2D.Double(x - width / 2.0,
+ y - metrics.getAscent(), width, metrics.getHeight()));
+ } else if (estimatePercentShare(value, percentAxis) <= 0.03
+ || !percentAxis) {
+ callouts.get(column).add(new StackCallout(x,
+ (top + bottom) / 2.0, text, seriesColor(row)));
+ }
+ }
+ cumulative += value;
+ }
+ }
+ if (horizontal) {
+ drawHorizontalStackCallouts(graphics, horizontalCallouts, dataArea, metrics, occupied);
+ } else {
+ for (int column = 0; column < columns; column++) {
+ drawStackCalloutColumn(graphics, image, callouts.get(column), column, columns,
+ dataArea, metrics, occupied);
+ }
+ }
+ }
+
+ private void drawHorizontalStackCallouts(Graphics2D graphics,
+ List<HorizontalStackCallout> items,
+ Rectangle2D dataArea, FontMetrics metrics,
+ List<Rectangle2D> occupied) {
+ if (items.isEmpty()) return;
+ Map<Long, List<HorizontalStackCallout>> rows = new LinkedHashMap<>();
+ for (HorizontalStackCallout item : items) {
+ Long key = Long.valueOf(Math.round(item.getY()));
+ List<HorizontalStackCallout> row = rows.get(key);
+ if (row == null) {
+ row = new ArrayList<>();
+ rows.put(key, row);
+ }
+ row.add(item);
+ }
+ for (List<HorizontalStackCallout> row : rows.values()) {
+ Collections.sort(row, new Comparator<HorizontalStackCallout>() {
+ @Override
+ public int compare(HorizontalStackCallout first, HorizontalStackCallout second) {
+ return Double.compare(first.getStartX(), second.getStartX());
+ }
+ });
+ int widest = 0;
+ double columnX = 0.0;
+ for (HorizontalStackCallout item : row) {
+ widest = Math.max(widest, metrics.stringWidth(item.getText()));
+ columnX = Math.max(columnX, item.getEndX() + 8.0);
+ }
+ // Keep the label column inside the reserved right margin.
+ columnX = Math.min(columnX, dataArea.getMaxX() + RIGHT_LABEL_BAND - widest);
+ int height = metrics.getHeight();
+ double step = height + 3.0;
+ double centerY = row.get(0).getY();
+ double top = centerY - (step * row.size()) / 2.0 + (step - height) / 2.0;
+ for (int i = 0; i < row.size(); i++) {
+ HorizontalStackCallout item = row.get(i);
+ int width = metrics.stringWidth(item.getText());
+ double x = columnX;
+ double y = top + i * step;
+ graphics.setColor(item.getColor());
+ graphics.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND,
+ BasicStroke.JOIN_ROUND));
+ graphics.drawLine((int) Math.round(item.getEndX()), (int) Math.round(item.getY()),
+ (int) Math.round(x - 3.0), (int) Math.round(y + height / 2.0));
+ drawLabelWithHalo(graphics, item.getText(),
+ new Rectangle2D.Double(x, y, width, height), metrics, item.getColor());
+ occupied.add(new Rectangle2D.Double(x - 2.0, y - 2.0,
+ width + 4.0, height + 4.0));
+ }
+ }
+ }
+
+ private void drawStackCalloutColumn(Graphics2D graphics, BufferedImage image,
+ List<StackCallout> items, int column, int columnCount,
+ Rectangle2D dataArea, FontMetrics metrics,
+ List<Rectangle2D> occupied) {
+ if (items.isEmpty()) return;
+ Collections.sort(items, new Comparator<StackCallout>() {
+ @Override
+ public int compare(StackCallout first, StackCallout second) {
+ return Double.compare(first.getY(), second.getY());
+ }
+ });
+ double categoryWidth = dataArea.getWidth() / Math.max(1, columnCount);
+ double barHalf = Math.min(80.0, Math.max(18.0, categoryWidth * 0.30));
+ double spacing = metrics.getHeight() + 4.0;
+ double totalHeight = (items.size() - 1) * spacing;
+ double averageY = 0.0;
+ for (StackCallout item : items) averageY += item.getY();
+ averageY /= items.size();
+ double startY = averageY - totalHeight / 2.0 - metrics.getHeight() / 2.0;
+ double maxStartY = dataArea.getMaxY() - metrics.getHeight() - 3.0 - totalHeight;
+ startY = Math.max(dataArea.getMinY() + 3.0, Math.min(startY, maxStartY));
+
+ double centerX = items.get(0).getX();
+ double maxWidth = 0.0;
+ for (StackCallout item : items) {
+ maxWidth = Math.max(maxWidth, metrics.stringWidth(item.getText()));
+ }
+ double rightLeft = centerX + barHalf + 14.0;
+ double leftLeft = centerX - barHalf - 14.0 - maxWidth;
+ double rightPenalty = stackLanePenalty(rightLeft, startY, items, metrics,
+ occupied, dataArea);
+ double leftPenalty = stackLanePenalty(leftLeft, startY, items, metrics,
+ occupied, dataArea);
+ boolean rightSide = rightPenalty <= leftPenalty;
+ double laneLeft = rightSide ? rightLeft : leftLeft;
+
+ for (int i = 0; i < items.size(); i++) {
+ StackCallout item = items.get(i);
+ int width = metrics.stringWidth(item.getText());
+ int height = metrics.getHeight();
+ int y = (int) Math.round(startY + i * spacing);
+ int textX = (int) Math.round(rightSide ? laneLeft : laneLeft + maxWidth - width);
+ int textY = y + metrics.getAscent();
+ double lineStartX = centerX + (rightSide ? barHalf : -barHalf);
+ double lineEndX = rightSide ? laneLeft : laneLeft + maxWidth;
+ graphics.setColor(item.getColor());
+ graphics.setStroke(new BasicStroke(1.8f, BasicStroke.CAP_ROUND,
+ BasicStroke.JOIN_ROUND));
+ graphics.drawLine((int) Math.round(lineStartX), (int) Math.round(item.getY()),
+ (int) Math.round(lineEndX), (int) Math.round(y + height / 2.0));
+ drawLabelWithHalo(graphics, item.getText(),
+ new Rectangle2D.Double(textX, y, width, height), metrics, item.getColor());
+ occupied.add(new Rectangle2D.Double(textX - 2.0, y - 2.0,
+ width + 4.0, height + 4.0));
+ }
+ }
+
+ private double stackLanePenalty(double laneLeft, double startY, List<StackCallout> items,
+ FontMetrics metrics, List<Rectangle2D> occupied,
+ Rectangle2D dataArea) {
+ double penalty = 0.0;
+ for (int i = 0; i < items.size(); i++) {
+ double width = metrics.stringWidth(items.get(i).getText());
+ double height = metrics.getHeight();
+ Rectangle2D rectangle = new Rectangle2D.Double(laneLeft, startY + i * (height + 4.0),
+ width, height);
+ if (rectangle.getMinX() < dataArea.getMinX()
+ || rectangle.getMaxX() > dataArea.getMaxX()
+ || rectangle.getMaxY() > dataArea.getMaxY()) {
+ penalty += 10000.0;
+ }
+ penalty += overlapArea(rectangle, occupied) * 100.0;
+ }
+ return penalty;
+ }
+
+ private double estimatePercentShare(double value, boolean percentAxis) {
+ return percentAxis ? value : value / 1.0;
+ }
+
+ private String formatStackLabel(double value, boolean percentAxis) {
+ if (percentAxis) {
+ NumberFormat formatter = NumberFormat.getPercentInstance();
+ formatter.setMaximumFractionDigits(1);
+ return formatter.format(value);
+ }
+ return new DecimalFormat("#,##0").format(value);
+ }
+
+ private static class HorizontalStackCallout {
+ private final double startX;
+ private final double endX;
+ private final double y;
+ private final String text;
+ private final Color color;
+
+ private HorizontalStackCallout(double startX, double endX, double y,
+ String text, Color color) {
+ this.startX = startX;
+ this.endX = endX;
+ this.y = y;
+ this.text = text;
+ this.color = color;
+ }
+
+ private double getStartX() { return startX; }
+ private double getEndX() { return endX; }
+ private double getY() { return y; }
+ private String getText() { return text; }
+ private Color getColor() { return color; }
+ }
+
+ private static class StackCallout {
+ private final double x;
+ private final double y;
+ private final String text;
+ private final Color color;
+
+ private StackCallout(double x, double y, String text, Color color) {
+ this.x = x;
+ this.y = y;
+ this.text = text;
+ this.color = color;
+ }
+
+ private double getX() { return x; }
+ private double getY() { return y; }
+ private String getText() { return text; }
+ private Color getColor() { return color; }
+ }
+
+ private void drawXYItemLabels(Graphics2D graphics, BufferedImage image, JFreeChart chart,
+ Rectangle2D dataArea, boolean percentItemLabels,
+ String unitLabel, int valueDecimals) {
+ if (!(chart.getPlot() instanceof XYPlot) || dataArea == null
+ || dataArea.getWidth() <= 0 || dataArea.getHeight() <= 0) {
+ return;
+ }
+ XYPlot plot = (XYPlot) chart.getXYPlot();
+ XYDataset dataset = plot.getDataset();
+ if (dataset == null) return;
+
+ graphics.setFont(ITEM_LABEL_FONT);
+ FontMetrics metrics = graphics.getFontMetrics();
+ List<Rectangle2D> placedLabels = new ArrayList<>();
+ if (unitLabel != null && !unitLabel.isEmpty()) {
+ placedLabels.add(new Rectangle2D.Double(0.0, 0.0,
+ dataArea.getMinX() + 100.0, dataArea.getMinY() + 30.0));
+ }
+ int itemCount = 0;
+ for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
+ itemCount = Math.max(itemCount, dataset.getItemCount(seriesIndex));
+ }
+
+ for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) {
+ List<DayLabel> labels = new ArrayList<>();
+ for (int seriesIndex = 0; seriesIndex < dataset.getSeriesCount(); seriesIndex++) {
+ if (itemIndex >= dataset.getItemCount(seriesIndex)) continue;
+ Number yValue = dataset.getY(seriesIndex, itemIndex);
+ Point2D point = itemPoint(plot, dataset, seriesIndex, itemIndex, dataArea);
+ if (yValue == null || point == null) continue;
+ labels.add(new DayLabel(seriesIndex, point,
+ formatLineLabel(yValue, percentItemLabels, valueDecimals)));
+ }
+ // A day's labels are stacked in the same top-to-bottom order as its data
+ // points, so a label never looks attached to a neighbouring line.
+ Collections.sort(labels, new Comparator<DayLabel>() {
+ @Override
+ public int compare(DayLabel first, DayLabel second) {
+ return Double.compare(first.getPoint().getY(), second.getPoint().getY());
+ }
+ });
+ List<List<LineLabelCandidate>> candidateSets = new ArrayList<>();
+ for (DayLabel label : labels) {
+ List<LineLabelCandidate> candidates = lineLabelCandidates(
+ label, metrics, dataArea);
+ if (candidates.isEmpty()) {
+ candidateSets.clear();
+ break;
+ }
+ candidateSets.add(candidates);
+ }
+ if (candidateSets.isEmpty()) continue;
+
+ // Dynamic programming keeps each label near its own point while preserving
+ // the same top-to-bottom order as the data points for that date.
+ List<int[]> backPointers = new ArrayList<>();
+ List<LineLabelCandidate> firstCandidates = candidateSets.get(0);
+ double[] previousCosts = new double[firstCandidates.size()];
+ for (int i = 0; i < firstCandidates.size(); i++) {
+ previousCosts[i] = lineLabelCost(firstCandidates.get(i), labels.get(0),
+ labels, placedLabels);
+ }
+
+ for (int labelIndex = 1; labelIndex < candidateSets.size(); labelIndex++) {
+ List<LineLabelCandidate> previousCandidates = candidateSets.get(labelIndex - 1);
+ List<LineLabelCandidate> currentCandidates = candidateSets.get(labelIndex);
+ double[] currentCosts = new double[currentCandidates.size()];
+ Arrays.fill(currentCosts, Double.MAX_VALUE);
+ int[] back = new int[currentCandidates.size()];
+ Arrays.fill(back, -1);
+ for (int currentIndex = 0; currentIndex < currentCandidates.size(); currentIndex++) {
+ LineLabelCandidate current = currentCandidates.get(currentIndex);
+ double currentCost = lineLabelCost(current, labels.get(labelIndex),
+ labels, placedLabels);
+ for (int previousIndex = 0; previousIndex < previousCandidates.size(); previousIndex++) {
+ if (previousCosts[previousIndex] == Double.MAX_VALUE) continue;
+ LineLabelCandidate previous = previousCandidates.get(previousIndex);
+ if (previous.getRectangle().getCenterY()
+ > current.getRectangle().getCenterY() + 0.1) {
+ continue;
+ }
+ double overlapPenalty = overlapArea(previous.getRectangle(),
+ Collections.singletonList(current.getRectangle())) * 1000000.0;
+ double cost = previousCosts[previousIndex] + currentCost + overlapPenalty;
+ if (cost < currentCosts[currentIndex]) {
+ currentCosts[currentIndex] = cost;
+ back[currentIndex] = previousIndex;
+ }
+ }
+ }
+ previousCosts = currentCosts;
+ backPointers.add(back);
+ }
+
+ int bestLastIndex = -1;
+ double bestCost = Double.MAX_VALUE;
+ for (int i = 0; i < previousCosts.length; i++) {
+ if (previousCosts[i] < bestCost) {
+ bestCost = previousCosts[i];
+ bestLastIndex = i;
+ }
+ }
+ if (bestLastIndex < 0) continue;
+
+ int[] chosenIndexes = new int[candidateSets.size()];
+ chosenIndexes[chosenIndexes.length - 1] = bestLastIndex;
+ for (int labelIndex = chosenIndexes.length - 2; labelIndex >= 0; labelIndex--) {
+ int nextIndex = chosenIndexes[labelIndex + 1];
+ chosenIndexes[labelIndex] = backPointers.get(labelIndex)[nextIndex];
+ }
+ for (int labelIndex = 0; labelIndex < chosenIndexes.length; labelIndex++) {
+ DayLabel label = labels.get(labelIndex);
+ Rectangle2D chosen = candidateSets.get(labelIndex)
+ .get(chosenIndexes[labelIndex]).getRectangle();
+ drawLabelWithHalo(graphics, label.getText(), chosen, metrics,
+ seriesColor(label.getSeriesIndex()));
+ placedLabels.add(new Rectangle2D.Double(chosen.getX() - 4.0, chosen.getY() - 4.0,
+ chosen.getWidth() + 8.0, chosen.getHeight() + 8.0));
+ }
+ }
+ }
+
+ private static class LineLabelCandidate {
+ private final Rectangle2D rectangle;
+
+ private LineLabelCandidate(Rectangle2D rectangle) {
+ this.rectangle = rectangle;
+ }
+
+ private Rectangle2D getRectangle() { return rectangle; }
+ }
+
+ private static class DayLabel {
+ private final int seriesIndex;
+ private final Point2D point;
+ private final String text;
+
+ private DayLabel(int seriesIndex, Point2D point, String text) {
+ this.seriesIndex = seriesIndex;
+ this.point = point;
+ this.text = text;
+ }
+
+ private int getSeriesIndex() { return seriesIndex; }
+ private Point2D getPoint() { return point; }
+ private String getText() { return text; }
+ }
+
+ private Point2D itemPoint(XYPlot plot, XYDataset dataset, int seriesIndex,
+ int itemIndex, Rectangle2D dataArea) {
+ Number yValue = dataset.getY(seriesIndex, itemIndex);
+ if (yValue == null || Double.isNaN(yValue.doubleValue())
+ || Double.isInfinite(yValue.doubleValue())) {
+ return null;
+ }
+ double x = plot.getDomainAxis().valueToJava2D(dataset.getXValue(seriesIndex, itemIndex),
+ dataArea, plot.getDomainAxisEdge());
+ double y = plot.getRangeAxis().valueToJava2D(yValue.doubleValue(),
+ dataArea, plot.getRangeAxisEdge());
+ return new Point2D.Double(x, y);
+ }
+
+ private List<LineLabelCandidate> lineLabelCandidates(DayLabel label, FontMetrics metrics,
+ Rectangle2D dataArea) {
+ List<LineLabelCandidate> result = new ArrayList<>();
+ int width = metrics.stringWidth(label.getText());
+ int height = metrics.getHeight();
+ double x = label.getPoint().getX();
+ double y = label.getPoint().getY();
+ double horizontalOffset = ITEM_LABEL_GAP + width / 2.0;
+ double farHorizontalOffset = horizontalOffset + 18.0;
+ double verticalOffset = ITEM_LABEL_GAP + height / 2.0;
+ double farVerticalOffset = verticalOffset + height + 6.0;
+ addLabelCandidate(result, x, y, width, height, 0.0, -verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height, 0.0, verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height, horizontalOffset, 0.0, dataArea);
+ addLabelCandidate(result, x, y, width, height, -horizontalOffset, 0.0, dataArea);
+ addLabelCandidate(result, x, y, width, height, farHorizontalOffset, 0.0, dataArea);
+ addLabelCandidate(result, x, y, width, height, -farHorizontalOffset, 0.0, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ horizontalOffset, -verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ horizontalOffset, verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -horizontalOffset, -verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -horizontalOffset, verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ farHorizontalOffset, -verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ farHorizontalOffset, verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -farHorizontalOffset, -verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -farHorizontalOffset, verticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height, 0.0, -farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height, 0.0, farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ horizontalOffset, -farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ horizontalOffset, farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -horizontalOffset, -farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -horizontalOffset, farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ farHorizontalOffset, -farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ farHorizontalOffset, farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -farHorizontalOffset, -farVerticalOffset, dataArea);
+ addLabelCandidate(result, x, y, width, height,
+ -farHorizontalOffset, farVerticalOffset, dataArea);
+ return result;
+ }
+
+ private void addLabelCandidate(List<LineLabelCandidate> result, double x, double y,
+ int width, int height, double dx, double dy,
+ Rectangle2D dataArea) {
+ Rectangle2D candidate = boundToDataArea(new Rectangle2D.Double(
+ x + dx - width / 2.0, y + dy - height / 2.0, width, height), dataArea);
+ if (candidate == null) return;
+ for (LineLabelCandidate existing : result) {
+ if (Math.abs(existing.getRectangle().getX() - candidate.getX()) < 0.5
+ && Math.abs(existing.getRectangle().getY() - candidate.getY()) < 0.5) {
+ return;
+ }
+ }
+ result.add(new LineLabelCandidate(candidate));
+ }
+
+ private double lineLabelCost(LineLabelCandidate candidate, DayLabel label,
+ List<DayLabel> sameDayLabels,
+ List<Rectangle2D> placedLabels) {
+ Rectangle2D rectangle = candidate.getRectangle();
+ double dx = rectangle.getCenterX() - label.getPoint().getX();
+ double dy = rectangle.getCenterY() - label.getPoint().getY();
+ double ownDistance = Math.sqrt(dx * dx + dy * dy);
+ double cost = ownDistance * 10.0 + overlapArea(rectangle, placedLabels) * 10000.0;
+ Rectangle2D ownMarker = new Rectangle2D.Double(label.getPoint().getX() - 9.0,
+ label.getPoint().getY() - 9.0, 18.0, 18.0);
+ if (rectangle.intersects(ownMarker)) cost += 3000.0;
+ for (DayLabel other : sameDayLabels) {
+ if (other == label) continue;
+ double otherDx = rectangle.getCenterX() - other.getPoint().getX();
+ double otherDy = rectangle.getCenterY() - other.getPoint().getY();
+ double otherDistance = Math.sqrt(otherDx * otherDx + otherDy * otherDy);
+ if (otherDistance < ownDistance - 1.0) {
+ cost += (ownDistance - otherDistance) * 5000.0;
+ }
+ Rectangle2D otherMarker = new Rectangle2D.Double(other.getPoint().getX() - 9.0,
+ other.getPoint().getY() - 9.0, 18.0, 18.0);
+ if (rectangle.intersects(otherMarker)) cost += 2000.0;
+ }
+ return cost;
+ }
+
+ private Rectangle2D boundToDataArea(Rectangle2D rectangle, Rectangle2D dataArea) {
+ double minX = dataArea.getMinX() + 16.0;
+ double minY = dataArea.getMinY() + 2.0;
+ double maxX = dataArea.getMaxX() - rectangle.getWidth() - 2.0;
+ double maxY = dataArea.getMaxY() - rectangle.getHeight() - 2.0;
+ if (maxX < minX || maxY < minY) return null;
+ double x = Math.max(minX, Math.min(rectangle.getX(), maxX));
+ double y = Math.max(minY, Math.min(rectangle.getY(), maxY));
+ return new Rectangle2D.Double(x, y, rectangle.getWidth(), rectangle.getHeight());
+ }
+
+ private double overlapArea(Rectangle2D rectangle, List<Rectangle2D> occupied) {
+ double total = 0.0;
+ for (Rectangle2D other : occupied) {
+ double width = Math.min(rectangle.getMaxX(), other.getMaxX())
+ - Math.max(rectangle.getMinX(), other.getMinX());
+ double height = Math.min(rectangle.getMaxY(), other.getMaxY())
+ - Math.max(rectangle.getMinY(), other.getMinY());
+ if (width > 0.0 && height > 0.0) total += width * height;
+ }
+ return total;
+ }
+
+ static int scaleLineChartHeight(int height) {
+ return (int) Math.round(height * LINE_CHART_HEIGHT_SCALE);
+ }
+
+ static long scaleLineChartHeight(long height) {
+ return Math.round(height * LINE_CHART_HEIGHT_SCALE);
+ }
+
+ static BigDecimal displayValue(BigDecimal value, boolean percentItemLabels, int valueDecimals) {
+ if (value == null) return null;
+ int scale = percentItemLabels ? PERCENT_ITEM_LABEL_DECIMALS + 2 : valueDecimals;
+ return value.setScale(scale, RoundingMode.HALF_UP);
+ }
+
+ private String formatLineLabel(Number value, boolean percentItemLabels, int valueDecimals) {
+ if (percentItemLabels) {
+ NumberFormat formatter = NumberFormat.getPercentInstance();
+ formatter.setMaximumFractionDigits(PERCENT_ITEM_LABEL_DECIMALS);
+ return formatter.format(value.doubleValue());
+ }
+ DecimalFormat formatter = new DecimalFormat("#,##0");
+ formatter.setMaximumFractionDigits(valueDecimals);
+ return formatter.format(value.doubleValue());
+ }
+
+ private void drawLabelWithHalo(Graphics2D graphics, String text, Rectangle2D rectangle,
+ FontMetrics metrics, Color color) {
+ int x = (int) Math.round(rectangle.getX());
+ int y = (int) Math.round(rectangle.getY() + metrics.getAscent());
+ // A stroked white outline keeps the digit legible even when a line runs
+ // underneath it, which lets labels stay right next to their own point.
+ java.awt.font.TextLayout layout = new java.awt.font.TextLayout(
+ text, graphics.getFont(), graphics.getFontRenderContext());
+ java.awt.Shape outline = layout.getOutline(
+ java.awt.geom.AffineTransform.getTranslateInstance(x, y));
+ graphics.setColor(Color.WHITE);
+ graphics.setStroke(new BasicStroke(HALO_OUTLINE_WIDTH,
+ BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ graphics.draw(outline);
+ graphics.setColor(color);
+ graphics.fill(outline);
+ }
+
+ public static final List<String> DAY_LABELS = Arrays.asList(
+ "10鏈�1鏃�", "10鏈�2鏃�", "10鏈�3鏃�", "10鏈�4鏃�",
+ "10鏈�5鏃�", "10鏈�6鏃�", "10鏈�7鏃�", "10鏈�8鏃�");
+
+ public static List<ChartSeries> emptySeries(int size) {
+ List<ChartSeries> result = new ArrayList<>();
+ for (int i = 0; i < size; i++) {
+ result.add(new ChartSeries());
+ }
+ return result;
+ }
+
+ @Data
+ public static class ChartSeries {
+ private String name;
+ private List<BigDecimal> values = new ArrayList<>();
+ }
+
+ @Data
+ public static class ChartCategory {
+ private String name;
+ private BigDecimal value;
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
new file mode 100644
index 0000000..9960464
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendService.java
@@ -0,0 +1,450 @@
+package com.trafficaudit.holiday.service;
+
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
+import com.trafficaudit.observation.dto.ObservationDailyOperation;
+import com.trafficaudit.observation.mapper.ObservationStationFlowMapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+@Service
+public class HolidayTrendService {
+
+ private static final BigDecimal HUNDRED = new BigDecimal("100");
+
+ @Value("${holiday.trend-template:docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx}")
+ private String trendTemplatePath;
+
+ @Resource
+ private HolidayStatService statService;
+ @Resource
+ private HolidayConfigService configService;
+ @Resource
+ private HolidayTrendChartService chartService;
+ @Resource
+ private HolidayTrendWordService wordService;
+ @Resource
+ private ObservationStationFlowMapper observationStationFlowMapper;
+
+ public byte[] generate(int year, String holidayType) {
+ String holidayLabel = HolidayConstants.HOLIDAY_TYPE_LABELS.get(holidayType);
+ if (holidayLabel == null) {
+ throw new IllegalArgumentException("涓嶆敮鎸佺殑鑺傚亣鏃ョ被鍨�");
+ }
+ List<Integer> years = Arrays.asList(year, year - 1, year - 2);
+ Map<Integer, YearData> yearData = new LinkedHashMap<>();
+ for (Integer item : years) {
+ yearData.put(item, buildYearData(item, holidayType));
+ }
+ YearData selected = yearData.get(year);
+ if (selected == null || !selected.hasAnyData()) {
+ throw new IllegalArgumentException("鎵�閫夊勾浠芥病鏈夎冻澶熺殑璧板娍鍥炬暟鎹�");
+ }
+
+ String prefix = "鈥�" + holidayLabel + "鈥濆亣鏈�";
+ List<HolidayTrendWordService.DocumentBlock> blocks = new ArrayList<>();
+ blocks.add(new HolidayTrendWordService.SectionBlock("涓�銆佹�讳綋鎯呭喌"));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ prefix + "鍏ㄧ渷璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺鎯呭喌",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.totalDaily), false, 1100, 715),
+ lineImageSize(5268595L, 3424555L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熺疮璁¤法鍖哄煙浜哄憳娴佸姩閲忚蛋鍔�",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.totalCumulative), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熷悇浜ら�氳繍杈撴柟寮忓杩愰噺鍗犳瘮鎯呭喌",
+ chartService.pieChart("浜烘", selected.modalShare(), 1000, 607), true,
+ new HolidayTrendWordService.ImageSize(5106670L, 3097530L)));
+
+ blocks.add(new HolidayTrendWordService.SectionBlock("浜屻�佸悇绉嶈繍杈撴柟寮忓娴侀噺璧板娍"));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熻嚜椹惧嚭琛岄噺鎯呭喌",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.selfDrivingPersonFlow), false, 1100, 522),
+ lineImageSize(5232400L, 2484120L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熼搧璺杩愰噺鎯呭喌",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.rail), false, 1100, 445),
+ lineImageSize(5264785L, 2129155L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熸按璺杩愰噺鎯呭喌",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.water), false, 1100, 433),
+ lineImageSize(5274310L, 2075815L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熸皯鑸杩愰噺鎯呭喌",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.civilAviation), false, 1100, 521),
+ lineImageSize(5271135L, 2496185L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熼亾璺杩愰噺鎯呭喌",
+ chartService.lineChart("涓囦汉娆�",
+ series(years, yearData, item -> item.road), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+
+ blocks.add(new HolidayTrendWordService.SectionBlock("涓夈�佽溅娴侀噺璧板娍"));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熼珮閫熷叕璺溅娴侀噺鎯呭喌",
+ chartService.lineChart("涓囪締娆�",
+ series(years, yearData, item -> item.expressway), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熸櫘閫氬浗鐪侀亾杞︽祦閲忔儏鍐�",
+ chartService.lineChart("涓囪締娆�",
+ series(years, yearData, item -> item.nationalProvincial), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熷叏鐪佽溅娴佹�婚噺鎯呭喌",
+ chartService.lineChart("涓囪締娆�",
+ series(years, yearData, item -> item.totalVehicleFlow), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熷叕璺嚭琛岀粨鏋�",
+ chartService.stackedBarChart("涓囦汉娆�",
+ Arrays.asList((year - 2) + "骞�", (year - 1) + "骞�", year + "骞�"),
+ roadStructureSeries(years, yearData), false, 1000, 607), true,
+ new HolidayTrendWordService.ImageSize(5106670L, 3097530L)));
+
+ blocks.add(new HolidayTrendWordService.SectionBlock("闄勫綍涓� 姣忔棩瀹㈡祦鏋勬垚鍙樺寲"));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ "鈥�" + holidayLabel + "鈥濆亣鏈熸瘡鏃ュ娴佹瀯鎴愬彉鍖�",
+ chartService.stackedBarChart("鍗犳瘮",
+ HolidayTrendChartService.DAY_LABELS,
+ dailyShareSeries(selected), true, 1000, 607), true,
+ new HolidayTrendWordService.ImageSize(5106670L, 3097530L)));
+
+ blocks.add(new HolidayTrendWordService.SectionBlock("闄勫綍浜� 鏅�氬浗鐪侀亾瑙傛祴绔欒矾缃戣繍琛�"));
+ ObservationData observation = observationData(year, holidayType);
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ prefix + "鏅�氬浗鐪侀亾瑙傛祴绔欏钩鍧囨満鍔ㄨ溅閫熷害",
+ chartService.lineChart("鍏噷/灏忔椂",
+ Collections.singletonList(series("骞冲潎閫熷害", observation.speed)), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock(
+ prefix + "鏅�氬浗鐪侀亾瑙傛祴绔欏钩鍧囨嫢鎸ゅ害",
+ chartService.lineChart("鎷ユ尋搴�",
+ Collections.singletonList(series("骞冲潎鎷ユ尋搴�", observation.congestion)), false, 1100, 590),
+ lineImageSize(5268595L, 2821305L)));
+
+ File template = resolveTemplateFile();
+ if (!template.exists() || !template.isFile()) {
+ throw new IllegalArgumentException("璧板娍鍥炬ā鏉挎枃浠朵笉瀛樺湪: " + template.getAbsolutePath());
+ }
+ try (InputStream input = new FileInputStream(template)) {
+ return wordService.generate(readAll(input), year, blocks);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("璧板娍鍥剧敓鎴愬け璐�: " + e.getMessage(), e);
+ }
+ }
+
+ private YearData buildYearData(int year, String holidayType) {
+ List<DailyMetrics> metrics = new ArrayList<>();
+ for (int day = 1; day <= HolidayTrendChartService.DAY_LABELS.size(); day++) {
+ metrics.add(statService.dailyMetrics(year, holidayType, day));
+ }
+ YearData data = new YearData();
+ data.year = year;
+ data.rail = values(metrics, DailyMetrics::getRail);
+ data.civilAviation = values(metrics, DailyMetrics::getCivilAviation);
+ data.road = values(metrics, DailyMetrics::getRoad);
+ data.water = values(metrics, DailyMetrics::getWater);
+ data.expressway = values(metrics, DailyMetrics::getExpressway);
+ data.nationalProvincial = values(metrics, DailyMetrics::getNationalProvincial);
+ data.selfDrivingPersonFlow = values(metrics, DailyMetrics::getNonBusinessPersonFlow);
+ data.selfDrivingVehicleFlow = combine(metrics,
+ DailyMetrics::getExpressway, DailyMetrics::getNationalProvincial);
+ data.totalVehicleFlow = combine(metrics,
+ DailyMetrics::getExpressway, DailyMetrics::getNationalProvincial);
+ data.totalDaily = values(metrics, DailyMetrics::getTotalPersonFlow);
+ data.totalCumulative = cumulative(data.totalDaily);
+ data.railCumulative = cumulative(data.rail);
+ data.civilAviationCumulative = cumulative(data.civilAviation);
+ data.roadCumulative = cumulative(data.road);
+ data.waterCumulative = cumulative(data.water);
+ data.selfDrivingPersonFlowCumulative = cumulative(data.selfDrivingPersonFlow);
+ return data;
+ }
+
+ private List<HolidayTrendChartService.ChartSeries> series(
+ List<Integer> years, Map<Integer, YearData> data,
+ Function<YearData, List<BigDecimal>> selector) {
+ List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>();
+ for (Integer year : years) {
+ YearData item = data.get(year);
+ result.add(series(year + "骞�", selector.apply(item)));
+ }
+ return result;
+ }
+
+ private List<HolidayTrendChartService.ChartSeries> yoySeries(
+ String label, List<Integer> years, Map<Integer, YearData> data,
+ Function<YearData, List<BigDecimal>> selector) {
+ List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>();
+ YearData selected = data.get(years.get(0));
+ List<BigDecimal> selectedValues = selector.apply(selected);
+ for (int i = 1; i < years.size(); i++) {
+ YearData comparison = data.get(years.get(i));
+ result.add(series(label + "鍚屾瘮" + comparison.year,
+ percentageChanges(selectedValues, selector.apply(comparison))));
+ }
+ return result;
+ }
+
+ private List<HolidayTrendChartService.ChartSeries> dailyShareSeries(YearData data) {
+ List<HolidayTrendChartService.ChartSeries> result = new ArrayList<>();
+ result.add(series("鑷┚", share(data.selfDrivingPersonFlow, data)));
+ result.add(series("閬撹矾", share(data.road, data)));
+ result.add(series("閾佽矾", share(data.rail, data)));
+ result.add(series("姘戣埅", share(data.civilAviation, data)));
+ result.add(series("姘磋矾", share(data.water, data)));
+ return result;
+ }
+
+ private List<BigDecimal> share(List<BigDecimal> values, YearData data) {
+ List<BigDecimal> result = new ArrayList<>();
+ for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
+ BigDecimal total = dayTotal(data, i);
+ BigDecimal value = valueAt(values, i);
+ result.add(total == null || total.signum() == 0
+ ? null : HolidayNumberUtils.divide(value == null ? BigDecimal.ZERO : value, total));
+ }
+ return result;
+ }
+
+ private BigDecimal dayTotal(YearData data, int index) {
+ BigDecimal total = BigDecimal.ZERO;
+ boolean hasValue = false;
+ for (List<BigDecimal> values : Arrays.asList(
+ data.selfDrivingPersonFlow, data.road, data.rail,
+ data.civilAviation, data.water)) {
+ BigDecimal value = valueAt(values, index);
+ if (value != null) {
+ total = total.add(value);
+ hasValue = true;
+ }
+ }
+ return hasValue ? HolidayNumberUtils.normalize(total) : null;
+ }
+
+ private List<HolidayTrendChartService.ChartSeries> roadStructureSeries(
+ List<Integer> years, Map<Integer, YearData> data) {
+ List<BigDecimal> road = new ArrayList<>();
+ List<BigDecimal> selfDriving = new ArrayList<>();
+ for (int i = years.size() - 1; i >= 0; i--) {
+ YearData item = data.get(years.get(i));
+ road.add(sum(item.road));
+ selfDriving.add(sum(item.selfDrivingPersonFlow));
+ }
+ return Arrays.asList(
+ series("鑷┚鍑鸿閲�", selfDriving),
+ series("閬撹矾瀹㈣繍閲�", road));
+ }
+
+ private ObservationData observationData(int year, String holidayType) {
+ List<HolidayCalendar> days = configService.findHolidayDays(year, holidayType);
+ if (days.isEmpty()) {
+ return new ObservationData(nulls(), nulls());
+ }
+ LocalDate start = days.get(0).getHolidayDate();
+ LocalDate end = start.plusDays(HolidayTrendChartService.DAY_LABELS.size() - 1L);
+ List<ObservationDailyOperation> rows = observationStationFlowMapper.selectDailyOperations(start, end);
+ Map<LocalDate, ObservationDailyOperation> byDate = new LinkedHashMap<>();
+ for (ObservationDailyOperation row : rows) {
+ byDate.put(row.getObsDate(), row);
+ }
+ List<BigDecimal> speed = new ArrayList<>();
+ List<BigDecimal> congestion = new ArrayList<>();
+ for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
+ ObservationDailyOperation row = byDate.get(start.plusDays(i));
+ speed.add(row == null ? null : row.getAverageSpeed());
+ congestion.add(row == null ? null : row.getAverageCongestion());
+ }
+ return new ObservationData(speed, congestion);
+ }
+
+ private HolidayTrendChartService.ChartSeries series(String name, List<BigDecimal> values) {
+ HolidayTrendChartService.ChartSeries result = new HolidayTrendChartService.ChartSeries();
+ result.setName(name);
+ result.setValues(values == null ? nulls() : values);
+ return result;
+ }
+
+ private List<BigDecimal> values(List<DailyMetrics> metrics, Function<DailyMetrics, BigDecimal> selector) {
+ List<BigDecimal> result = new ArrayList<>();
+ for (DailyMetrics item : metrics) {
+ result.add(item == null ? null : selector.apply(item));
+ }
+ return result;
+ }
+
+ private List<BigDecimal> combine(List<DailyMetrics> metrics,
+ Function<DailyMetrics, BigDecimal> left,
+ Function<DailyMetrics, BigDecimal> right) {
+ List<BigDecimal> result = new ArrayList<>();
+ for (DailyMetrics item : metrics) {
+ result.add(item == null ? null : HolidayNumberUtils.sumNullable(left.apply(item), right.apply(item)));
+ }
+ return result;
+ }
+
+ private List<BigDecimal> cumulative(List<BigDecimal> values) {
+ List<BigDecimal> result = new ArrayList<>();
+ BigDecimal total = null;
+ for (BigDecimal value : values) {
+ if (value != null) total = HolidayNumberUtils.sumNullable(total, value);
+ result.add(value == null ? null : total);
+ }
+ return result;
+ }
+
+ private List<BigDecimal> percentageChanges(List<BigDecimal> current, List<BigDecimal> previous) {
+ List<BigDecimal> result = new ArrayList<>();
+ for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) {
+ BigDecimal currentValue = valueAt(current, i);
+ BigDecimal previousValue = valueAt(previous, i);
+ BigDecimal ratio = HolidayNumberUtils.divide(currentValue, previousValue);
+ result.add(ratio == null ? null : HolidayNumberUtils.multiply(
+ ratio.subtract(BigDecimal.ONE), HUNDRED));
+ }
+ return result;
+ }
+
+ private BigDecimal sum(List<BigDecimal> values) {
+ BigDecimal total = null;
+ for (BigDecimal value : values) {
+ if (value != null) total = HolidayNumberUtils.sumNullable(total, value);
+ }
+ return total;
+ }
+
+ private BigDecimal valueAt(List<BigDecimal> values, int index) {
+ return values == null || index >= values.size() ? null : values.get(index);
+ }
+
+ private List<BigDecimal> nulls() {
+ List<BigDecimal> result = new ArrayList<>();
+ for (int i = 0; i < HolidayTrendChartService.DAY_LABELS.size(); i++) result.add(null);
+ return result;
+ }
+
+ private HolidayTrendWordService.ImageSize lineImageSize(long width, long height) {
+ return new HolidayTrendWordService.ImageSize(
+ width, HolidayTrendChartService.scaleLineChartHeight(height));
+ }
+
+ private File resolveTemplateFile() {
+ List<File> candidates = new ArrayList<>();
+ File configured = new File(trendTemplatePath);
+ candidates.add(configured);
+ if (!configured.isAbsolute()) {
+ candidates.add(new File(System.getProperty("user.dir"), trendTemplatePath));
+ candidates.add(new File(System.getProperty("user.dir"), "../" + trendTemplatePath));
+ }
+ for (File candidate : candidates) {
+ try {
+ if (candidate.exists() && candidate.isFile()) return candidate.getCanonicalFile();
+ } catch (Exception ignored) {
+ // Keep checking.
+ }
+ }
+ return configured;
+ }
+
+ private byte[] readAll(InputStream input) throws Exception {
+ java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int length;
+ while ((length = input.read(buffer)) > 0) {
+ output.write(buffer, 0, length);
+ }
+ return output.toByteArray();
+ }
+
+ private static class YearData {
+ private int year;
+ private List<BigDecimal> rail;
+ private List<BigDecimal> civilAviation;
+ private List<BigDecimal> road;
+ private List<BigDecimal> water;
+ private List<BigDecimal> expressway;
+ private List<BigDecimal> nationalProvincial;
+ private List<BigDecimal> selfDrivingPersonFlow;
+ private List<BigDecimal> selfDrivingVehicleFlow;
+ private List<BigDecimal> totalVehicleFlow;
+ private List<BigDecimal> totalDaily;
+ private List<BigDecimal> totalCumulative;
+ private List<BigDecimal> railCumulative;
+ private List<BigDecimal> civilAviationCumulative;
+ private List<BigDecimal> roadCumulative;
+ private List<BigDecimal> waterCumulative;
+ private List<BigDecimal> selfDrivingPersonFlowCumulative;
+
+ private boolean hasAnyData() {
+ return hasValue(totalDaily) || hasValue(rail) || hasValue(road)
+ || hasValue(expressway) || hasValue(nationalProvincial);
+ }
+
+ private boolean hasValue(List<BigDecimal> values) {
+ if (values == null) return false;
+ for (BigDecimal value : values) {
+ if (value != null) return true;
+ }
+ return false;
+ }
+
+ private List<HolidayTrendChartService.ChartCategory> modalShare() {
+ List<HolidayTrendChartService.ChartCategory> result = new ArrayList<>();
+ result.add(category("鑷┚", sum(selfDrivingPersonFlow)));
+ result.add(category("閬撹矾", sum(road)));
+ result.add(category("閾佽矾", sum(rail)));
+ result.add(category("姘戣埅", sum(civilAviation)));
+ result.add(category("姘磋矾", sum(water)));
+ return result;
+ }
+
+ private BigDecimal sum(List<BigDecimal> values) {
+ BigDecimal total = null;
+ for (BigDecimal value : values) {
+ if (value != null) total = HolidayNumberUtils.sumNullable(total, value);
+ }
+ return total;
+ }
+
+ private HolidayTrendChartService.ChartCategory category(String name, BigDecimal value) {
+ HolidayTrendChartService.ChartCategory category = new HolidayTrendChartService.ChartCategory();
+ category.setName(name);
+ category.setValue(value == null ? BigDecimal.ZERO : value);
+ return category;
+ }
+ }
+
+ private static class ObservationData {
+ private final List<BigDecimal> speed;
+ private final List<BigDecimal> congestion;
+
+ private ObservationData(List<BigDecimal> speed, List<BigDecimal> congestion) {
+ this.speed = speed;
+ this.congestion = congestion;
+ }
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java
new file mode 100644
index 0000000..ccca074
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayTrendWordService.java
@@ -0,0 +1,535 @@
+package com.trafficaudit.holiday.service;
+
+import org.springframework.stereotype.Service;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+@Service
+public class HolidayTrendWordService {
+
+ private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ private static final String DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main";
+ private static final String WORD_DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
+ private static final String REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+ private static final String PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
+
+ public byte[] generate(byte[] templateBytes, int year, List<DocumentBlock> blocks) {
+ try {
+ byte[] documentXml = readEntry(templateBytes, "word/document.xml");
+ Document document = parse(documentXml);
+ Element body = firstElement(document.getDocumentElement(), "body");
+ if (body == null) throw new IllegalArgumentException("璧板娍鍥炬ā鏉跨己灏戞鏂�");
+
+ TemplateParts parts = TemplateParts.from(document, body);
+ Map<String, byte[]> media = new LinkedHashMap<>();
+ List<ImageReference> imageReferences = new ArrayList<>();
+ int imageIndex = 1;
+ for (DocumentBlock block : blocks) {
+ if (!(block instanceof ChartBlock)) continue;
+ String imageName = "image" + imageIndex++ + ".png";
+ imageReferences.add(new ImageReference("rId" + (100 + imageIndex - 2),
+ "media/" + imageName, ((ChartBlock) block).getPng()));
+ media.put("word/media/" + imageName, ((ChartBlock) block).getPng());
+ }
+
+ Element sectPr = directChild(body, "sectPr");
+ List<Node> removals = directChildren(body);
+ for (Node node : removals) {
+ if (node != sectPr) body.removeChild(node);
+ }
+
+ appendClone(body, parts.titleLine, year + "骞粹�滃崄涓�鈥濆亣鏈熷叏鐪佺患鍚堣繍杈撳娴侀噺", sectPr);
+ appendClone(body, parts.subtitleLine, null, sectPr);
+ appendClone(body, parts.titleBlank, null, sectPr);
+
+ int chartCursor = 0;
+ int chartsOnPage = 0;
+ for (int blockIndex = 0; blockIndex < blocks.size(); blockIndex++) {
+ DocumentBlock block = blocks.get(blockIndex);
+ if (block instanceof PageBreakBlock) {
+ appendPageBreak(body, sectPr);
+ chartsOnPage = 0;
+ } else if (block instanceof SectionBlock) {
+ appendClone(body, parts.sectionTemplate, ((SectionBlock) block).getText(), sectPr, true);
+ } else if (block instanceof ChartBlock) {
+ ChartBlock chart = (ChartBlock) block;
+ Element titleTemplate = chart.isCompact()
+ ? parts.compactTitleTemplate : parts.lineTitleTemplate;
+ Element imageTemplate = chart.isCompact()
+ ? parts.compactImageTemplate : parts.lineImageTemplate;
+ appendClone(body, titleTemplate, chart.getTitle(), sectPr);
+ Element imageParagraph = clone(imageTemplate);
+ ImageReference imageReference = imageReferences.get(chartCursor++);
+ bindImage(imageParagraph, imageReference, chart.getTitle(), chartCursor,
+ ((ChartBlock) block).getImageSize());
+ body.insertBefore(imageParagraph, sectPr);
+ appendClone(body, parts.chartBlank, null, sectPr);
+ chartsOnPage++;
+ boolean firstChartPageBreak = chartCursor == 1
+ && needsAutomaticPageBreak(blocks, blockIndex + 1);
+ if (firstChartPageBreak
+ || chartsOnPage >= 2
+ && needsAutomaticPageBreak(blocks, blockIndex + 1)) {
+ appendPageBreak(body, sectPr);
+ chartsOnPage = 0;
+ }
+ }
+ }
+
+ byte[] updatedDocument = serialize(document);
+ byte[] updatedRels = buildRelationships(templateBytes, imageReferences);
+ Map<String, byte[]> replacements = new HashMap<>();
+ replacements.put("word/document.xml", updatedDocument);
+ replacements.put("word/_rels/document.xml.rels", updatedRels);
+ replacements.putAll(media);
+ return rewriteZip(templateBytes, replacements);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("璧板娍鍥炬枃妗g敓鎴愬け璐�: " + e.getMessage(), e);
+ }
+ }
+
+ private byte[] buildRelationships(byte[] templateBytes, List<ImageReference> imageReferences) throws Exception {
+ Document rels = parse(readEntry(templateBytes, "word/_rels/document.xml.rels"));
+ Element root = rels.getDocumentElement();
+ NodeList children = root.getChildNodes();
+ List<Node> removals = new ArrayList<>();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node node = children.item(i);
+ if (node instanceof Element) {
+ Element element = (Element) node;
+ if ("Relationship".equals(element.getLocalName())
+ && element.getAttribute("Type").endsWith("/image")) {
+ removals.add(node);
+ }
+ }
+ }
+ for (Node node : removals) root.removeChild(node);
+ for (ImageReference reference : imageReferences) {
+ Element relationship = rels.createElementNS(PACKAGE_REL_NS, "Relationship");
+ relationship.setAttribute("Id", reference.getRelationshipId());
+ relationship.setAttribute("Type",
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image");
+ relationship.setAttribute("Target", reference.getTarget());
+ root.appendChild(relationship);
+ }
+ return serialize(rels);
+ }
+
+ private void bindImage(Element paragraph, ImageReference reference, String title, int index,
+ ImageSize imageSize) {
+ Element blip = firstElement(paragraph, "blip");
+ if (blip == null) throw new IllegalArgumentException("璧板娍鍥炬ā鏉垮浘鐗囪妭鐐圭己澶�");
+ blip.setAttributeNS(REL_NS, "r:embed", reference.getRelationshipId());
+ Element srcRect = firstElement(paragraph, "srcRect");
+ if (srcRect != null) srcRect.getParentNode().removeChild(srcRect);
+ resizeDrawing(paragraph, imageSize);
+ Element docPr = firstElement(paragraph, "docPr");
+ if (docPr != null) {
+ docPr.setAttribute("id", String.valueOf(1000 + index));
+ docPr.setAttribute("name", title);
+ docPr.setAttribute("descr", title);
+ }
+ }
+
+ private void resizeDrawing(Element paragraph, ImageSize imageSize) {
+ if (imageSize == null) return;
+ NodeList extents = paragraph.getElementsByTagNameNS(WORD_DRAWING_NS, "extent");
+ for (int i = 0; i < extents.getLength(); i++) {
+ Element extent = (Element) extents.item(i);
+ extent.setAttribute("cx", String.valueOf(imageSize.getWidth()));
+ extent.setAttribute("cy", String.valueOf(imageSize.getHeight()));
+ }
+ NodeList transforms = paragraph.getElementsByTagNameNS(DRAWING_NS, "xfrm");
+ for (int i = 0; i < transforms.getLength(); i++) {
+ Element transform = (Element) transforms.item(i);
+ Element extent = firstElement(transform, "ext");
+ if (extent == null) continue;
+ extent.setAttribute("cx", String.valueOf(imageSize.getWidth()));
+ extent.setAttribute("cy", String.valueOf(imageSize.getHeight()));
+ }
+ }
+
+ private void appendClone(Element body, Element template, String text, Element before) {
+ appendClone(body, template, text, before, false);
+ }
+
+ private void appendClone(Element body, Element template, String text, Element before,
+ boolean removeNumbering) {
+ if (template == null) return;
+ Element copy = clone(template);
+ if (removeNumbering) removeNumbering(copy);
+ if (text != null) setParagraphText(copy, text);
+ body.insertBefore(copy, before);
+ }
+
+ private void removeNumbering(Element paragraph) {
+ NodeList numbered = paragraph.getElementsByTagNameNS(WORD_NS, "numPr");
+ while (numbered.getLength() > 0) {
+ Node node = numbered.item(0);
+ node.getParentNode().removeChild(node);
+ }
+ }
+
+ private boolean needsAutomaticPageBreak(List<DocumentBlock> blocks, int nextIndex) {
+ if (nextIndex >= blocks.size()) return false;
+ return !(blocks.get(nextIndex) instanceof PageBreakBlock);
+ }
+
+ private void appendPageBreak(Element body, Element before) {
+ Document document = body.getOwnerDocument();
+ Element paragraph = document.createElementNS(WORD_NS, "w:p");
+ Element run = document.createElementNS(WORD_NS, "w:r");
+ Element br = document.createElementNS(WORD_NS, "w:br");
+ br.setAttributeNS(WORD_NS, "w:type", "page");
+ run.appendChild(br);
+ paragraph.appendChild(run);
+ body.insertBefore(paragraph, before);
+ }
+
+ private void setParagraphText(Element paragraph, String text) {
+ Element pPr = firstDirectChild(paragraph, "pPr");
+ Element firstRun = firstDirectChild(paragraph, "r");
+ Element rPr = firstRun == null ? null : firstDirectChild(firstRun, "rPr");
+ List<Node> removals = new ArrayList<>();
+ Node child = paragraph.getFirstChild();
+ while (child != null) {
+ Node next = child.getNextSibling();
+ if (child != pPr) removals.add(child);
+ child = next;
+ }
+ for (Node node : removals) paragraph.removeChild(node);
+ Element run = paragraph.getOwnerDocument().createElementNS(WORD_NS, "w:r");
+ if (rPr != null) run.appendChild(rPr.cloneNode(true));
+ Element t = paragraph.getOwnerDocument().createElementNS(WORD_NS, "w:t");
+ t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
+ t.setTextContent(text);
+ run.appendChild(t);
+ paragraph.appendChild(run);
+ }
+
+ private Element clone(Element source) {
+ return (Element) source.cloneNode(true);
+ }
+
+ private byte[] rewriteZip(byte[] source, Map<String, byte[]> replacements) throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ Map<String, Boolean> seen = new HashMap<>();
+ try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(source));
+ ZipOutputStream zipOutput = new ZipOutputStream(output)) {
+ ZipEntry entry;
+ byte[] buffer = new byte[8192];
+ while ((entry = input.getNextEntry()) != null) {
+ seen.put(entry.getName(), Boolean.TRUE);
+ byte[] replacement = replacements.get(entry.getName());
+ if (isGeneratedChartImage(entry.getName()) && replacement == null) continue;
+ zipOutput.putNextEntry(new ZipEntry(entry.getName()));
+ if (replacement != null) {
+ zipOutput.write(replacement);
+ } else {
+ int length;
+ while ((length = input.read(buffer)) > 0) {
+ zipOutput.write(buffer, 0, length);
+ }
+ }
+ zipOutput.closeEntry();
+ }
+ for (Map.Entry<String, byte[]> replacement : replacements.entrySet()) {
+ if (Boolean.TRUE.equals(seen.get(replacement.getKey()))) continue;
+ zipOutput.putNextEntry(new ZipEntry(replacement.getKey()));
+ zipOutput.write(replacement.getValue());
+ zipOutput.closeEntry();
+ }
+ }
+ return output.toByteArray();
+ }
+
+ private boolean isGeneratedChartImage(String entryName) {
+ return entryName != null && entryName.matches("word/media/image[0-9]+\\.png");
+ }
+
+ private byte[] readEntry(byte[] zipBytes, String entryName) throws Exception {
+ try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
+ ZipEntry entry;
+ while ((entry = input.getNextEntry()) != null) {
+ if (entryName.equals(entry.getName())) return readAll(input);
+ }
+ }
+ throw new IllegalArgumentException("妯℃澘涓己灏� " + entryName);
+ }
+
+ private Document parse(byte[] xml) throws Exception {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
+ }
+
+ private byte[] serialize(Document document) throws Exception {
+ Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+ transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ transformer.transform(new DOMSource(document), new StreamResult(output));
+ return output.toByteArray();
+ }
+
+ private byte[] readAll(InputStream input) throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int length;
+ while ((length = input.read(buffer)) > 0) {
+ output.write(buffer, 0, length);
+ }
+ return output.toByteArray();
+ }
+
+ private Element firstElement(Element parent, String localName) {
+ NodeList nodes = parent.getElementsByTagNameNS("*", localName);
+ if (nodes.getLength() > 0) return (Element) nodes.item(0);
+ if (DRAWING_NS.equals(parent.getNamespaceURI()) && parent.getLocalName() != null
+ && localName.equals(parent.getLocalName())) return parent;
+ NodeList children = parent.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node node = children.item(i);
+ if (node instanceof Element) {
+ Element found = firstElement((Element) node, localName);
+ if (found != null) return found;
+ }
+ }
+ return null;
+ }
+
+ private Element firstDirectChild(Element parent, String localName) {
+ Node node = parent.getFirstChild();
+ while (node != null) {
+ if (node instanceof Element && localName.equals(localName(node))) return (Element) node;
+ node = node.getNextSibling();
+ }
+ return null;
+ }
+
+ private Element directChild(Element parent, String localName) {
+ return firstDirectChild(parent, localName);
+ }
+
+ private List<Node> directChildren(Element parent) {
+ List<Node> result = new ArrayList<>();
+ Node node = parent.getFirstChild();
+ while (node != null) {
+ result.add(node);
+ node = node.getNextSibling();
+ }
+ return result;
+ }
+
+ private String localName(Node node) {
+ return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName();
+ }
+
+ public static class DocumentBlock {
+ }
+
+ public static class SectionBlock extends DocumentBlock {
+ private final String text;
+
+ public SectionBlock(String text) {
+ this.text = text;
+ }
+
+ public String getText() {
+ return text;
+ }
+ }
+
+ public static class PageBreakBlock extends DocumentBlock {
+ }
+
+ public static class ChartBlock extends DocumentBlock {
+ private final String title;
+ private final byte[] png;
+ private final boolean compact;
+ private final ImageSize imageSize;
+
+ public ChartBlock(String title, byte[] png) {
+ this(title, png, false);
+ }
+
+ public ChartBlock(String title, byte[] png, boolean compact) {
+ this(title, png, compact, null);
+ }
+
+ public ChartBlock(String title, byte[] png, boolean compact, ImageSize imageSize) {
+ this.title = title;
+ this.png = png;
+ this.compact = compact;
+ this.imageSize = imageSize;
+ }
+
+ public ChartBlock(String title, byte[] png, ImageSize imageSize) {
+ this(title, png, false, imageSize);
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public byte[] getPng() {
+ return png;
+ }
+
+ public boolean isCompact() {
+ return compact;
+ }
+
+ public ImageSize getImageSize() {
+ return imageSize;
+ }
+ }
+
+ public static class ImageSize {
+ private final long width;
+ private final long height;
+
+ public ImageSize(long width, long height) {
+ this.width = width;
+ this.height = height;
+ }
+
+ public long getWidth() {
+ return width;
+ }
+
+ public long getHeight() {
+ return height;
+ }
+ }
+
+ private static class ImageReference {
+ private final String relationshipId;
+ private final String target;
+ private final byte[] image;
+
+ private ImageReference(String relationshipId, String target, byte[] image) {
+ this.relationshipId = relationshipId;
+ this.target = target;
+ this.image = image;
+ }
+
+ private String getRelationshipId() {
+ return relationshipId;
+ }
+
+ private String getTarget() {
+ return target;
+ }
+
+ private byte[] getImage() {
+ return image;
+ }
+ }
+
+ private static class TemplateParts {
+ private Element titleLine;
+ private Element subtitleLine;
+ private Element titleBlank;
+ private Element sectionTemplate;
+ private Element sectionBlank;
+ private Element lineTitleTemplate;
+ private Element lineImageTemplate;
+ private Element compactTitleTemplate;
+ private Element compactImageTemplate;
+ private Element chartBlank;
+
+ private static TemplateParts from(Document document, Element body) {
+ TemplateParts parts = new TemplateParts();
+ List<Element> paragraphs = directParagraphs(body);
+ parts.titleLine = byText(paragraphs, "2026骞粹�滃崄涓�鈥濆亣鏈熷叏鐪佺患鍚堣繍杈撳娴侀噺");
+ parts.subtitleLine = byText(paragraphs, "璧� 鍔� 鍥�");
+ parts.sectionTemplate = byText(paragraphs, "鎬讳綋鎯呭喌");
+ parts.lineTitleTemplate = byText(paragraphs, "2026骞粹�滃崄涓�鈥濆亣鏈熷叏鐪佽法鍖哄煙浜哄憳娴佸姩閲忔儏鍐�");
+ parts.lineImageTemplate = imageAfter(paragraphs, parts.lineTitleTemplate);
+ parts.compactTitleTemplate = byText(paragraphs, "鈥滃崄涓�鈥濆亣鏈熷悇浜ら�氳繍杈撴柟寮忓杩愰噺鍗犳瘮鎯呭喌");
+ parts.compactImageTemplate = imageAfter(paragraphs, parts.compactTitleTemplate);
+ parts.titleBlank = nextParagraph(paragraphs, parts.subtitleLine);
+ parts.sectionBlank = nextParagraph(paragraphs, parts.sectionTemplate);
+ parts.chartBlank = afterParagraph(paragraphs, parts.lineImageTemplate);
+ if (parts.titleLine == null || parts.subtitleLine == null || parts.sectionTemplate == null
+ || parts.lineTitleTemplate == null || parts.lineImageTemplate == null
+ || parts.compactTitleTemplate == null || parts.compactImageTemplate == null) {
+ throw new IllegalArgumentException("璧板娍鍥炬ā鏉跨粨鏋勪笉瀹屾暣");
+ }
+ return parts;
+ }
+
+ private static List<Element> directParagraphs(Element body) {
+ List<Element> result = new ArrayList<>();
+ Node node = body.getFirstChild();
+ while (node != null) {
+ if (node instanceof Element && "p".equals(((Element) node).getLocalName())) {
+ result.add((Element) node);
+ }
+ node = node.getNextSibling();
+ }
+ return result;
+ }
+
+ private static Element byText(List<Element> paragraphs, String text) {
+ for (Element paragraph : paragraphs) {
+ if (text.equals(paragraphText(paragraph))) return paragraph;
+ }
+ return null;
+ }
+
+ private static Element imageAfter(List<Element> paragraphs, Element anchor) {
+ int index = paragraphs.indexOf(anchor);
+ if (index < 0) return null;
+ for (int i = index + 1; i < paragraphs.size(); i++) {
+ if (firstDescendant(paragraphs.get(i), "blip") != null) return paragraphs.get(i);
+ }
+ return null;
+ }
+
+ private static Element nextParagraph(List<Element> paragraphs, Element anchor) {
+ int index = paragraphs.indexOf(anchor);
+ return index >= 0 && index + 1 < paragraphs.size() ? paragraphs.get(index + 1) : null;
+ }
+
+ private static Element afterParagraph(List<Element> paragraphs, Element anchor) {
+ int index = paragraphs.indexOf(anchor);
+ return index >= 0 && index + 1 < paragraphs.size() ? paragraphs.get(index + 1) : null;
+ }
+
+ private static Element firstDescendant(Element parent, String localName) {
+ NodeList nodes = parent.getElementsByTagNameNS("*", localName);
+ return nodes.getLength() == 0 ? null : (Element) nodes.item(0);
+ }
+
+ private static String paragraphText(Element paragraph) {
+ StringBuilder builder = new StringBuilder();
+ NodeList nodes = paragraph.getElementsByTagNameNS(WORD_NS, "t");
+ for (int i = 0; i < nodes.getLength(); i++) {
+ builder.append(nodes.item(i).getTextContent());
+ }
+ return builder.toString();
+ }
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java
new file mode 100644
index 0000000..7823197
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentService.java
@@ -0,0 +1,177 @@
+package com.trafficaudit.holiday.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
+import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
+import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Service
+public class HolidayVehicleAdjustmentService {
+
+ private static final int REASON_MAX_LENGTH = 500;
+
+ @Resource
+ private HolidayVehicleAdjustmentMapper adjustmentMapper;
+ @Resource
+ private HolidayVehicleFlowMapper vehicleFlowMapper;
+
+ public List<HolidayVehicleAdjustment> list(Integer year) {
+ List<HolidayVehicleAdjustment> rows = adjustmentMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleAdjustment>()
+ .eq(year != null, HolidayVehicleAdjustment::getYear, year)
+ .orderByDesc(HolidayVehicleAdjustment::getYear)
+ .orderByAsc(HolidayVehicleAdjustment::getFlowDate));
+ if (rows.isEmpty()) return rows;
+
+ Set<Integer> years = new HashSet<>();
+ for (HolidayVehicleAdjustment row : rows) {
+ if (row.getYear() != null) years.add(row.getYear());
+ }
+ if (years.isEmpty()) return rows;
+
+ List<HolidayVehicleFlow> sourceRows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL)
+ .eq(HolidayVehicleFlow::getVehicleType, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER)
+ .in(HolidayVehicleFlow::getYear, years));
+ Map<String, BigDecimal> originalValues = new HashMap<>();
+ for (HolidayVehicleFlow sourceRow : sourceRows) {
+ if (sourceRow.getYear() == null || sourceRow.getFlowDate() == null || sourceRow.getVehicleCount() == null) {
+ continue;
+ }
+ originalValues.put(sourceKey(sourceRow.getYear(), sourceRow.getFlowDate()), sourceRow.getVehicleCount());
+ }
+ for (HolidayVehicleAdjustment row : rows) {
+ if (row.getYear() != null && row.getFlowDate() != null) {
+ row.setOriginalValue(originalValues.get(sourceKey(row.getYear(), row.getFlowDate())));
+ }
+ }
+ return rows;
+ }
+
+ public BigDecimal findOriginalValue(Integer year, LocalDate flowDate) {
+ if (year == null || flowDate == null || flowDate.getYear() != year) {
+ throw new IllegalArgumentException("骞翠唤鍜屾棩鏈熶笉姝g‘");
+ }
+ HolidayVehicleFlow sourceRow = vehicleFlowMapper.selectOne(
+ new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)
+ .eq(HolidayVehicleFlow::getFlowDate, flowDate)
+ .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL)
+ .eq(HolidayVehicleFlow::getVehicleType, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER)
+ .last("limit 1"));
+ return sourceRow == null ? null : sourceRow.getVehicleCount();
+ }
+
+ @Transactional
+ public HolidayVehicleAdjustment save(Map<String, Object> params) {
+ Long id = longValue(params.get("id"));
+ Integer year = intValue(params.get("year"));
+ LocalDate flowDate = dateValue(params.get("flowDate"));
+ BigDecimal adjustedValue = decimalValue(params.get("adjustedValue"));
+ String reason = textValue(params.get("reason"));
+
+ if (year == null || year < 2000 || year > 2100) {
+ throw new IllegalArgumentException("骞翠唤涓嶆纭�");
+ }
+ if (year >= LocalDate.now().getYear()) {
+ throw new IllegalArgumentException("寰�骞村浗鐪侀亾鏁版嵁璋冩暣浠呯淮鎶ゅ巻鍙插勾浠�");
+ }
+ if (flowDate == null || flowDate.getYear() != year) {
+ throw new IllegalArgumentException("鏃ユ湡蹇呴』灞炰簬鎵�閫夊勾浠�");
+ }
+ if (adjustedValue == null || adjustedValue.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("璋冩暣鍚庣殑鍥界渷閬撹溅娴侀噺涓嶈兘灏忎簬 0");
+ }
+ if (reason.isEmpty()) {
+ throw new IllegalArgumentException("璋冩暣鍘熷洜涓嶈兘涓虹┖");
+ }
+ if (reason.length() > REASON_MAX_LENGTH) {
+ throw new IllegalArgumentException("璋冩暣鍘熷洜涓嶈兘瓒呰繃 " + REASON_MAX_LENGTH + " 涓瓧绗�");
+ }
+
+ HolidayVehicleAdjustment sameKey = adjustmentMapper.selectOne(
+ new LambdaQueryWrapper<HolidayVehicleAdjustment>()
+ .eq(HolidayVehicleAdjustment::getYear, year)
+ .eq(HolidayVehicleAdjustment::getFlowDate, flowDate)
+ .last("limit 1"));
+ HolidayVehicleAdjustment entity;
+ if (id == null) {
+ entity = sameKey == null ? new HolidayVehicleAdjustment() : sameKey;
+ } else {
+ entity = adjustmentMapper.selectById(id);
+ if (entity == null) {
+ throw new IllegalArgumentException("璋冩暣璁板綍涓嶅瓨鍦�");
+ }
+ if (sameKey != null && !sameKey.getId().equals(id)) {
+ throw new IllegalArgumentException("璇ュ勾浠藉拰鏃ユ湡宸插瓨鍦ㄨ皟鏁磋褰�");
+ }
+ }
+
+ if (entity.getId() == null) {
+ entity.setCreatedAt(LocalDateTime.now());
+ }
+ entity.setYear(year);
+ entity.setFlowDate(flowDate);
+ entity.setAdjustedValue(HolidayNumberUtils.normalize(adjustedValue));
+ entity.setReason(reason);
+ entity.setUpdatedAt(LocalDateTime.now());
+ if (entity.getId() == null) {
+ adjustmentMapper.insert(entity);
+ } else {
+ adjustmentMapper.updateById(entity);
+ }
+ return entity;
+ }
+
+ @Transactional
+ public void delete(Long id) {
+ if (id == null) {
+ throw new IllegalArgumentException("缂哄皯璁板綍 id");
+ }
+ adjustmentMapper.deleteById(id);
+ }
+
+ private String sourceKey(Integer year, LocalDate flowDate) {
+ return year + "|" + flowDate;
+ }
+
+ private Long longValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return Long.valueOf(value.toString().trim());
+ }
+
+ private Integer intValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return Integer.valueOf(value.toString().trim());
+ }
+
+ private BigDecimal decimalValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return HolidayNumberUtils.decimalValue(value);
+ }
+
+ private LocalDate dateValue(Object value) {
+ if (value == null || value.toString().trim().isEmpty()) return null;
+ return LocalDate.parse(value.toString().trim());
+ }
+
+ private String textValue(Object value) {
+ return value == null ? "" : value.toString().trim();
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
new file mode 100644
index 0000000..cdf5e8a
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/holiday/service/HolidayWorkbookService.java
@@ -0,0 +1,1083 @@
+package com.trafficaudit.holiday.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.entity.HolidayPassengerCoefficient;
+import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
+import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
+import com.trafficaudit.holiday.mapper.HolidayPassengerCoefficientMapper;
+import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.DataFormatter;
+import org.apache.poi.ss.usermodel.FormulaEvaluator;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class HolidayWorkbookService {
+
+ private static final Map<String, String> PASSENGER_SHEET_MODE = new LinkedHashMap<>();
+ private static final int FIRST_DATA_ROW_INDEX = 2;
+ private static final int DAY_ROW_SCAN_LIMIT = 30;
+ private static final int[] REFERENCE_YEARS = {2024, 2025, 2026};
+
+ static {
+ PASSENGER_SHEET_MODE.put("閾佽矾", HolidayConstants.MODE_RAIL);
+ PASSENGER_SHEET_MODE.put("姘戣埅", HolidayConstants.MODE_CIVIL_AVIATION);
+ PASSENGER_SHEET_MODE.put("閬撹矾", HolidayConstants.MODE_ROAD);
+ PASSENGER_SHEET_MODE.put("姘磋矾", HolidayConstants.MODE_WATER);
+ }
+
+ @Resource
+ private HolidayConfigService configService;
+ @Resource
+ private HolidayPassengerFlowMapper passengerFlowMapper;
+ @Resource
+ private HolidayVehicleFlowMapper vehicleFlowMapper;
+ @Resource
+ private HolidayPassengerCoefficientMapper coefficientMapper;
+
+ @Value("${holiday.reference-path:docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鏁版嵁.xlsx}")
+ private String referencePath;
+
+ public File resolveReferenceFile() {
+ List<File> candidates = new ArrayList<>();
+ File configured = new File(referencePath);
+ candidates.add(configured);
+ if (!configured.isAbsolute()) {
+ candidates.add(new File(System.getProperty("user.dir"), referencePath));
+ candidates.add(new File(System.getProperty("user.dir"), "../" + referencePath));
+ }
+ for (File candidate : candidates) {
+ try {
+ if (candidate.exists() && candidate.isFile()) {
+ return candidate.getCanonicalFile();
+ }
+ } catch (Exception ignored) {
+ // Keep checking the remaining candidate paths.
+ }
+ }
+ return configured;
+ }
+
+ public String referenceFileName() {
+ return resolveReferenceFile().getName();
+ }
+
+ @Transactional
+ public ImportSummary importWorkbook(MultipartFile file, int year, String holidayType, boolean allowHistoricalOverwrite) {
+ if (file == null || file.isEmpty()) {
+ throw new IllegalArgumentException("璇烽�夋嫨瑕佸鍏ョ殑 Excel 鏂囦欢");
+ }
+ List<HolidayCalendar> holidayDays = configService.findHolidayDays(year, holidayType);
+ if (holidayDays.isEmpty()) {
+ throw new IllegalArgumentException("鎵�閫夊勾浠藉拰鑺傚亣鏃ュ皻鏈厤缃棩鏈燂紝璇峰厛鍦ㄧ郴缁熼厤缃腑缁存姢鑺傚亣鏃�");
+ }
+ Map<Integer, LocalDate> dateByDay = new LinkedHashMap<>();
+ for (HolidayCalendar day : holidayDays) {
+ dateByDay.put(day.getDayIndex(), day.getHolidayDate());
+ }
+
+ if (!allowHistoricalOverwrite) {
+ ImportSummary historicalSummary = detectHistoricalChanges(file, year, dateByDay);
+ if (historicalSummary.isConfirmationRequired()) {
+ return historicalSummary;
+ }
+ }
+
+ ImportSummary summary = new ImportSummary();
+ try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
+ FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
+ importPassengerSheets(workbook, evaluator, year, holidayType, dateByDay, summary);
+ importVehicleSheets(workbook, evaluator, year, dateByDay, summary);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("宸ヤ綔绨胯В鏋愬け璐�: " + e.getMessage(), e);
+ }
+ return summary;
+ }
+
+ private ImportSummary detectHistoricalChanges(MultipartFile file, int year, Map<Integer, LocalDate> dateByDay) {
+ ImportSummary summary = new ImportSummary();
+ try (InputStream in = file.getInputStream(); Workbook workbook = WorkbookFactory.create(in)) {
+ FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
+ int latestDay = findLatestIncomingDay(workbook, evaluator, year, dateByDay);
+ if (latestDay <= 1) {
+ return summary;
+ }
+ Map<LocalDate, Map<String, BigDecimal>> existingPassenger = loadPassengerByDate(year);
+ Map<LocalDate, Map<String, BigDecimal>> existingVehicle = loadVehicleByDate(year);
+ detectPassengerHistory(workbook, evaluator, year, dateByDay, latestDay, existingPassenger, summary);
+ detectVehicleHistory(workbook, evaluator, year, dateByDay, latestDay, existingVehicle, summary);
+ if (!summary.getHistoricalChanges().isEmpty()) {
+ summary.setConfirmationRequired(true);
+ }
+ } catch (Exception e) {
+ throw new IllegalArgumentException("宸ヤ綔绨胯В鏋愬け璐�: " + e.getMessage(), e);
+ }
+ return summary;
+ }
+
+ private int findLatestIncomingDay(Workbook workbook, FormulaEvaluator evaluator, int year, Map<Integer, LocalDate> dateByDay) {
+ int latest = 0;
+ for (Integer dayIndex : dateByDay.keySet()) {
+ if (hasPassengerValue(workbook, evaluator, year, dayIndex)
+ || hasVehicleValue(workbook, evaluator, year, dayIndex)) {
+ latest = Math.max(latest, dayIndex);
+ }
+ }
+ return latest;
+ }
+
+ private boolean hasPassengerValue(Workbook workbook, FormulaEvaluator evaluator, int year, int dayIndex) {
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) continue;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) continue;
+ Row row = findDayRow(sheet, evaluator, dayIndex);
+ if (row == null) continue;
+ BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
+ if (value != null) return true;
+ }
+ return false;
+ }
+
+ private boolean hasVehicleValue(Workbook workbook, FormulaEvaluator evaluator, int year, int dayIndex) {
+ Sheet expressway = workbook.getSheet("楂橀�熷叕璺�");
+ if (expressway != null) {
+ Integer yearColumn = findYearColumn(expressway, evaluator, year, 2, 7);
+ if (yearColumn != null) {
+ Row row = findDayRow(expressway, evaluator, dayIndex);
+ if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
+ }
+ }
+ Sheet nationalProvincial = workbook.getSheet("鏅�氬浗鐪侀亾");
+ if (nationalProvincial != null) {
+ Integer yearColumn = findYearColumn(nationalProvincial, evaluator, year, 3, 8);
+ if (yearColumn != null) {
+ Row row = findDayRow(nationalProvincial, evaluator, dayIndex);
+ if (row != null && numberValue(row.getCell(yearColumn), evaluator) != null) return true;
+ }
+ }
+ return false;
+ }
+
+ private void detectPassengerHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dateByDay, int latestDay,
+ Map<LocalDate, Map<String, BigDecimal>> existing,
+ ImportSummary summary) {
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) continue;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) continue;
+ for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
+ if (day.getKey() >= latestDay) continue;
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
+ if (incoming == null) continue;
+ Map<String, BigDecimal> existingDay = existing.get(day.getValue());
+ BigDecimal oldValue = existingDay == null ? null : existingDay.get(entry.getValue());
+ if (oldValue != null && !sameValue(oldValue, incoming)) {
+ summary.addHistoricalChange("绗�" + day.getKey() + "鏃� " + day.getValue() + " "
+ + entry.getKey() + "锛氬師鍊� " + formatChangeValue(oldValue) + "锛屽鍏ュ�� " + formatChangeValue(incoming));
+ }
+ }
+ }
+ }
+
+ private void detectVehicleHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dateByDay, int latestDay,
+ Map<LocalDate, Map<String, BigDecimal>> existing,
+ ImportSummary summary) {
+ detectVehicleSheetHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary,
+ "楂橀�熷叕璺�", HolidayConstants.ROAD_EXPRESSWAY, 2, 7);
+ detectVehicleSheetHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary,
+ "鏅�氬浗鐪侀亾", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, 3, 8);
+ detectComparisonBaseHistory(workbook, evaluator, year, dateByDay, latestDay, existing, summary);
+ }
+
+ private void detectVehicleSheetHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dateByDay, int latestDay,
+ Map<LocalDate, Map<String, BigDecimal>> existing,
+ ImportSummary summary, String sheetName, String roadCategory,
+ int startColumn, int endColumn) {
+ Sheet sheet = workbook.getSheet(sheetName);
+ if (sheet == null) return;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
+ if (yearColumn == null) return;
+ for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
+ if (day.getKey() >= latestDay) continue;
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ BigDecimal incoming = numberValue(row.getCell(yearColumn), evaluator);
+ if (incoming == null) continue;
+ Map<String, BigDecimal> existingDay = existing.get(day.getValue());
+ BigDecimal oldValue = existingDay == null ? null : existingDay.get(roadCategory);
+ if (oldValue != null && !sameValue(oldValue, incoming)) {
+ summary.addHistoricalChange("绗�" + day.getKey() + "鏃� " + day.getValue() + " "
+ + sheetName + "锛氬師鍊� " + formatChangeValue(oldValue) + "锛屽鍏ュ�� " + formatChangeValue(incoming));
+ }
+ }
+ }
+
+ private void detectComparisonBaseHistory(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dateByDay, int latestDay,
+ Map<LocalDate, Map<String, BigDecimal>> existingVehicle,
+ ImportSummary summary) {
+ if (!HolidayConstants.supportsComparisonBase(year)) return;
+ Sheet sheet = workbook.getSheet("鏅�氬浗鐪侀亾");
+ if (sheet == null) return;
+ Map<LocalDate, BigDecimal> existingBase = loadComparisonBaseByDate(year);
+ for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
+ if (day.getKey() >= latestDay) continue;
+ Map<String, BigDecimal> existingDay = existingVehicle.get(day.getValue());
+ if (existingDay == null || existingDay.get(HolidayConstants.ROAD_NATIONAL_PROVINCIAL) == null) continue;
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ BigDecimal incoming;
+ try {
+ incoming = numberValue(row.getCell(2), evaluator);
+ } catch (Exception e) {
+ summary.addError("鏅�氬浗鐪侀亾 绗�" + (row.getRowNum() + 1) + "琛岀幆姣斿熀鏈燂細" + e.getMessage());
+ continue;
+ }
+ BigDecimal oldValue = existingBase.get(day.getValue());
+ if (!sameNullableValue(oldValue, incoming)) {
+ summary.addHistoricalChange("绗�" + day.getKey() + "鏃� " + day.getValue() + " 鏅�氬浗鐪侀亾鐜瘮鍩烘湡锛氬師鍊� "
+ + nullableChangeValue(oldValue) + "锛屽鍏ュ�� " + nullableChangeValue(incoming));
+ }
+ }
+ }
+
+ private Map<LocalDate, Map<String, BigDecimal>> loadPassengerByDate(int year) {
+ Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
+ List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayPassengerFlow>().eq(HolidayPassengerFlow::getYear, year));
+ for (HolidayPassengerFlow row : rows) {
+ result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
+ .put(row.getTransportMode(), row.getPassengerCount());
+ }
+ return result;
+ }
+
+ private Map<LocalDate, Map<String, BigDecimal>> loadVehicleByDate(int year) {
+ Map<LocalDate, Map<String, BigDecimal>> result = new LinkedHashMap<>();
+ List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>().eq(HolidayVehicleFlow::getYear, year));
+ for (HolidayVehicleFlow row : rows) {
+ result.computeIfAbsent(row.getFlowDate(), key -> new LinkedHashMap<>())
+ .put(row.getRoadCategory(), row.getVehicleCount());
+ }
+ return result;
+ }
+
+ private Map<LocalDate, BigDecimal> loadComparisonBaseByDate(int year) {
+ Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
+ List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)
+ .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
+ for (HolidayVehicleFlow row : rows) result.put(row.getFlowDate(), row.getComparisonBase());
+ return result;
+ }
+
+ private boolean sameValue(BigDecimal left, BigDecimal right) {
+ return HolidayNumberUtils.sameValue(left, right);
+ }
+
+ private boolean sameNullableValue(BigDecimal left, BigDecimal right) {
+ if (left == null || right == null) return left == right;
+ return sameValue(left, right);
+ }
+
+ private String nullableChangeValue(BigDecimal value) {
+ return value == null ? "绌�" : formatChangeValue(value);
+ }
+
+ private String formatChangeValue(BigDecimal value) {
+ return HolidayNumberUtils.changeText(value);
+ }
+
+ @Transactional
+ public void seedReferenceDataIfNeeded() {
+ File reference = resolveReferenceFile();
+ if (!reference.exists() || !reference.isFile()) {
+ log.warn("Holiday reference workbook not found: {}", reference.getAbsolutePath());
+ return;
+ }
+ try (InputStream in = new FileInputStream(reference); Workbook workbook = WorkbookFactory.create(in)) {
+ FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
+ seedNationalDayCalendar();
+ ensurePreviousDayCalendarIfMissing();
+ for (int year : REFERENCE_YEARS) {
+ if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
+ .eq(HolidayPassengerFlow::getYear, year)) == 0) {
+ seedPassengerYear(workbook, evaluator, year);
+ }
+ if (vehicleFlowMapper.selectCount(new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)) == 0) {
+ seedVehicleYear(workbook, evaluator, year);
+ }
+ if (coefficientMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerCoefficient>()
+ .eq(HolidayPassengerCoefficient::getYear, year)) == 0) {
+ seedCoefficientYear(workbook, evaluator, year);
+ }
+ }
+ seedPreviousDayPassengerIfMissing(workbook, evaluator);
+ seedRoad2019IfMissing(workbook, evaluator);
+ } catch (Exception e) {
+ log.error("Failed to seed holiday reference data", e);
+ }
+ }
+
+ public byte[] exportWorkbook(int year, String holidayType) {
+ File reference = resolveReferenceFile();
+ if (!reference.exists() || !reference.isFile()) {
+ throw new IllegalArgumentException("鑺傚亣鏃ユā鏉挎枃浠朵笉瀛樺湪: " + reference.getAbsolutePath());
+ }
+ try (InputStream in = new FileInputStream(reference); XSSFWorkbook workbook = new XSSFWorkbook(in)) {
+ FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
+ fillPassengerSheets(workbook, evaluator, year, holidayType);
+ fillVehicleSheets(workbook, evaluator, year, holidayType);
+ fillCoefficients(workbook, evaluator, year, holidayType);
+ workbook.setForceFormulaRecalculation(true);
+ try {
+ evaluator.evaluateAll();
+ } catch (Exception e) {
+ log.warn("Workbook formula recalculation did not complete; Excel will recalculate on open", e);
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ workbook.write(out);
+ return out.toByteArray();
+ } catch (Exception e) {
+ throw new IllegalArgumentException("鑺傚亣鏃ユ暟鎹鍑哄け璐�: " + e.getMessage(), e);
+ }
+ }
+
+ private void importPassengerSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
+ String holidayType, Map<Integer, LocalDate> dateByDay,
+ ImportSummary summary) {
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) {
+ summary.addError(entry.getKey() + "锛氭壘涓嶅埌宸ヤ綔琛�");
+ continue;
+ }
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) {
+ summary.addError(entry.getKey() + "锛氭壘涓嶅埌 " + year + " 骞存暟鎹垪");
+ continue;
+ }
+ for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) {
+ summary.addError(entry.getKey() + " 绗�" + day.getKey() + "鏃ワ細宸ヤ綔琛ㄧ己灏戣琛�");
+ continue;
+ }
+ Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
+ if (rowDay == null || !rowDay.equals(day.getKey())) {
+ summary.addError(entry.getKey() + " 绗�" + (row.getRowNum() + 1) + "琛岋細绗嚑鏃ヤ笌鑺傚亣鏃ラ厤缃笉涓�鑷�");
+ continue;
+ }
+ BigDecimal value;
+ try {
+ value = numberValue(row.getCell(yearColumn), evaluator);
+ } catch (Exception e) {
+ summary.addError(entry.getKey() + " 绗�" + (row.getRowNum() + 1) + "琛岋細" + e.getMessage());
+ continue;
+ }
+ if (value == null) {
+ summary.incrementSkipped();
+ continue;
+ }
+ upsertPassenger(year, day.getValue(), entry.getValue(), value);
+ summary.incrementPassenger();
+ }
+ }
+ importPreviousDayPassengerRows(workbook, evaluator, year, holidayType, dateByDay, summary);
+ }
+
+ private void importPreviousDayPassengerRows(Workbook workbook, FormulaEvaluator evaluator, int year,
+ String holidayType, Map<Integer, LocalDate> dateByDay,
+ ImportSummary summary) {
+ LocalDate previousDate = previousDayDate(year, holidayType, dateByDay);
+ if (previousDate == null) return;
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ if (!HolidayConstants.MODE_RAIL.equals(entry.getValue())
+ && !HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
+ continue;
+ }
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) continue;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) continue;
+ Row row = findPreviousDayRow(sheet, evaluator);
+ if (row == null) continue;
+ BigDecimal value;
+ try {
+ value = numberValue(row.getCell(yearColumn), evaluator);
+ } catch (Exception e) {
+ summary.addError(entry.getKey() + " 鍓嶄竴鏃ヨ锛�" + e.getMessage());
+ continue;
+ }
+ if (value == null) {
+ summary.incrementSkipped();
+ continue;
+ }
+ upsertPassenger(year, previousDate, entry.getValue(), value);
+ summary.incrementPassenger();
+ }
+ }
+
+ private void importVehicleSheets(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dateByDay, ImportSummary summary) {
+ importVehicleSheet(workbook, evaluator, year, dateByDay, summary,
+ "楂橀�熷叕璺�", HolidayConstants.ROAD_EXPRESSWAY, HolidayConstants.VEHICLE_SMALL_PASSENGER, 2, 7, null);
+ importVehicleSheet(workbook, evaluator, year, dateByDay, summary,
+ "鏅�氬浗鐪侀亾", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER, 3, 8,
+ HolidayConstants.supportsComparisonBase(year) ? 2 : null);
+ }
+
+ private void importVehicleSheet(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dateByDay, ImportSummary summary,
+ String sheetName, String roadCategory, String vehicleType,
+ int startColumn, int endColumn, Integer comparisonBaseColumn) {
+ Sheet sheet = workbook.getSheet(sheetName);
+ if (sheet == null) {
+ summary.addError(sheetName + "锛氭壘涓嶅埌宸ヤ綔琛�");
+ return;
+ }
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
+ if (yearColumn == null) {
+ summary.addError(sheetName + "锛氭壘涓嶅埌 " + year + " 骞存暟鎹垪");
+ return;
+ }
+ for (Map.Entry<Integer, LocalDate> day : dateByDay.entrySet()) {
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) {
+ summary.addError(sheetName + " 绗�" + day.getKey() + "鏃ワ細宸ヤ綔琛ㄧ己灏戣琛�");
+ continue;
+ }
+ Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
+ if (rowDay == null || !rowDay.equals(day.getKey())) {
+ summary.addError(sheetName + " 绗�" + (row.getRowNum() + 1) + "琛岋細绗嚑鏃ヤ笌鑺傚亣鏃ラ厤缃笉涓�鑷�");
+ continue;
+ }
+ BigDecimal value;
+ try {
+ value = numberValue(row.getCell(yearColumn), evaluator);
+ } catch (Exception e) {
+ summary.addError(sheetName + " 绗�" + (row.getRowNum() + 1) + "琛岋細" + e.getMessage());
+ continue;
+ }
+ if (value == null) {
+ summary.incrementSkipped();
+ continue;
+ }
+ BigDecimal comparisonBase = null;
+ if (comparisonBaseColumn != null) {
+ try {
+ comparisonBase = numberValue(row.getCell(comparisonBaseColumn), evaluator);
+ } catch (Exception e) {
+ summary.addError(sheetName + " 绗�" + (row.getRowNum() + 1) + "琛岀幆姣斿熀鏈燂細" + e.getMessage());
+ continue;
+ }
+ }
+ upsertVehicle(year, day.getValue(), roadCategory, vehicleType, value, comparisonBase);
+ summary.incrementVehicle();
+ }
+ }
+
+ private void fillPassengerSheets(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) continue;
+ for (int year : REFERENCE_YEARS) {
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) continue;
+ Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
+ Map<LocalDate, BigDecimal> values = loadPassengerValues(year, entry.getValue());
+ for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
+ if (!values.containsKey(day.getValue())) continue;
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
+ }
+ LocalDate previousDate = previousDayDate(year, holidayType, dayDates);
+ Row previousRow = findPreviousDayRow(sheet, evaluator);
+ if (previousDate != null && previousRow != null) {
+ BigDecimal previousValue = values.get(previousDate);
+ if (previousValue != null) {
+ setNumeric(sheet, previousRow.getRowNum(), yearColumn, previousValue);
+ }
+ }
+ }
+ }
+ }
+
+ private void fillVehicleSheets(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
+ fillVehicleSheet(workbook, evaluator, "楂橀�熷叕璺�", HolidayConstants.ROAD_EXPRESSWAY, 2, 7, holidayType, null);
+ fillVehicleSheet(workbook, evaluator, "鏅�氬浗鐪侀亾", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, 3, 8, holidayType,
+ HolidayConstants.supportsComparisonBase(selectedYear) ? 2 : null);
+ }
+
+ private void fillVehicleSheet(XSSFWorkbook workbook, FormulaEvaluator evaluator, String sheetName,
+ String roadCategory, int startColumn, int endColumn, String holidayType,
+ Integer comparisonBaseColumn) {
+ Sheet sheet = workbook.getSheet(sheetName);
+ if (sheet == null) return;
+ for (int year : REFERENCE_YEARS) {
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
+ if (yearColumn == null) continue;
+ Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
+ Map<LocalDate, BigDecimal> values = loadVehicleValues(year, roadCategory);
+ for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
+ if (!values.containsKey(day.getValue())) continue;
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ setNumeric(sheet, row.getRowNum(), yearColumn, values.get(day.getValue()));
+ }
+ if (comparisonBaseColumn != null && HolidayConstants.supportsComparisonBase(year)) {
+ // Column C (comparison base) is written as an operator backup value only. The ring
+ // ratio in column E always compares the current day with the previous day row of
+ // the same year, so the base never changes the calculated percentage.
+ Map<LocalDate, BigDecimal> bases = loadComparisonBaseValues(year);
+ for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
+ if (!bases.containsKey(day.getValue())) continue;
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ setNumeric(sheet, row.getRowNum(), comparisonBaseColumn, bases.get(day.getValue()));
+ }
+ Integer previousRowIndex = null;
+ for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) {
+ previousRowIndex = null;
+ continue;
+ }
+ if (previousRowIndex != null) {
+ setFormula(sheet, row.getRowNum(), 4,
+ ringRatioFormula(row.getRowNum() + 1, previousRowIndex + 1));
+ }
+ previousRowIndex = row.getRowNum();
+ }
+ }
+ }
+ }
+
+ // Ring ratio = (current day value - previous day value) / previous day value, always taken
+ // from the active year column (D). This intentionally ignores the comparison base column.
+ static String ringRatioFormula(int excelRow, int previousExcelRow) {
+ return "=(D" + excelRow + "-D" + previousExcelRow + ")/D" + previousExcelRow;
+ }
+
+ private void fillCoefficients(XSSFWorkbook workbook, FormulaEvaluator evaluator, int selectedYear, String holidayType) {
+ fillCoefficientSheet(workbook, evaluator, "楂橀�熷叕璺�", 8, 10, holidayType);
+ fillCoefficientSheet(workbook, evaluator, "鏅�氬浗鐪侀亾", 9, 11, holidayType);
+ }
+
+ private void fillCoefficientSheet(XSSFWorkbook workbook, FormulaEvaluator evaluator, String sheetName,
+ int startColumn, int endColumn, String holidayType) {
+ Sheet sheet = workbook.getSheet(sheetName);
+ if (sheet == null) return;
+ for (int year : REFERENCE_YEARS) {
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
+ if (yearColumn == null) continue;
+ BigDecimal coefficient = configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY);
+ if (coefficient == null) continue;
+ Map<Integer, LocalDate> dayDates = dayDates(year, holidayType);
+ for (Integer dayIndex : dayDates.keySet()) {
+ setNumeric(sheet, FIRST_DATA_ROW_INDEX + dayIndex - 1, yearColumn, coefficient);
+ }
+ }
+ }
+
+ private void seedNationalDayCalendar() {
+ // Calendar is admin-managed after the first bootstrap. Re-adding missing day indexes on
+ // every start would resurrect days the admin deleted, so only seed a fresh table.
+ if (!configService.listHolidays(null, null).isEmpty()) {
+ return;
+ }
+ int[] calendarYears = {2019, 2024, 2025, 2026};
+ int[] lengths = {8, 7, 8, 8};
+ for (int i = 0; i < calendarYears.length; i++) {
+ int year = calendarYears[i];
+ List<HolidayCalendar> existing = configService.findHolidayDays(year, HolidayConstants.TYPE_NATIONAL_DAY);
+ Map<Integer, HolidayCalendar> byDay = new LinkedHashMap<>();
+ for (HolidayCalendar row : existing) {
+ byDay.put(row.getDayIndex(), row);
+ }
+ for (int day = 1; day <= lengths[i]; day++) {
+ if (byDay.containsKey(day)) continue;
+ HolidayCalendar calendar = new HolidayCalendar();
+ calendar.setYear(year);
+ calendar.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
+ calendar.setHolidayDate(LocalDate.of(year, 10, day));
+ calendar.setDayIndex(day);
+ calendar.setCreatedAt(LocalDateTime.now());
+ calendar.setUpdatedAt(LocalDateTime.now());
+ configService.saveHoliday(toMap(calendar));
+ }
+ if (year == 2019) {
+ continue;
+ }
+ HolidayCalendar previousDay = new HolidayCalendar();
+ previousDay.setYear(year);
+ previousDay.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
+ previousDay.setHolidayDate(LocalDate.of(year, 10, 1).minusDays(1));
+ previousDay.setDayIndex(0);
+ previousDay.setCreatedAt(LocalDateTime.now());
+ previousDay.setUpdatedAt(LocalDateTime.now());
+ configService.saveHoliday(toMap(previousDay));
+ }
+ }
+
+ private void ensurePreviousDayCalendarIfMissing() {
+ for (int year : REFERENCE_YEARS) {
+ if (configService.findPreviousDay(year, HolidayConstants.TYPE_NATIONAL_DAY) != null) {
+ continue;
+ }
+ LocalDate firstHolidayDate = firstDate(dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY));
+ if (firstHolidayDate == null) {
+ continue;
+ }
+ HolidayCalendar previousDay = new HolidayCalendar();
+ previousDay.setYear(year);
+ previousDay.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
+ previousDay.setHolidayDate(firstHolidayDate.minusDays(1));
+ previousDay.setDayIndex(0);
+ previousDay.setCreatedAt(LocalDateTime.now());
+ previousDay.setUpdatedAt(LocalDateTime.now());
+ configService.saveHoliday(toMap(previousDay));
+ }
+ }
+
+ private void seedPreviousDayPassengerIfMissing(Workbook workbook, FormulaEvaluator evaluator) {
+ for (int year : REFERENCE_YEARS) {
+ Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
+ LocalDate previousDate = previousDayDate(year, HolidayConstants.TYPE_NATIONAL_DAY, dayDates);
+ if (previousDate == null) continue;
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ if (!HolidayConstants.MODE_RAIL.equals(entry.getValue())
+ && !HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
+ continue;
+ }
+ if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
+ .eq(HolidayPassengerFlow::getYear, year)
+ .eq(HolidayPassengerFlow::getFlowDate, previousDate)
+ .eq(HolidayPassengerFlow::getTransportMode, entry.getValue())) > 0) {
+ continue;
+ }
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) continue;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) continue;
+ Row row = findPreviousDayRow(sheet, evaluator);
+ if (row == null) continue;
+ BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
+ if (value != null) upsertPassenger(year, previousDate, entry.getValue(), value);
+ }
+ }
+ }
+
+ private void seedRoad2019IfMissing(Workbook workbook, FormulaEvaluator evaluator) {
+ if (passengerFlowMapper.selectCount(new LambdaQueryWrapper<HolidayPassengerFlow>()
+ .eq(HolidayPassengerFlow::getYear, 2019)
+ .eq(HolidayPassengerFlow::getTransportMode, HolidayConstants.MODE_ROAD)) > 0) {
+ return;
+ }
+ Sheet sheet = workbook.getSheet("閬撹矾");
+ if (sheet == null) return;
+ Integer yearColumn = findYearColumn(sheet, evaluator, 2019, 2, 20);
+ if (yearColumn == null) return;
+ for (int rowIndex = 0; rowIndex <= Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT); rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) continue;
+ Integer dayIndex = parseDayIndex(row.getCell(0), evaluator);
+ if (dayIndex == null) continue;
+ BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
+ if (value != null) {
+ upsertPassenger(2019, LocalDate.of(2019, 10, dayIndex), HolidayConstants.MODE_ROAD, value);
+ }
+ }
+ }
+
+ private void seedPassengerYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
+ Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
+ if (dayDates.isEmpty()) return;
+ for (Map.Entry<String, String> entry : PASSENGER_SHEET_MODE.entrySet()) {
+ Sheet sheet = workbook.getSheet(entry.getKey());
+ if (sheet == null) continue;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, 2, 7);
+ if (yearColumn == null) continue;
+ for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
+ if (value != null) {
+ upsertPassenger(year, day.getValue(), entry.getValue(), value);
+ }
+ }
+ if (HolidayConstants.MODE_RAIL.equals(entry.getValue())
+ || HolidayConstants.MODE_CIVIL_AVIATION.equals(entry.getValue())) {
+ Row previousRow = findPreviousDayRow(sheet, evaluator);
+ LocalDate previousDate = previousDayDate(year, HolidayConstants.TYPE_NATIONAL_DAY, dayDates);
+ if (previousRow != null && previousDate != null) {
+ BigDecimal value = numberValue(previousRow.getCell(yearColumn), evaluator);
+ if (value != null) {
+ upsertPassenger(year, previousDate, entry.getValue(), value);
+ }
+ }
+ }
+ }
+ }
+
+ private void seedVehicleYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
+ Map<Integer, LocalDate> dayDates = dayDates(year, HolidayConstants.TYPE_NATIONAL_DAY);
+ if (dayDates.isEmpty()) return;
+ seedVehicleSheet(workbook, evaluator, year, dayDates, "楂橀�熷叕璺�",
+ HolidayConstants.ROAD_EXPRESSWAY, HolidayConstants.VEHICLE_SMALL_PASSENGER, 2, 7);
+ seedVehicleSheet(workbook, evaluator, year, dayDates, "鏅�氬浗鐪侀亾",
+ HolidayConstants.ROAD_NATIONAL_PROVINCIAL, HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER, 3, 8);
+ }
+
+ private void seedVehicleSheet(Workbook workbook, FormulaEvaluator evaluator, int year,
+ Map<Integer, LocalDate> dayDates, String sheetName,
+ String roadCategory, String vehicleType, int startColumn, int endColumn) {
+ Sheet sheet = workbook.getSheet(sheetName);
+ if (sheet == null) return;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
+ if (yearColumn == null) return;
+ for (Map.Entry<Integer, LocalDate> day : dayDates.entrySet()) {
+ Row row = findDayRow(sheet, evaluator, day.getKey());
+ if (row == null) continue;
+ BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
+ if (value != null) {
+ upsertVehicle(year, day.getValue(), roadCategory, vehicleType, value, null);
+ }
+ }
+ }
+
+ private void seedCoefficientYear(Workbook workbook, FormulaEvaluator evaluator, int year) {
+ List<BigDecimal> values = new ArrayList<>();
+ collectCoefficientValues(workbook, evaluator, "楂橀�熷叕璺�", year, 8, 10, values);
+ collectCoefficientValues(workbook, evaluator, "鏅�氬浗鐪侀亾", year, 9, 11, values);
+ if (values.isEmpty()) return;
+ BigDecimal sum = BigDecimal.ZERO;
+ for (BigDecimal value : values) {
+ sum = HolidayNumberUtils.normalize(sum.add(value));
+ }
+ BigDecimal coefficient = HolidayNumberUtils.divide(sum, BigDecimal.valueOf(values.size()));
+ upsertCoefficient(year, HolidayConstants.CATEGORY_HOLIDAY, coefficient);
+ upsertCoefficient(year, HolidayConstants.CATEGORY_NON_HOLIDAY, coefficient);
+ }
+
+ private void collectCoefficientValues(Workbook workbook, FormulaEvaluator evaluator, String sheetName,
+ int year, int startColumn, int endColumn, List<BigDecimal> values) {
+ Sheet sheet = workbook.getSheet(sheetName);
+ if (sheet == null) return;
+ Integer yearColumn = findYearColumn(sheet, evaluator, year, startColumn, endColumn);
+ if (yearColumn == null) return;
+ for (int rowIndex = FIRST_DATA_ROW_INDEX; rowIndex < FIRST_DATA_ROW_INDEX + 10; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) continue;
+ try {
+ BigDecimal value = numberValue(row.getCell(yearColumn), evaluator);
+ if (value != null && value.compareTo(BigDecimal.ZERO) > 0) values.add(value);
+ } catch (Exception ignored) {
+ // Skip cells that are not numeric coefficients.
+ }
+ }
+ }
+
+ private LocalDate previousDayDate(int year, String holidayType, Map<Integer, LocalDate> dateByDay) {
+ HolidayCalendar configured = configService.findPreviousDay(year, holidayType);
+ if (configured != null && configured.getHolidayDate() != null) {
+ return configured.getHolidayDate();
+ }
+ LocalDate firstDate = firstDate(dateByDay);
+ return firstDate == null ? null : firstDate.minusDays(1);
+ }
+
+ private LocalDate firstDate(Map<Integer, LocalDate> dateByDay) {
+ LocalDate result = null;
+ for (LocalDate date : dateByDay.values()) {
+ if (date != null && (result == null || date.isBefore(result))) result = date;
+ }
+ return result;
+ }
+
+ private Row findDayRow(Sheet sheet, FormulaEvaluator evaluator, int dayIndex) {
+ int lastRow = Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT);
+ for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) continue;
+ Integer rowDay = parseDayIndex(row.getCell(0), evaluator);
+ if (rowDay != null && rowDay.intValue() == dayIndex) return row;
+ }
+ return null;
+ }
+
+ private Row findPreviousDayRow(Sheet sheet, FormulaEvaluator evaluator) {
+ int lastRow = Math.min(sheet.getLastRowNum(), DAY_ROW_SCAN_LIMIT);
+ DataFormatter formatter = new DataFormatter();
+ for (int rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null || row.getCell(0) == null) continue;
+ String text = formatter.formatCellValue(row.getCell(0), evaluator).trim();
+ if (text.contains("鍓嶄竴鏃�")) return row;
+ }
+ return null;
+ }
+
+ private Map<Integer, LocalDate> dayDates(int year, String holidayType) {
+ Map<Integer, LocalDate> result = new LinkedHashMap<>();
+ for (HolidayCalendar row : configService.findHolidayDays(year, holidayType)) {
+ result.put(row.getDayIndex(), row.getHolidayDate());
+ }
+ return result;
+ }
+
+ private Map<LocalDate, BigDecimal> loadPassengerValues(int year, String transportMode) {
+ Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
+ List<HolidayPassengerFlow> rows = passengerFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayPassengerFlow>()
+ .eq(HolidayPassengerFlow::getYear, year)
+ .eq(HolidayPassengerFlow::getTransportMode, transportMode));
+ for (HolidayPassengerFlow row : rows) result.put(row.getFlowDate(), row.getPassengerCount());
+ return result;
+ }
+
+ private Map<LocalDate, BigDecimal> loadVehicleValues(int year, String roadCategory) {
+ Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
+ List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)
+ .eq(HolidayVehicleFlow::getRoadCategory, roadCategory));
+ for (HolidayVehicleFlow row : rows) result.put(row.getFlowDate(), row.getVehicleCount());
+ return result;
+ }
+
+ private Map<LocalDate, BigDecimal> loadComparisonBaseValues(int year) {
+ Map<LocalDate, BigDecimal> result = new LinkedHashMap<>();
+ List<HolidayVehicleFlow> rows = vehicleFlowMapper.selectList(
+ new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)
+ .eq(HolidayVehicleFlow::getRoadCategory, HolidayConstants.ROAD_NATIONAL_PROVINCIAL));
+ for (HolidayVehicleFlow row : rows) {
+ if (row.getComparisonBase() != null) result.put(row.getFlowDate(), row.getComparisonBase());
+ }
+ return result;
+ }
+
+ private void upsertPassenger(int year, LocalDate date, String mode, BigDecimal value) {
+ HolidayPassengerFlow existing = passengerFlowMapper.selectOne(new LambdaQueryWrapper<HolidayPassengerFlow>()
+ .eq(HolidayPassengerFlow::getYear, year)
+ .eq(HolidayPassengerFlow::getFlowDate, date)
+ .eq(HolidayPassengerFlow::getTransportMode, mode)
+ .last("limit 1"));
+ if (existing == null) {
+ existing = new HolidayPassengerFlow();
+ existing.setYear(year);
+ existing.setFlowDate(date);
+ existing.setTransportMode(mode);
+ existing.setPassengerCount(value);
+ existing.setCreatedAt(LocalDateTime.now());
+ existing.setUpdatedAt(LocalDateTime.now());
+ passengerFlowMapper.insert(existing);
+ } else {
+ existing.setPassengerCount(value);
+ existing.setUpdatedAt(LocalDateTime.now());
+ passengerFlowMapper.updateById(existing);
+ }
+ }
+
+ private void upsertVehicle(int year, LocalDate date, String roadCategory, String vehicleType,
+ BigDecimal value, BigDecimal comparisonBase) {
+ HolidayVehicleFlow existing = vehicleFlowMapper.selectOne(new LambdaQueryWrapper<HolidayVehicleFlow>()
+ .eq(HolidayVehicleFlow::getYear, year)
+ .eq(HolidayVehicleFlow::getFlowDate, date)
+ .eq(HolidayVehicleFlow::getRoadCategory, roadCategory)
+ .eq(HolidayVehicleFlow::getVehicleType, vehicleType)
+ .last("limit 1"));
+ if (existing == null) {
+ existing = new HolidayVehicleFlow();
+ existing.setYear(year);
+ existing.setFlowDate(date);
+ existing.setRoadCategory(roadCategory);
+ existing.setVehicleType(vehicleType);
+ existing.setVehicleCount(value);
+ existing.setComparisonBase(comparisonBase);
+ existing.setCreatedAt(LocalDateTime.now());
+ existing.setUpdatedAt(LocalDateTime.now());
+ vehicleFlowMapper.insert(existing);
+ } else {
+ existing.setVehicleCount(value);
+ existing.setComparisonBase(comparisonBase);
+ existing.setUpdatedAt(LocalDateTime.now());
+ vehicleFlowMapper.updateById(existing);
+ }
+ }
+
+ private void upsertCoefficient(int year, String dateCategory, BigDecimal value) {
+ HolidayPassengerCoefficient existing = coefficientMapper.selectOne(
+ new LambdaQueryWrapper<HolidayPassengerCoefficient>()
+ .eq(HolidayPassengerCoefficient::getYear, year)
+ .eq(HolidayPassengerCoefficient::getDateCategory, dateCategory)
+ .last("limit 1"));
+ if (existing == null) {
+ existing = new HolidayPassengerCoefficient();
+ existing.setYear(year);
+ existing.setDateCategory(dateCategory);
+ existing.setCoefficient(value);
+ existing.setCreatedAt(LocalDateTime.now());
+ existing.setUpdatedAt(LocalDateTime.now());
+ coefficientMapper.insert(existing);
+ } else {
+ existing.setCoefficient(value);
+ existing.setUpdatedAt(LocalDateTime.now());
+ coefficientMapper.updateById(existing);
+ }
+ }
+
+ private Integer findYearColumn(Sheet sheet, FormulaEvaluator evaluator, int year, int startColumn, int endColumn) {
+ Row header = sheet.getRow(1);
+ if (header == null) return null;
+ String wanted = year + "骞�";
+ DataFormatter formatter = new DataFormatter();
+ for (int column = startColumn; column <= endColumn; column++) {
+ Cell cell = header.getCell(column);
+ if (cell == null) continue;
+ String text = formatter.formatCellValue(cell, evaluator).replace("\n", "").trim();
+ if (wanted.equals(text) || text.startsWith(wanted + "绱")) return column;
+ }
+ return null;
+ }
+
+ private Integer parseDayIndex(Cell cell, FormulaEvaluator evaluator) {
+ if (cell == null) return null;
+ DataFormatter formatter = new DataFormatter();
+ String text = formatter.formatCellValue(cell, evaluator).trim();
+ java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("绗琝\s*(\\d+)\\s*鏃�").matcher(text);
+ return matcher.find() ? Integer.valueOf(matcher.group(1)) : null;
+ }
+
+ private BigDecimal numberValue(Cell cell, FormulaEvaluator evaluator) {
+ if (cell == null) return null;
+ CellType type = cell.getCellType();
+ if (type == CellType.FORMULA) {
+ type = evaluator.evaluateFormulaCell(cell);
+ }
+ if (type == CellType.BLANK) return null;
+ if (type == CellType.NUMERIC) return HolidayNumberUtils.excelValue(cell.getNumericCellValue());
+ if (type == CellType.STRING) {
+ String text = cell.getStringCellValue().trim().replace(",", "");
+ if (text.isEmpty() || "-".equals(text) || "鈥�".equals(text)) return null;
+ try {
+ return HolidayNumberUtils.decimalValue(text);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("鍗曞厓鏍间笉鏄湁鏁堟暟瀛�: " + text);
+ }
+ }
+ if (type == CellType.BOOLEAN) throw new IllegalArgumentException("鍗曞厓鏍间笉鑳芥槸甯冨皵鍊�");
+ throw new IllegalArgumentException("鍗曞厓鏍间笉鏄湁鏁堟暟瀛�");
+ }
+
+ private void setNumeric(Sheet sheet, int rowIndex, int columnIndex, BigDecimal value) {
+ if (value == null) return;
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) row = sheet.createRow(rowIndex);
+ Cell cell = row.getCell(columnIndex);
+ if (cell == null) cell = row.createCell(columnIndex);
+ cell.setCellValue(value.doubleValue());
+ }
+
+ private void setFormula(Sheet sheet, int rowIndex, int columnIndex, String formula) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) row = sheet.createRow(rowIndex);
+ Cell cell = row.getCell(columnIndex);
+ if (cell == null) cell = row.createCell(columnIndex);
+ // POI's setCellFormula rejects a leading '='; the helper accepts either form.
+ String normalized = formula.startsWith("=") ? formula.substring(1) : formula;
+ cell.setCellFormula(normalized);
+ }
+
+ private Map<String, Object> toMap(HolidayCalendar calendar) {
+ Map<String, Object> params = new LinkedHashMap<>();
+ params.put("year", calendar.getYear());
+ params.put("holidayType", calendar.getHolidayType());
+ params.put("holidayDate", calendar.getHolidayDate().toString());
+ params.put("dayIndex", calendar.getDayIndex());
+ return params;
+ }
+
+ @Data
+ public static class ImportSummary {
+ private int passengerRows;
+ private int vehicleRows;
+ private int skippedRows;
+ private boolean confirmationRequired;
+ private final List<String> errors = new ArrayList<>();
+ private final List<String> historicalChanges = new ArrayList<>();
+
+ public void incrementPassenger() {
+ passengerRows++;
+ }
+
+ public void incrementVehicle() {
+ vehicleRows++;
+ }
+
+ public void incrementSkipped() {
+ skippedRows++;
+ }
+
+ public void addError(String message) {
+ errors.add(message);
+ }
+
+ public void addHistoricalChange(String message) {
+ if (historicalChanges.size() < 200) {
+ historicalChanges.add(message);
+ }
+ }
+
+ public int getSuccessRows() {
+ return passengerRows + vehicleRows;
+ }
+
+ public int getFailRows() {
+ return errors.size();
+ }
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java
new file mode 100644
index 0000000..6a77ca5
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/controller/ObservationStationController.java
@@ -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());
+ }
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java
new file mode 100644
index 0000000..c5804b0
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDailyOperation.java
@@ -0,0 +1,15 @@
+package com.trafficaudit.observation.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/** Daily observation-station operation averages used by the holiday trend report. */
+@Data
+public class ObservationDailyOperation {
+ private LocalDate obsDate;
+ private BigDecimal averageSpeed;
+ private BigDecimal averageCongestion;
+ private Integer stationCount;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java
new file mode 100644
index 0000000..d7281d3
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/dto/ObservationDateSummary.java
@@ -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;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java
new file mode 100644
index 0000000..6b26fd2
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/entity/ObservationStationFlow.java
@@ -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;
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
new file mode 100644
index 0000000..c8055d5
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/mapper/ObservationStationFlowMapper.java
@@ -0,0 +1,31 @@
+package com.trafficaudit.observation.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.trafficaudit.observation.dto.ObservationDateSummary;
+import com.trafficaudit.observation.dto.ObservationDailyOperation;
+import com.trafficaudit.observation.entity.ObservationStationFlow;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.time.LocalDate;
+
+@Mapper
+public interface ObservationStationFlowMapper extends BaseMapper<ObservationStationFlow> {
+
+ @Select("SELECT obs_date, COUNT(*) AS station_count, "
+ + "SUM(small_medium_passenger) AS small_medium_passenger_total, "
+ + "MAX(updated_at) AS imported_at "
+ + "FROM observation_station_flow GROUP BY obs_date ORDER BY obs_date ASC")
+ List<ObservationDateSummary> selectDateSummary();
+
+ @Select("SELECT obs_date, COUNT(*) AS station_count, "
+ + "AVG(vehicle_speed) AS average_speed, "
+ + "AVG(congestion_degree) AS average_congestion "
+ + "FROM observation_station_flow "
+ + "WHERE obs_date BETWEEN #{startDate} AND #{endDate} "
+ + "GROUP BY obs_date ORDER BY obs_date ASC")
+ List<ObservationDailyOperation> selectDailyOperations(@Param("startDate") LocalDate startDate,
+ @Param("endDate") LocalDate endDate);
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java b/traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java
new file mode 100644
index 0000000..643a784
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/observation/service/ObservationStationImportService.java
@@ -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();
+ }
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java b/traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java
index 6adee4c..f1e60cd 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java
@@ -35,7 +35,7 @@
.authorizeRequests()
// 鐧诲綍鍓嶅繀椤诲彲鐢ㄧ殑鎺ュ彛
.antMatchers("/api/auth/login", "/api/auth/captcha", "/api/auth/captcha/verify",
- "/api/auth/captcha-config").permitAll()
+ "/api/auth/captcha-config", "/api/version").permitAll()
// 鍓嶇闈欐�佽祫婧�
.antMatchers("/", "/index.html", "/favicon.ico", "/js/**", "/css/**", "/fonts/**", "/img/**", "/static/**").permitAll()
.anyRequest().permitAll()
@@ -44,4 +44,4 @@
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
-}
\ No newline at end of file
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java b/traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java
index 0b5226b..1cf05f4 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java
@@ -27,7 +27,8 @@
"/api/auth/login",
"/api/auth/captcha",
"/api/auth/captcha/verify",
- "/api/auth/captcha-config"
+ "/api/auth/captcha-config",
+ "/api/version"
);
@Resource
@@ -61,4 +62,4 @@
request.setAttribute("loginUser", username);
chain.doFilter(request, response);
}
-}
\ No newline at end of file
+}
diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java b/traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java
new file mode 100644
index 0000000..a4d9de6
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/system/controller/VersionController.java
@@ -0,0 +1,83 @@
+package com.trafficaudit.system.controller;
+
+import com.trafficaudit.common.Result;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Properties;
+
+/**
+ * Exposes the product version so the UI (and operators) can confirm which build
+ * is actually running on a server. Kept public on purpose: the login page shows
+ * the version before any token exists.
+ */
+@RestController
+@RequestMapping("/api")
+public class VersionController {
+
+ private static final String VERSION_FILE = "app-version.properties";
+
+ @GetMapping("/version")
+ public Result<Map<String, Object>> version() {
+ Map<String, Object> info = new LinkedHashMap<>();
+ info.put("version", readVersion());
+ info.put("buildTime", resolveBuildTime());
+ return Result.ok(info);
+ }
+
+ private String readVersion() {
+ try (InputStream in = new ClassPathResource(VERSION_FILE).getInputStream()) {
+ Properties props = new Properties();
+ props.load(in);
+ String value = props.getProperty("version");
+ return value == null || value.trim().isEmpty() ? "unknown" : value.trim();
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+
+ /**
+ * Build time of the running artifact. In a packaged run this is the jar file's
+ * last-modified time; when running from exploded classes (mvn spring-boot:run)
+ * there is no jar, so the value is null.
+ */
+ private String resolveBuildTime() {
+ try {
+ URI location = VersionController.class.getProtectionDomain()
+ .getCodeSource().getLocation().toURI();
+ Path path = resolveArtifactPath(location);
+ if (path != null && Files.isRegularFile(path)) {
+ return Files.getLastModifiedTime(path).toInstant().toString();
+ }
+ } catch (Exception ignored) {
+ // Fall through: build time is optional information.
+ }
+ return null;
+ }
+
+ /**
+ * Spring Boot executable jars expose a nested classpath such as
+ * "jar:file:/app.jar!/BOOT-INF/classes!/". Strip the nested part before
+ * asking the filesystem for the jar timestamp.
+ */
+ private Path resolveArtifactPath(URI location) {
+ String value = location.toString();
+ int nested = value.indexOf("!/");
+ if (nested >= 0) {
+ value = value.substring(0, nested);
+ }
+ if (value.startsWith("jar:")) {
+ value = value.substring(4);
+ }
+ return Paths.get(URI.create(value));
+ }
+}
diff --git a/traffic-audit-server/src/main/resources/app-version.properties b/traffic-audit-server/src/main/resources/app-version.properties
new file mode 100644
index 0000000..af2a560
--- /dev/null
+++ b/traffic-audit-server/src/main/resources/app-version.properties
@@ -0,0 +1,3 @@
+# Supplied from pom.xml during Maven resource filtering.
+# Bump <version> in pom.xml on every release; do not hard-code a version here.
+version=@project.version@
diff --git a/traffic-audit-server/src/main/resources/application.yml.example b/traffic-audit-server/src/main/resources/application.yml.example
index dc87cf7..3c03dbd 100644
--- a/traffic-audit-server/src/main/resources/application.yml.example
+++ b/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}
+ # 蹇呴』鏄�孊ase64 鍚堟硶瀛楃 + 闀垮害鏄� 4 鐨勫�嶆暟銆嶏紱涓嬮潰杩欎覆 32 浣嶅崄鍏繘鍒跺彲鐩存帴鐢�
+ secret: ${TRAFFIC_JWT_SECRET:8f2c1d64a7b34e09c5d81f6a2b0e7c93}
expiration: 86400000
docs:
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java
new file mode 100644
index 0000000..cc81b4a
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayBulletinServiceTest.java
@@ -0,0 +1,141 @@
+package com.trafficaudit.holiday.service;
+
+import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class HolidayBulletinServiceTest {
+
+ @Test
+ void formatsTableRatiosAsSignedPercentages() throws Exception {
+ HolidayBulletinService service = new HolidayBulletinService(null);
+
+ assertEquals("-4.7%", ratio(service, "95.3", "100", 1));
+ assertEquals("-4.4%", ratio(service, "95.551398", "100", 1));
+ assertEquals("-4.5%", ratio(service, "95.551398", "100", 2));
+ assertEquals("-0.1%", ratio(service, "99.955", "100", 2));
+ assertEquals("0%", ratio(service, "99.955", "100", 1));
+ assertEquals("6.7%", ratio(service, "106.649", "100", 2));
+ assertEquals("6.6%", ratio(service, "106.649", "100", 1));
+ assertEquals("1.0%", ratio(service, "101", "100", 1));
+ assertEquals("0%", ratio(service, "100", "100", 1));
+ assertEquals("0%", ratio(service, "10004", "10000", 1));
+ assertEquals("鈥�", ratio(service, "100", null, 1));
+ assertEquals("鈥�", ratio(service, "100", "0", 1));
+ }
+
+ @Test
+ void comparesCivilAviationOnlyThroughCoveredDay() throws Exception {
+ HolidayStatService stat = mock(HolidayStatService.class);
+ HolidayBulletinService service = new HolidayBulletinService(stat);
+
+ DailyMetrics current = new DailyMetrics();
+ current.setYear(2026);
+ current.setHolidayType("NATIONAL_DAY");
+ current.setDayIndex(2);
+
+ DailyMetrics coveredDay = new DailyMetrics();
+ coveredDay.setCivilAviation(new BigDecimal("6.7482"));
+ DailyMetrics missingDay = new DailyMetrics();
+
+ when(stat.dailyMetrics(2026, "NATIONAL_DAY", 2)).thenReturn(missingDay);
+ when(stat.dailyMetrics(2026, "NATIONAL_DAY", 1)).thenReturn(coveredDay);
+
+ DailyMetrics previousYear = new DailyMetrics();
+ previousYear.setYear(2025);
+ DailyMetrics previousYearThroughDay1 = new DailyMetrics();
+ previousYearThroughDay1.setCivilAviation(new BigDecimal("7.5003"));
+ when(stat.cumulativeMetrics(2025, "NATIONAL_DAY", 1)).thenReturn(previousYearThroughDay1);
+
+ Object result = comparableCumulative(service, current, previousYear, DailyMetrics::getCivilAviation);
+
+ assertSame(previousYearThroughDay1, result);
+ verify(stat).cumulativeMetrics(2025, "NATIONAL_DAY", 1);
+ verify(stat, never()).cumulativeMetrics(2025, "NATIONAL_DAY", 2);
+ }
+
+ @Test
+ void comparableCumulativeIsNullWhenCurrentYearHasNoMetricData() throws Exception {
+ HolidayStatService stat = mock(HolidayStatService.class);
+ HolidayBulletinService service = new HolidayBulletinService(stat);
+
+ DailyMetrics current = new DailyMetrics();
+ current.setYear(2026);
+ current.setHolidayType("NATIONAL_DAY");
+ current.setDayIndex(2);
+ when(stat.dailyMetrics(2026, "NATIONAL_DAY", 2)).thenReturn(new DailyMetrics());
+ when(stat.dailyMetrics(2026, "NATIONAL_DAY", 1)).thenReturn(new DailyMetrics());
+
+ Object result = comparableCumulative(service, current, new DailyMetrics(), DailyMetrics::getCivilAviation);
+
+ assertNull(result);
+ verify(stat, never()).cumulativeMetrics(0, "NATIONAL_DAY", 1);
+ }
+
+ @Test
+ void cumulativeParagraphUsesExcelDisplayThenBulletinRounding() throws Exception {
+ HolidayBulletinService service = new HolidayBulletinService(null);
+
+ DailyMetrics current = new DailyMetrics();
+ current.setTotalPersonFlow(new BigDecimal("3088.62958"));
+ DailyMetrics previousYear = new DailyMetrics();
+ previousYear.setTotalPersonFlow(new BigDecimal("3232.42742"));
+ DailyMetrics twoYearsAgo = new DailyMetrics();
+ twoYearsAgo.setTotalPersonFlow(new BigDecimal("3137.80860"));
+
+ Method method = HolidayBulletinService.class.getDeclaredMethod(
+ "cumulativeParagraph", DailyMetrics.class, DailyMetrics.class, DailyMetrics.class);
+ method.setAccessible(true);
+ String text = (String) method.invoke(service, current, previousYear, twoYearsAgo);
+
+ assertEquals(
+ "鍋囨湡浠ユ潵锛屽叏鐪佸叏绀句細璺ㄥ尯鍩熶汉鍛樻祦鍔ㄩ噺绱3088.63涓囦汉娆★紝姣�2025骞村悓鏈熶笅闄�4.5%锛屾瘮2024骞村悓鏈熶笅闄�1.6%銆�",
+ text);
+ }
+
+ @Test
+ void cumulativeAmountsUseExcelYiDisplayBeforeWanRounding() throws Exception {
+ HolidayBulletinService service = new HolidayBulletinService(null);
+
+ assertEquals("3961.77", cumulativeNumber(service, "3961.76472"));
+ assertEquals("4189.32", cumulativeNumber(service, "4189.31472"));
+ assertEquals("4486.63", cumulativeNumber(service, "4486.62652"));
+ }
+
+ private String cumulativeNumber(HolidayBulletinService service, String value) throws Exception {
+ Method method = HolidayBulletinService.class.getDeclaredMethod("numberCumulative", BigDecimal.class);
+ method.setAccessible(true);
+ BigDecimal number = value == null ? null : new BigDecimal(value);
+ return (String) method.invoke(service, number);
+ }
+
+ private Object comparableCumulative(HolidayBulletinService service, DailyMetrics current,
+ DailyMetrics comparisonYear,
+ Function<DailyMetrics, BigDecimal> selector) throws Exception {
+ Method method = HolidayBulletinService.class.getDeclaredMethod(
+ "comparableCumulative", DailyMetrics.class, DailyMetrics.class, Function.class);
+ method.setAccessible(true);
+ return method.invoke(service, current, comparisonYear, selector);
+ }
+
+ private String ratio(HolidayBulletinService service, String current, String base,
+ int excelPercentScale) throws Exception {
+ Method method = HolidayBulletinService.class.getDeclaredMethod(
+ "ratio", BigDecimal.class, BigDecimal.class, int.class);
+ method.setAccessible(true);
+ BigDecimal currentValue = current == null ? null : new BigDecimal(current);
+ BigDecimal baseValue = base == null ? null : new BigDecimal(base);
+ return (String) method.invoke(service, currentValue, baseValue, excelPercentScale);
+ }
+}
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java
new file mode 100644
index 0000000..8dc3531
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayComparisonBaseTest.java
@@ -0,0 +1,181 @@
+package com.trafficaudit.holiday.service;
+
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.HolidayNumberUtils;
+import com.trafficaudit.holiday.entity.HolidayCalendar;
+import com.trafficaudit.holiday.entity.HolidayPassengerFlow;
+import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
+import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
+import com.trafficaudit.holiday.mapper.HolidayPassengerFlowMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Ring-ratio denominators are built one day at a time. The Excel comparison base (column C) is
+ * consumed on the source day and inherited by the next day's denominator, while historical
+ * adjustment records change only that historical day's same-day value.
+ */
+class HolidayComparisonBaseTest {
+
+ @Test
+ void comparisonBaseChangesOnlyTheNextDayRingRatioDenominator() throws Exception {
+ HolidayStatService service = serviceWithData(
+ 2026,
+ passengerRows(2026, "50", "10", "6", "3"),
+ vehicleRows(2026),
+ Collections.<HolidayVehicleAdjustment>emptyList());
+
+ List<Map<String, Object>> rows = service.buildOverviewRows(
+ 2026, HolidayConstants.TYPE_NATIONAL_DAY, calendar(2026));
+
+ assertEquals(new BigDecimal("323.7837"), rows.get(2).get("nationalProvincial"));
+ assertEquals(new BigDecimal("320.2418"), rows.get(2).get("comparisonBase"));
+ assertEquals(new BigDecimal("322.7695"), rows.get(3).get("nationalProvincial"));
+
+ BigDecimal nationalRatio = (BigDecimal) rows.get(3).get("nationalProvincialRingRatio");
+ assertEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("320.2418")), nationalRatio);
+ assertNotEquals(ratio(new BigDecimal("322.7695"), new BigDecimal("323.7837")), nationalRatio);
+
+ HolidayStatService.DailyMetrics base = service.ringRatioBaseMetrics(
+ 2026, HolidayConstants.TYPE_NATIONAL_DAY, 4);
+ assertNotNull(base);
+ assertEquals(new BigDecimal("320.2418"), base.getNationalProvincial());
+ assertDecimalEquals("1008.58032", base.getNonBusinessPersonFlow());
+ assertDecimalEquals("1058.58032", base.getRoadPersonFlow());
+ assertDecimalEquals("1077.58032", base.getTotalPersonFlow());
+ }
+
+ @Test
+ void historicalAdjustmentChangesTheHistoricalSameDayValue() throws Exception {
+ HolidayVehicleAdjustment adjustment = new HolidayVehicleAdjustment();
+ adjustment.setYear(2025);
+ adjustment.setFlowDate(LocalDate.of(2025, 10, 4));
+ adjustment.setAdjustedValue(new BigDecimal("337.5009"));
+
+ HolidayStatService service = serviceWithData(
+ 2025,
+ passengerRows(2025, "50", "10", "6", "3"),
+ Arrays.asList(
+ vehicle("2025-10-03", HolidayConstants.ROAD_EXPRESSWAY, "100", null),
+ vehicle("2025-10-03", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "362.0035", null),
+ vehicle("2025-10-04", HolidayConstants.ROAD_EXPRESSWAY, "110", null),
+ vehicle("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "350.0000", null)),
+ Collections.singletonList(adjustment));
+
+ List<Map<String, Object>> rows = service.buildOverviewRows(
+ 2025, HolidayConstants.TYPE_NATIONAL_DAY, calendar(2025));
+
+ assertEquals(new BigDecimal("337.5009"), rows.get(3).get("nationalProvincial"));
+ }
+
+ private static HolidayStatService serviceWithData(int year,
+ List<HolidayPassengerFlow> passengerRows,
+ List<HolidayVehicleFlow> vehicleRows,
+ List<HolidayVehicleAdjustment> adjustments) throws Exception {
+ HolidayConfigService configService = mock(HolidayConfigService.class);
+ HolidayPassengerFlowMapper passengerMapper = mock(HolidayPassengerFlowMapper.class);
+ HolidayVehicleFlowMapper vehicleMapper = mock(HolidayVehicleFlowMapper.class);
+ HolidayVehicleAdjustmentMapper adjustmentMapper = mock(HolidayVehicleAdjustmentMapper.class);
+
+ when(configService.coefficientOf(year, HolidayConstants.CATEGORY_HOLIDAY)).thenReturn(new BigDecimal("2.4"));
+ when(configService.findHolidayDays(year, HolidayConstants.TYPE_NATIONAL_DAY)).thenReturn(calendar(year));
+ when(passengerMapper.selectList(any())).thenReturn(passengerRows);
+ when(vehicleMapper.selectList(any())).thenReturn(vehicleRows);
+ when(adjustmentMapper.selectList(any())).thenReturn(adjustments);
+
+ HolidayStatService service = new HolidayStatService();
+ inject(service, "configService", configService);
+ inject(service, "passengerFlowMapper", passengerMapper);
+ inject(service, "vehicleFlowMapper", vehicleMapper);
+ inject(service, "vehicleAdjustmentMapper", adjustmentMapper);
+ return service;
+ }
+
+ private static BigDecimal ratio(BigDecimal current, BigDecimal base) {
+ return HolidayNumberUtils.normalize(HolidayNumberUtils.divide(current, base).subtract(BigDecimal.ONE));
+ }
+
+ private static void assertDecimalEquals(String expected, BigDecimal actual) {
+ assertNotNull(actual);
+ assertEquals(0, new BigDecimal(expected).compareTo(actual));
+ }
+
+ private static List<HolidayVehicleFlow> vehicleRows(int year) {
+ List<HolidayVehicleFlow> rows = new ArrayList<>();
+ rows.add(vehicle(year + "-10-03", HolidayConstants.ROAD_EXPRESSWAY, "100", null));
+ rows.add(vehicle(year + "-10-03", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "323.7837", "320.2418"));
+ rows.add(vehicle(year + "-10-04", HolidayConstants.ROAD_EXPRESSWAY, "110", null));
+ rows.add(vehicle(year + "-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "322.7695", null));
+ return rows;
+ }
+
+ private static HolidayVehicleFlow vehicle(String date, String roadCategory, String value, String base) {
+ HolidayVehicleFlow row = new HolidayVehicleFlow();
+ row.setYear(LocalDate.parse(date).getYear());
+ row.setFlowDate(LocalDate.parse(date));
+ row.setRoadCategory(roadCategory);
+ row.setVehicleType(HolidayConstants.VEHICLE_MEDIUM_SMALL_PASSENGER);
+ row.setVehicleCount(new BigDecimal(value));
+ row.setComparisonBase(base == null ? null : new BigDecimal(base));
+ return row;
+ }
+
+ private static List<HolidayPassengerFlow> passengerRows(int year, String road, String rail,
+ String civilAviation, String water) {
+ List<HolidayPassengerFlow> rows = new ArrayList<>();
+ rows.add(passenger(year + "-10-03", HolidayConstants.MODE_ROAD, road));
+ rows.add(passenger(year + "-10-03", HolidayConstants.MODE_RAIL, rail));
+ rows.add(passenger(year + "-10-03", HolidayConstants.MODE_CIVIL_AVIATION, civilAviation));
+ rows.add(passenger(year + "-10-03", HolidayConstants.MODE_WATER, water));
+ rows.add(passenger(year + "-10-04", HolidayConstants.MODE_ROAD, road));
+ rows.add(passenger(year + "-10-04", HolidayConstants.MODE_RAIL, rail));
+ rows.add(passenger(year + "-10-04", HolidayConstants.MODE_CIVIL_AVIATION, civilAviation));
+ rows.add(passenger(year + "-10-04", HolidayConstants.MODE_WATER, water));
+ return rows;
+ }
+
+ private static HolidayPassengerFlow passenger(String date, String mode, String value) {
+ HolidayPassengerFlow row = new HolidayPassengerFlow();
+ row.setYear(LocalDate.parse(date).getYear());
+ row.setFlowDate(LocalDate.parse(date));
+ row.setTransportMode(mode);
+ row.setPassengerCount(new BigDecimal(value));
+ return row;
+ }
+
+ private static List<HolidayCalendar> calendar(int year) {
+ List<HolidayCalendar> days = new ArrayList<>();
+ for (int i = 1; i <= 4; i++) {
+ HolidayCalendar day = new HolidayCalendar();
+ day.setYear(year);
+ day.setHolidayType(HolidayConstants.TYPE_NATIONAL_DAY);
+ day.setDayIndex(i);
+ day.setHolidayDate(LocalDate.of(year, 10, i));
+ days.add(day);
+ }
+ return days;
+ }
+
+ private static void inject(Object target, String field, Object value) throws Exception {
+ Field declared = target.getClass().getDeclaredField(field);
+ declared.setAccessible(true);
+ declared.set(target, value);
+ }
+}
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
new file mode 100644
index 0000000..fff727d
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendChartServiceTest.java
@@ -0,0 +1,136 @@
+package com.trafficaudit.holiday.service;
+
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HolidayTrendChartServiceTest {
+
+ @Test
+ void rendersLinePieAndStackedChartsAsPng() throws Exception {
+ HolidayTrendChartService service = new HolidayTrendChartService();
+ HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries();
+ first.setName("2026骞�");
+ first.setValues(java.util.Arrays.asList(
+ java.math.BigDecimal.ONE, java.math.BigDecimal.TEN, null));
+ HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries();
+ second.setName("2025骞�");
+ second.setValues(java.util.Arrays.asList(
+ java.math.BigDecimal.valueOf(2), java.math.BigDecimal.valueOf(8),
+ java.math.BigDecimal.valueOf(4)));
+
+ byte[] line = service.lineChart("涓囦汉娆�", java.util.Arrays.asList(first, second), false);
+ assertPng(line);
+ assertNoLargeLightGrayBackground(line);
+
+ HolidayTrendChartService.ChartCategory category = new HolidayTrendChartService.ChartCategory();
+ category.setName("閾佽矾");
+ category.setValue(java.math.BigDecimal.TEN);
+ byte[] pie = service.pieChart("浜烘", java.util.Collections.singletonList(category), 400, 300);
+ assertPng(pie);
+
+ byte[] stacked = service.stackedBarChart("鍗犳瘮", java.util.Arrays.asList("10鏈�1鏃�", "10鏈�2鏃�"),
+ java.util.Arrays.asList(first, second), true, 500, 320);
+ assertPng(stacked);
+ assertNoLargeLightGrayBackground(stacked);
+ }
+
+ @Test
+ void roundsLineDataToTheSamePrecisionAsLabels() {
+ assertEquals(new BigDecimal("0.49"),
+ HolidayTrendChartService.displayValue(new BigDecimal("0.4904"), false, 2));
+ assertEquals(new BigDecimal("0.49"),
+ HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 2));
+ assertEquals(new BigDecimal("0.491"),
+ HolidayTrendChartService.displayValue(new BigDecimal("0.4911"), false, 3));
+ assertEquals(new BigDecimal("0.123"),
+ HolidayTrendChartService.displayValue(new BigDecimal("0.1234"), true, 2));
+ }
+
+ @Test
+ void rendersSameGeometryWhenValuesRoundToSameLabel() {
+ HolidayTrendChartService service = new HolidayTrendChartService();
+ HolidayTrendChartService.ChartSeries first = new HolidayTrendChartService.ChartSeries();
+ first.setName("骞冲潎鎷ユ尋搴�");
+ first.setValues(java.util.Arrays.asList(
+ new BigDecimal("0.4904"), new BigDecimal("0.4923"), new BigDecimal("0.5100")));
+ HolidayTrendChartService.ChartSeries second = new HolidayTrendChartService.ChartSeries();
+ second.setName("骞冲潎鎷ユ尋搴�");
+ second.setValues(java.util.Arrays.asList(
+ new BigDecimal("0.4911"), new BigDecimal("0.4934"), new BigDecimal("0.5100")));
+
+ byte[] firstChart = service.lineChart("鎷ユ尋搴�",
+ java.util.Collections.singletonList(first), false, 2, 1100, 590);
+ byte[] secondChart = service.lineChart("鎷ユ尋搴�",
+ java.util.Collections.singletonList(second), false, 2, 1100, 590);
+
+ assertPng(firstChart);
+ assertPng(secondChart);
+ assertArrayEquals(firstChart, secondChart);
+ }
+
+ @Test
+ void scalesLineChartHeightForBetterVerticalSeparation() throws Exception {
+ HolidayTrendChartService service = new HolidayTrendChartService();
+ HolidayTrendChartService.ChartSeries series = new HolidayTrendChartService.ChartSeries();
+ series.setName("娴嬭瘯");
+ series.setValues(java.util.Arrays.asList(
+ new BigDecimal("1.00"), new BigDecimal("2.00"), new BigDecimal("3.00")));
+
+ byte[] png = service.lineChart("涓囦汉娆�",
+ java.util.Collections.singletonList(series), false, 1100, 590);
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(png));
+
+ assertNotNull(image);
+ assertEquals(HolidayTrendChartService.scaleLineChartHeight(590), image.getHeight());
+ }
+
+ @Test
+ void rendersHigherPrecisionLineLabelsWhenRequested() {
+ HolidayTrendChartService service = new HolidayTrendChartService();
+ HolidayTrendChartService.ChartSeries congestion = new HolidayTrendChartService.ChartSeries();
+ congestion.setName("骞冲潎鎷ユ尋搴�");
+ congestion.setValues(java.util.Arrays.asList(
+ new java.math.BigDecimal("0.467029"),
+ new java.math.BigDecimal("0.490890"),
+ new java.math.BigDecimal("0.492414")));
+
+ byte[] twoDecimals = service.lineChart("鎷ユ尋搴�",
+ java.util.Collections.singletonList(congestion), false, 2, 1100, 590);
+ byte[] threeDecimals = service.lineChart("鎷ユ尋搴�",
+ java.util.Collections.singletonList(congestion), false, 3, 1100, 590);
+
+ assertPng(twoDecimals);
+ assertPng(threeDecimals);
+ assertFalse(java.util.Arrays.equals(twoDecimals, threeDecimals));
+ }
+
+ private void assertNoLargeLightGrayBackground(byte[] data) throws Exception {
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(data));
+ int matches = 0;
+ for (int y = 0; y < image.getHeight(); y++) {
+ for (int x = 0; x < image.getWidth(); x++) {
+ int rgb = image.getRGB(x, y) & 0xFFFFFF;
+ if (rgb == 0xF3F4F3) matches++;
+ }
+ }
+ assertTrue(matches < image.getWidth() * image.getHeight() / 20,
+ "chart still contains a large light-gray background area");
+ }
+
+ private void assertPng(byte[] data) {
+ assertNotNull(data);
+ assertTrue(data.length > 100);
+ assertTrue(data[0] == (byte) 0x89 && data[1] == 0x50
+ && data[2] == 0x4e && data[3] == 0x47);
+ }
+}
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java
new file mode 100644
index 0000000..d973be0
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayTrendWordServiceTest.java
@@ -0,0 +1,143 @@
+package com.trafficaudit.holiday.service;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HolidayTrendWordServiceTest {
+
+ @Test
+ void rebuildsTemplateWithSectionsChartsAndAppendix() throws Exception {
+ Path template = resolveTemplate();
+ byte[] templateBytes = Files.readAllBytes(template);
+ HolidayTrendWordService service = new HolidayTrendWordService();
+
+ List<HolidayTrendWordService.DocumentBlock> blocks = new ArrayList<>();
+ blocks.add(new HolidayTrendWordService.SectionBlock("涓�銆佹�讳綋鎯呭喌"));
+ blocks.add(new HolidayTrendWordService.ChartBlock("瀹㈡祦鎬婚噺", png(1),
+ new HolidayTrendWordService.ImageSize(5000000L, 3000000L)));
+ blocks.add(new HolidayTrendWordService.ChartBlock("瀹㈡祦鍗犳瘮", png(2), true,
+ new HolidayTrendWordService.ImageSize(4000000L, 2500000L)));
+ blocks.add(new HolidayTrendWordService.PageBreakBlock());
+ blocks.add(new HolidayTrendWordService.SectionBlock("闄勫綍涓� 姣忔棩瀹㈡祦鏋勬垚鍙樺寲"));
+ blocks.add(new HolidayTrendWordService.ChartBlock("姣忔棩鏋勬垚", png(3), true));
+
+ byte[] result = service.generate(templateBytes, 2026, blocks);
+ List<String> entries = entries(result);
+ assertTrue(entries.contains("word/media/image1.png"));
+ assertTrue(entries.contains("word/media/image2.png"));
+ assertTrue(entries.contains("word/media/image3.png"));
+ assertEquals(3, entries.stream().filter(name -> name.endsWith(".png")).count());
+
+ String documentXml = readEntry(result, "word/document.xml");
+ assertTrue(documentXml.contains("涓�銆佹�讳綋鎯呭喌"));
+ assertTrue(documentXml.contains("闄勫綍涓� 姣忔棩瀹㈡祦鏋勬垚鍙樺寲"));
+ assertTrue(documentXml.contains("瀹㈡祦鎬婚噺"));
+ assertTrue(documentXml.contains("姣忔棩鏋勬垚"));
+ assertTrue(!paragraphHasNumbering(documentXml, "涓�銆佹�讳綋鎯呭喌"));
+ assertTrue(!paragraphHasNumbering(documentXml, "闄勫綍涓� 姣忔棩瀹㈡祦鏋勬垚鍙樺寲"));
+ assertTrue(documentXml.contains("cx=\"5000000\" cy=\"3000000\""));
+ assertTrue(documentXml.contains("cx=\"4000000\" cy=\"2500000\""));
+ assertEquals(2, pageBreakCount(documentXml));
+
+ String rels = readEntry(result, "word/_rels/document.xml.rels");
+ assertTrue(rels.contains("rId100"));
+ assertTrue(rels.contains("rId102"));
+ }
+
+ private Path resolveTemplate() {
+ Path direct = Paths.get("docs/涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx");
+ if (Files.exists(direct)) return direct;
+ return Paths.get("..", "docs", "涓撻」缁熻", "閲嶈鑺傚亣鏃�", "2026骞村崄涓�鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx");
+ }
+
+ private boolean paragraphHasNumbering(String documentXml, String text) throws Exception {
+ javax.xml.parsers.DocumentBuilderFactory factory =
+ javax.xml.parsers.DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ org.w3c.dom.Document document = factory.newDocumentBuilder().parse(
+ new java.io.ByteArrayInputStream(documentXml.getBytes("UTF-8")));
+ org.w3c.dom.NodeList paragraphs = document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "p");
+ for (int i = 0; i < paragraphs.getLength(); i++) {
+ org.w3c.dom.Element paragraph = (org.w3c.dom.Element) paragraphs.item(i);
+ StringBuilder builder = new StringBuilder();
+ org.w3c.dom.NodeList texts = paragraph.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "t");
+ for (int j = 0; j < texts.getLength(); j++) {
+ builder.append(texts.item(j).getTextContent());
+ }
+ if (text.equals(builder.toString())) {
+ return paragraph.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "numPr")
+ .getLength() > 0;
+ }
+ }
+ throw new AssertionError("Paragraph not found: " + text);
+ }
+
+ private int pageBreakCount(String documentXml) throws Exception {
+ javax.xml.parsers.DocumentBuilderFactory factory =
+ javax.xml.parsers.DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ org.w3c.dom.Document document = factory.newDocumentBuilder().parse(
+ new java.io.ByteArrayInputStream(documentXml.getBytes("UTF-8")));
+ org.w3c.dom.NodeList breaks = document.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "br");
+ int count = 0;
+ for (int i = 0; i < breaks.getLength(); i++) {
+ org.w3c.dom.Element br = (org.w3c.dom.Element) breaks.item(i);
+ if ("page".equals(br.getAttributeNS(
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "type"))) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private byte[] png(int seed) {
+ byte[] bytes = new byte[256];
+ Arrays.fill(bytes, (byte) seed);
+ bytes[0] = (byte) 0x89;
+ bytes[1] = 0x50;
+ bytes[2] = 0x4e;
+ bytes[3] = 0x47;
+ return bytes;
+ }
+
+ private List<String> entries(byte[] zipBytes) throws Exception {
+ List<String> result = new ArrayList<>();
+ try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
+ ZipEntry entry;
+ while ((entry = input.getNextEntry()) != null) result.add(entry.getName());
+ }
+ return result;
+ }
+
+ private String readEntry(byte[] zipBytes, String name) throws Exception {
+ try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
+ ZipEntry entry;
+ while ((entry = input.getNextEntry()) != null) {
+ if (name.equals(entry.getName())) {
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = input.read(buffer)) > 0) out.write(buffer, 0, length);
+ return out.toString("UTF-8");
+ }
+ }
+ }
+ return "";
+ }
+}
diff --git a/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java
new file mode 100644
index 0000000..4239b6d
--- /dev/null
+++ b/traffic-audit-server/src/test/java/com/trafficaudit/holiday/service/HolidayVehicleAdjustmentServiceTest.java
@@ -0,0 +1,85 @@
+package com.trafficaudit.holiday.service;
+
+import com.trafficaudit.holiday.HolidayConstants;
+import com.trafficaudit.holiday.entity.HolidayVehicleAdjustment;
+import com.trafficaudit.holiday.entity.HolidayVehicleFlow;
+import com.trafficaudit.holiday.mapper.HolidayVehicleAdjustmentMapper;
+import com.trafficaudit.holiday.mapper.HolidayVehicleFlowMapper;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class HolidayVehicleAdjustmentServiceTest {
+
+ @Test
+ void listAddsOriginalNationalProvincialValueAndLeavesMissingSourceBlank() throws Exception {
+ HolidayVehicleAdjustment adjusted = adjustment("2025-10-04", "337.5009");
+ HolidayVehicleAdjustment missingSource = adjustment("2025-10-05", "330.0000");
+
+ HolidayVehicleAdjustmentMapper adjustmentMapper = mock(HolidayVehicleAdjustmentMapper.class);
+ HolidayVehicleFlowMapper vehicleFlowMapper = mock(HolidayVehicleFlowMapper.class);
+ when(adjustmentMapper.selectList(any())).thenReturn(Arrays.asList(adjusted, missingSource));
+ when(vehicleFlowMapper.selectList(any())).thenReturn(Collections.singletonList(
+ vehicleFlow("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "350.0000")));
+
+ HolidayVehicleAdjustmentService service = new HolidayVehicleAdjustmentService();
+ inject(service, "adjustmentMapper", adjustmentMapper);
+ inject(service, "vehicleFlowMapper", vehicleFlowMapper);
+
+ List<HolidayVehicleAdjustment> rows = service.list(2025);
+
+ assertEquals(0, new BigDecimal("350.0000").compareTo(rows.get(0).getOriginalValue()));
+ assertNull(rows.get(1).getOriginalValue());
+ }
+
+ @Test
+ void findOriginalValueReturnsMatchedValueAndNullWhenMissing() throws Exception {
+ HolidayVehicleFlowMapper vehicleFlowMapper = mock(HolidayVehicleFlowMapper.class);
+ when(vehicleFlowMapper.selectOne(any()))
+ .thenReturn(vehicleFlow("2025-10-04", HolidayConstants.ROAD_NATIONAL_PROVINCIAL, "337.9501"))
+ .thenReturn(null);
+
+ HolidayVehicleAdjustmentService service = new HolidayVehicleAdjustmentService();
+ inject(service, "vehicleFlowMapper", vehicleFlowMapper);
+
+ assertEquals(0, new BigDecimal("337.9501")
+ .compareTo(service.findOriginalValue(2025, LocalDate.of(2025, 10, 4))));
+ assertNull(service.findOriginalValue(2025, LocalDate.of(2025, 10, 5)));
+ assertThrows(IllegalArgumentException.class,
+ () -> service.findOriginalValue(2025, LocalDate.of(2024, 10, 4)));
+ }
+ private static HolidayVehicleAdjustment adjustment(String date, String adjustedValue) {
+ HolidayVehicleAdjustment row = new HolidayVehicleAdjustment();
+ row.setYear(LocalDate.parse(date).getYear());
+ row.setFlowDate(LocalDate.parse(date));
+ row.setAdjustedValue(new BigDecimal(adjustedValue));
+ return row;
+ }
+
+ private static HolidayVehicleFlow vehicleFlow(String date, String roadCategory, String value) {
+ HolidayVehicleFlow row = new HolidayVehicleFlow();
+ row.setYear(LocalDate.parse(date).getYear());
+ row.setFlowDate(LocalDate.parse(date));
+ row.setRoadCategory(roadCategory);
+ row.setVehicleCount(new BigDecimal(value));
+ return row;
+ }
+
+ private static void inject(Object target, String fieldName, Object value) throws Exception {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+}
diff --git a/traffic-audit-web/src/components/AppHeader.vue b/traffic-audit-web/src/components/AppHeader.vue
index aeec948..f077c9a 100644
--- a/traffic-audit-web/src/components/AppHeader.vue
+++ b/traffic-audit-web/src/components/AppHeader.vue
@@ -1,6 +1,9 @@
<template>
<el-header class="app-header">
- <span class="header-title">{{ title }}</span>
+ <span class="header-title">
+ {{ systemName }}<span class="header-version" v-if="version">V{{ version }}</span>
+ <template v-if="pageName"> - {{ pageName }}</template>
+ </span>
<div class="header-right">
<span class="header-user">
<i class="el-icon-user"></i> {{ username }}
@@ -43,6 +46,7 @@
},
data() {
return {
+ version: '',
pwdDialogVisible: false,
pwdSaving: false,
pwdForm: { oldPassword: '', newPassword: '', confirmPassword: '' },
@@ -65,7 +69,22 @@
computed: {
username() {
return sessionStorage.getItem('username') || 'admin'
+ },
+ // The system name always comes first so the version can sit right after it.
+ systemName() {
+ return this.title.split(' - ')[0]
+ },
+ pageName() {
+ const parts = this.title.split(' - ')
+ return parts.length > 1 ? parts.slice(1).join(' - ') : ''
}
+ },
+ created() {
+ api.get('/version')
+ .then(res => {
+ if (res && res.data && res.data.version) this.version = res.data.version
+ })
+ .catch(() => {})
},
methods: {
openChangePwd() {
@@ -134,6 +153,14 @@
line-height: normal;
}
.header-user { opacity: 0.92; }
+.header-version {
+ font-size: 12px;
+ line-height: 1.6;
+ margin-left: 8px;
+ padding: 2px 8px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.18);
+}
.header-link { color: #fff; font-size: 14px; padding: 0; }
.header-link:hover { color: #ffd04b; }
.header-logout { color: #fff; font-size: 14px; padding: 0; }
diff --git a/traffic-audit-web/src/main.js b/traffic-audit-web/src/main.js
index 7937fbf..0413f43 100644
--- a/traffic-audit-web/src/main.js
+++ b/traffic-audit-web/src/main.js
@@ -11,6 +11,11 @@
// 鍏ㄥ眬鐧诲嚭锛氭竻闄ょ櫥褰曟�佸苟鍥炵櫥褰曢〉锛堝悇椤甸潰渚ц竟鏍忋�岄��鍑虹櫥褰曘�嶈彍鍗曞叡鐢級
Vue.mixin({
+ computed: {
+ operatorIsAdmin() {
+ return sessionStorage.getItem('username') === 'admin'
+ }
+ },
methods: {
// el-menu 鍚岃矾鐢卞鑸紙NavigationDuplicated锛夋椂 Element UI 浼氭妸楂樹寒鍥炴粴涓虹偣鍑诲墠鏃у�硷紱
// 鐐瑰嚮鍚庡欢杩熶竴涓畯浠诲姟寮哄埗绾犳锛堝畯浠诲姟鏅氫簬 push 鐨勫井浠诲姟鍥炶皟锛屼繚璇佹渶缁堥珮浜纭級銆�
diff --git a/traffic-audit-web/src/router/index.js b/traffic-audit-web/src/router/index.js
index 6316dc6..93d08e7 100644
--- a/traffic-audit-web/src/router/index.js
+++ b/traffic-audit-web/src/router/index.js
@@ -14,7 +14,9 @@
{ path: '/audit', name: 'AuditResult', component: () => import('@/views/AuditResult.vue') },
{ path: '/report', name: 'ReportExport', component: () => import('@/views/ReportExport.vue') },
{ path: '/rules', name: 'RuleManage', component: () => import('@/views/RuleManage.vue') },
- { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') }
+ { path: '/users', name: 'UserManage', component: () => import('@/views/UserManage.vue') },
+ { path: '/holiday-stat', name: 'HolidayStat', component: () => import('@/views/HolidayStat.vue') },
+ { path: '/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfig.vue'), meta: { requireAdmin: true } }
]
})
@@ -22,8 +24,12 @@
const token = sessionStorage.getItem('token')
if (to.path === '/login') {
next(token ? '/dashboard' : undefined)
+ } else if (!token) {
+ next('/login')
+ } else if (to.meta && to.meta.requireAdmin && sessionStorage.getItem('username') !== 'admin') {
+ next('/dashboard')
} else {
- next(token ? undefined : '/login')
+ next()
}
})
diff --git a/traffic-audit-web/src/views/AuditResult.vue b/traffic-audit-web/src/views/AuditResult.vue
index f1a2bfd..e3b218a 100644
--- a/traffic-audit-web/src/views/AuditResult.vue
+++ b/traffic-audit-web/src/views/AuditResult.vue
@@ -10,9 +10,14 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
-</el-menu>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
</el-aside>
<el-main :class="{ 'main-focus': focusMode }" style="display:flex;flex-direction:column;min-height:0">
<h3 v-show="!focusMode" style="flex:none;margin:0 0 10px">瀹℃牳缁撴灉</h3>
diff --git a/traffic-audit-web/src/views/Dashboard.vue b/traffic-audit-web/src/views/Dashboard.vue
index ea88d0e..5753ac3 100644
--- a/traffic-audit-web/src/views/Dashboard.vue
+++ b/traffic-audit-web/src/views/Dashboard.vue
@@ -10,8 +10,14 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
- <el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item></el-menu>
+ <el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
</el-aside>
<el-main>
<div class="page-scroll">
diff --git a/traffic-audit-web/src/views/DataImport.vue b/traffic-audit-web/src/views/DataImport.vue
index deb64c6..82abc05 100644
--- a/traffic-audit-web/src/views/DataImport.vue
+++ b/traffic-audit-web/src/views/DataImport.vue
@@ -10,9 +10,14 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
-</el-menu>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
</el-aside>
<el-main>
<div class="page-scroll">
diff --git a/traffic-audit-web/src/views/DataView.vue b/traffic-audit-web/src/views/DataView.vue
index d72dc3a..6eb9ba0 100644
--- a/traffic-audit-web/src/views/DataView.vue
+++ b/traffic-audit-web/src/views/DataView.vue
@@ -10,9 +10,14 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
-</el-menu>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
</el-aside>
<el-main>
<h3>瀵煎叆鏁版嵁鏌ョ湅</h3>
diff --git a/traffic-audit-web/src/views/ExplainReview.vue b/traffic-audit-web/src/views/ExplainReview.vue
index f622ecc..130272b 100644
--- a/traffic-audit-web/src/views/ExplainReview.vue
+++ b/traffic-audit-web/src/views/ExplainReview.vue
@@ -10,9 +10,14 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
-</el-menu>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
</el-aside>
<el-main>
<h3>浼佷笟瑙i噴</h3>
diff --git a/traffic-audit-web/src/views/HolidayStat.vue b/traffic-audit-web/src/views/HolidayStat.vue
new file mode 100644
index 0000000..6875128
--- /dev/null
+++ b/traffic-audit-web/src/views/HolidayStat.vue
@@ -0,0 +1,690 @@
+<template>
+ <el-container direction="vertical">
+ <app-header title="浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 涓撻」缁熻"></app-header>
+ <el-container>
+ <el-aside width="200px">
+ <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
+ <el-menu-item index="/dashboard">宸ヤ綔鍙�</el-menu-item>
+ <el-menu-item index="/import">鏁版嵁瀵煎叆</el-menu-item>
+ <el-menu-item index="/data">鏁版嵁鏌ョ湅</el-menu-item>
+ <el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
+ <el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
+ <el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
+ <el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
+ <el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
+ </el-aside>
+ <el-main>
+ <div class="page-scroll">
+ <h3>涓撻」缁熻 - 鑺傚亣鏃�</h3>
+ <div class="toolbar">
+ <el-select v-model="year" style="width:120px" @change="loadOverview">
+ <el-option v-for="item in years" :key="item" :label="item + '骞�'" :value="item"></el-option>
+ </el-select>
+ <el-select v-model="holidayType" style="width:150px" @change="loadOverview">
+ <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+ </el-select>
+ <el-button icon="el-icon-refresh" @click="loadOverview">鍒锋柊</el-button>
+ <el-button icon="el-icon-download" @click="downloadTemplate">涓嬭浇妯℃澘</el-button>
+ </div>
+
+ <el-card shadow="never" class="operation-card">
+ <div class="operation-row">
+ <div class="operation-item">
+ <span class="label">瀵煎叆鏁版嵁</span>
+ <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
+ accept=".xlsx,.xls" :on-change="onFileChange" :on-remove="onFileRemove" :file-list="fileList">
+ <el-button type="primary" plain icon="el-icon-folder-opened">閫夋嫨鏂囦欢</el-button>
+ </el-upload>
+ <el-button type="primary" icon="el-icon-upload2" :loading="importing" :disabled="!selectedFile" @click="importData">瀵煎叆</el-button>
+ </div>
+ <div class="operation-item">
+ <span class="label">瀵煎嚭鏁版嵁</span>
+ <el-button type="success" icon="el-icon-download" :loading="exporting" @click="exportData">瀵煎嚭 Excel</el-button>
+ </div>
+ <div class="operation-item">
+ <span class="label">瀹㈡祦蹇姤</span>
+ <el-date-picker v-model="bulletinDate" type="date" value-format="yyyy-MM-dd" style="width:160px" placeholder="閫夋嫨鏃ユ湡"></el-date-picker>
+ <el-button type="warning" icon="el-icon-document" :loading="bulletinLoading" @click="downloadBulletin">涓嬭浇蹇姤</el-button>
+ </div>
+ <div class="operation-item">
+ <span class="label">璧板娍鍥�</span>
+ <el-button type="primary" icon="el-icon-picture-outline" :loading="trendLoading" @click="downloadTrend">涓嬭浇璧板娍鍥�</el-button>
+ </div>
+ </div>
+ </el-card>
+
+ <div v-if="importMessage" class="import-message">
+ <pre>{{ importMessage }}</pre>
+ </div>
+
+ <el-card shadow="never" style="margin-top:14px">
+ <div slot="header">{{ year }}骞磠{ holidayTypeLabel }}鑺傚亣鏃ユ暟鎹�</div>
+ <el-table :data="rows" border stripe size="small" v-loading="loading" style="width:100%">
+ <el-table-column prop="dayIndex" label="绗嚑鏃�" width="80" align="center"></el-table-column>
+ <el-table-column prop="date" label="鏃ユ湡" width="120" align="center"></el-table-column>
+ <el-table-column label="閾佽矾锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.rail) }}</template></el-table-column>
+ <el-table-column label="姘戣埅锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.civilAviation) }}</template></el-table-column>
+ <el-table-column label="閬撹矾锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.road) }}</template></el-table-column>
+ <el-table-column label="姘磋矾锛堜竾浜烘锛�" width="120" align="right"><template slot-scope="s">{{ formatNumber(s.row.water) }}</template></el-table-column>
+ <el-table-column label="楂橀�熻溅娴侊紙涓囪締娆★級" width="150" align="right"><template slot-scope="s">{{ formatNumber(s.row.expressway) }}</template></el-table-column>
+ <el-table-column label="鍥界渷閬撹溅娴侊紙涓囪締娆★級" width="160" align="right"><template slot-scope="s">{{ formatNumber(s.row.nationalProvincial) }}</template></el-table-column>
+ <el-table-column label="鍥界渷閬撶幆姣斿熀鏈燂紙涓囪締娆★級" width="190" align="right"><template slot-scope="s">{{ formatNumber(s.row.comparisonBase) }}</template></el-table-column>
+ <el-table-column label="鍥界渷閬撹溅娴佺幆姣�" width="140" align="right"><template slot-scope="s">{{ formatRatio(s.row.nationalProvincialRingRatio) }}</template></el-table-column>
+ <el-table-column label="瀹㈣繍绯绘暟" width="100" align="right"><template slot-scope="s">{{ formatCoefficient(s.row.coefficient) }}</template></el-table-column>
+ <el-table-column label="闈炶惀涓氭�т汉鍛樻祦鍔ㄩ噺锛堜竾浜烘锛�" min-width="200" align="right"><template slot-scope="s">{{ formatNumber(s.row.nonBusinessPersonFlow) }}</template></el-table-column>
+ <el-table-column label="鎬讳汉鍛樻祦鍔ㄩ噺锛堜竾浜烘锛�" min-width="180" align="right"><template slot-scope="s">{{ formatNumber(s.row.totalPersonFlow) }}</template></el-table-column>
+ </el-table>
+ </el-card>
+
+ <el-card shadow="never" style="margin-top:14px">
+ <div slot="header">
+ <span>寰�骞村浗鐪侀亾鏁版嵁璋冩暣</span>
+ </div>
+ <div class="operation-row">
+ <div class="operation-item">
+ <span class="label">骞翠唤</span>
+ <el-select v-model="adjustmentYear" style="width:120px" @change="loadAdjustments">
+ <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + '骞�'" :value="item"></el-option>
+ </el-select>
+ </div>
+ <div v-if="operatorIsAdmin" class="operation-item">
+ <el-button type="primary" icon="el-icon-plus" @click="openAdjustmentDialog()">鏂板璋冩暣</el-button>
+ </div>
+ </div>
+ <el-table :data="adjustments" border stripe size="small" style="margin-top:12px" v-loading="adjustmentLoading">
+ <el-table-column prop="year" label="骞翠唤" width="90" align="center"></el-table-column>
+ <el-table-column prop="flowDate" label="鏃ユ湡" width="130" align="center"></el-table-column>
+ <el-table-column label="璋冩暣鍓嶅浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級" width="240" align="right">
+ <template slot-scope="s">{{ formatOriginalAdjustmentValue(s.row.originalValue) }}</template>
+ </el-table-column>
+ <el-table-column label="璋冩暣鍚庡浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級" width="240" align="right">
+ <template slot-scope="s">{{ formatAdjustmentValue(s.row.adjustedValue) }}</template>
+ </el-table-column>
+ <el-table-column prop="reason" label="璋冩暣鍘熷洜" min-width="280" show-overflow-tooltip></el-table-column>
+ <el-table-column label="鏇存柊鏃堕棿" min-width="170"><template slot-scope="s">{{ formatDateTime(s.row.updatedAt) }}</template></el-table-column>
+ <el-table-column v-if="operatorIsAdmin" label="鎿嶄綔" width="130" align="center">
+ <template slot-scope="s">
+ <el-button type="text" @click="openAdjustmentDialog(s.row)">缂栬緫</el-button>
+ <el-button type="text" class="danger-text" @click="deleteAdjustment(s.row)">鍒犻櫎</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ <el-dialog :title="adjustmentForm.id ? '缂栬緫璋冩暣' : '鏂板璋冩暣'" :visible.sync="adjustmentDialogVisible" width="560px" v-dialog-drag>
+ <el-form label-width="220px">
+ <el-form-item label="骞翠唤">
+ <el-select v-model="adjustmentForm.year" style="width:100%" @change="onAdjustmentYearChange">
+ <el-option v-for="item in availableAdjustmentYears" :key="item" :label="item + '骞�'" :value="item"></el-option>
+ </el-select>
+ </el-form-item>
+ <el-form-item label="鏃ユ湡">
+ <el-date-picker v-model="adjustmentForm.flowDate" type="date" value-format="yyyy-MM-dd"
+ style="width:100%" :picker-options="adjustmentDatePickerOptions" placeholder="閫夋嫨鏃ユ湡"
+ @change="onAdjustmentDateChange"></el-date-picker>
+ </el-form-item>
+ <el-form-item label="璋冩暣鍓嶅浗鐪侀亾杞︽祦閲忥紙涓囪締娆★級">
+ <el-input :value="formatOriginalAdjustmentValue(adjustmentForm.originalValue)" readonly
+ :placeholder="adjustmentOriginalLoading ? '鏌ヨ涓�...' : ''"></el-input>
+ </el-form-item>
+ <el-form-item label="璋冩暣鍚庤溅娴侀噺锛堜竾杈嗘锛�">
+ <el-input-number v-model="adjustmentForm.adjustedValue" :min="0" :precision="4" :step="0.0001" controls-position="right" style="width:100%"></el-input-number>
+ </el-form-item>
+ <el-form-item label="璋冩暣鍘熷洜">
+ <el-input v-model="adjustmentForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit></el-input>
+ </el-form-item>
+ </el-form>
+ <div slot="footer">
+ <el-button @click="adjustmentDialogVisible = false">鍙栨秷</el-button>
+ <el-button type="primary" :loading="adjustmentSaving" @click="saveAdjustment">淇濆瓨</el-button>
+ </div>
+ </el-dialog>
+ </el-card>
+
+ <el-card shadow="never" style="margin-top:14px">
+ <div slot="header">
+ <span>瑙傛祴绔欏垎鏃ヨ皟鏌ユ暟鎹�</span>
+ <span class="hint">鐢ㄤ簬鍘熷鏁版嵁鐣欏瓨锛屼笉涓庝笂鏂硅妭鍋囨棩缁熻鏁版嵁鍏宠仈</span>
+ </div>
+ <div class="operation-row">
+ <div class="operation-item">
+ <span class="label">瀵煎叆鏁版嵁</span>
+ <el-upload action="#" :auto-upload="false" :show-file-list="true" :limit="1"
+ accept=".xls,.xlsx" :on-change="onObservationFileChange" :on-remove="onObservationFileRemove"
+ :file-list="observationFileList">
+ <el-button type="primary" plain icon="el-icon-folder-opened">閫夋嫨鏂囦欢</el-button>
+ </el-upload>
+ <el-button type="primary" icon="el-icon-upload2" :loading="observationImporting"
+ :disabled="!observationFile" @click="importObservation">瀵煎叆</el-button>
+ </div>
+ </div>
+ <div v-if="observationMessage" class="import-message">
+ <pre>{{ observationMessage }}</pre>
+ </div>
+ <el-table :data="observationDates" border stripe size="small" style="margin-top:12px" v-loading="observationLoading">
+ <el-table-column prop="obsDate" label="瑙傛祴鏃ユ湡" width="130" align="center"></el-table-column>
+ <el-table-column prop="stationCount" label="瑙傛祴绔欐暟" width="110" align="right"></el-table-column>
+ <el-table-column label="涓皬瀹㈡祦閲忓悎璁★紙涓囪締娆★級" width="210" align="right">
+ <template slot-scope="s">{{ formatWanVehicles(s.row.smallMediumPassengerTotal) }}</template>
+ </el-table-column>
+ <el-table-column prop="importedAt" label="鏈�杩戝鍏ユ椂闂�" min-width="180"></el-table-column>
+ <el-table-column label="鎿嶄綔" width="150" align="center">
+ <template slot-scope="s">
+ <el-button type="text" @click="viewObservation(s.row.obsDate)">鏌ョ湅</el-button>
+ <el-button type="text" class="danger-text" @click="deleteObservation(s.row.obsDate)">鍒犻櫎</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ </el-card>
+
+ <el-dialog :title="'瑙傛祴绔欐槑缁� - ' + observationDialogDate" :visible.sync="observationDialogVisible" width="92%" top="5vh">
+ <el-table :data="observationRows" border stripe size="mini" height="540" v-loading="observationRowsLoading">
+ <el-table-column prop="obsDate" label="瑙傛祴鏃ユ湡" width="110" fixed="left" align="center"></el-table-column>
+ <el-table-column prop="stationCode" label="瑙傛祴绔欑紪鍙�" width="130" fixed="left"></el-table-column>
+ <el-table-column prop="stationName" label="瑙傛祴绔欏悕绉�" width="120"></el-table-column>
+ <el-table-column label="瑙傛祴閲岀▼" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.mileage) }}</template></el-table-column>
+ <el-table-column prop="cityName" label="鍦板競鍚嶇О" width="140"></el-table-column>
+ <el-table-column prop="countyName" label="鍖哄幙鍚嶇О" width="110"></el-table-column>
+ <el-table-column prop="direction" label="琛岄┒鏂瑰悜" width="90"></el-table-column>
+ <el-table-column label="涓皬瀹㈡祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallMediumPassenger) }}</template></el-table-column>
+ <el-table-column label="澶у杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largePassenger) }}</template></el-table-column>
+ <el-table-column label="灏忚揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.smallTruck) }}</template></el-table-column>
+ <el-table-column label="涓揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.mediumTruck) }}</template></el-table-column>
+ <el-table-column label="澶ц揣杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.largeTruck) }}</template></el-table-column>
+ <el-table-column label="鐗瑰ぇ璐ф祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.extraLargeTruck) }}</template></el-table-column>
+ <el-table-column label="闆嗚绠辨祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.containerTruck) }}</template></el-table-column>
+ <el-table-column label="鎽╂墭杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorcycle) }}</template></el-table-column>
+ <el-table-column label="鎷栨媺鏈烘祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.tractor) }}</template></el-table-column>
+ <el-table-column label="瀹㈣溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerTotal) }}</template></el-table-column>
+ <el-table-column label="璐ц溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckTotal) }}</template></el-table-column>
+ <el-table-column label="姹借溅娴侀噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carTotal) }}</template></el-table-column>
+ <el-table-column label="鏈哄姩杞︽祦閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorTotal) }}</template></el-table-column>
+ <el-table-column label="瀹㈣溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.passengerEquivalent) }}</template></el-table-column>
+ <el-table-column label="璐ц溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.truckEquivalent) }}</template></el-table-column>
+ <el-table-column label="姹借溅褰撻噺" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.carEquivalent) }}</template></el-table-column>
+ <el-table-column label="鏈哄姩杞﹀綋閲�" width="110" align="right"><template slot-scope="s">{{ formatInteger(s.row.motorEquivalent) }}</template></el-table-column>
+ <el-table-column label="鏈哄姩杞﹂�熷害" width="110" align="right"><template slot-scope="s">{{ formatPlain(s.row.vehicleSpeed) }}</template></el-table-column>
+ <el-table-column label="鎷ユ尋搴�" width="100" align="right"><template slot-scope="s">{{ formatPlain(s.row.congestionDegree) }}</template></el-table-column>
+ <el-table-column prop="sourceFile" label="鏉ユ簮鏂囦欢鍚�" min-width="220" show-overflow-tooltip></el-table-column>
+ </el-table>
+ </el-dialog>
+ </div>
+ </el-main>
+ </el-container>
+ </el-container>
+</template>
+
+<script>
+import api from '@/api'
+import AppHeader from '@/components/AppHeader.vue'
+
+export default {
+ components: { AppHeader },
+ name: 'HolidayStat',
+ data() {
+ return {
+ years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
+ year: 2026,
+ holidayType: 'NATIONAL_DAY',
+ holidayTypeOptions: [],
+ rows: [],
+ loading: false,
+ importing: false,
+ exporting: false,
+ bulletinLoading: false,
+ trendLoading: false,
+ selectedFile: null,
+ fileList: [],
+ importMessage: '',
+ allowHistoricalOverwrite: false,
+ bulletinDate: this.previousDateString(),
+ observationFile: null,
+ observationFileList: [],
+ observationImporting: false,
+ observationLoading: false,
+ observationMessage: '',
+ observationDates: [],
+ observationDialogVisible: false,
+ observationDialogDate: '',
+ observationRows: [],
+ observationRowsLoading: false,
+ adjustmentYear: new Date().getFullYear() - 1,
+ adjustments: [],
+ adjustmentLoading: false,
+ adjustmentDialogVisible: false,
+ adjustmentSaving: false,
+ adjustmentOriginalLoading: false,
+ adjustmentOriginalRequestId: 0,
+ adjustmentForm: {
+ id: null,
+ year: new Date().getFullYear() - 1,
+ flowDate: '',
+ originalValue: null,
+ adjustedValue: null,
+ reason: ''
+ }
+ }
+ },
+ computed: {
+ holidayTypeLabel() {
+ const item = this.holidayTypeOptions.find(x => x.value === this.holidayType)
+ return item ? item.label : this.holidayType
+ },
+ availableAdjustmentYears() {
+ const currentYear = new Date().getFullYear()
+ return this.years.filter(item => item < currentYear)
+ },
+ adjustmentDatePickerOptions() {
+ return {
+ disabledDate: date => date.getFullYear() !== Number(this.adjustmentForm.year)
+ }
+ }
+ },
+ mounted() {
+ this.loadOptions()
+ this.loadOverview()
+ this.loadObservationDates()
+ this.loadAdjustments()
+ },
+ methods: {
+ previousDateString() {
+ const date = new Date()
+ date.setDate(date.getDate() - 1)
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return date.getFullYear() + '-' + month + '-' + day
+ },
+ loadOptions() {
+ api.get('/holiday/config/options').then(res => {
+ const map = (res.data && res.data.holidayTypes) || {}
+ this.holidayTypeOptions = Object.keys(map).map(key => ({ value: key, label: map[key] }))
+ })
+ },
+ loadOverview() {
+ this.loading = true
+ api.get('/holiday/overview', { params: { year: this.year, holidayType: this.holidayType } })
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+ this.rows = (res.data && res.data.rows) || []
+ })
+ .catch(err => this.showError(err, '鑺傚亣鏃ユ暟鎹姞杞藉け璐�'))
+ .finally(() => { this.loading = false })
+ },
+ loadAdjustments() {
+ this.adjustmentLoading = true
+ api.get('/holiday/adjustments', { params: { year: this.adjustmentYear } })
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+ this.adjustments = res.data || []
+ })
+ .catch(err => this.showError(err, '寰�骞村浗鐪侀亾鏁版嵁璋冩暣鍔犺浇澶辫触'))
+ .finally(() => { this.adjustmentLoading = false })
+ },
+ openAdjustmentDialog(row) {
+ const source = row || {}
+ this.adjustmentForm = {
+ id: source.id || null,
+ year: source.year || this.adjustmentYear,
+ flowDate: source.flowDate || '',
+ originalValue: source.originalValue === undefined || source.originalValue === null
+ ? null : Number(source.originalValue),
+ adjustedValue: source.adjustedValue === undefined || source.adjustedValue === null
+ ? null : Number(source.adjustedValue),
+ reason: source.reason || ''
+ }
+ this.adjustmentDialogVisible = true
+ this.loadAdjustmentOriginalValue()
+ },
+ onAdjustmentYearChange() {
+ this.adjustmentOriginalRequestId += 1
+ this.adjustmentOriginalLoading = false
+ this.adjustmentForm.flowDate = ''
+ this.adjustmentForm.originalValue = null
+ },
+ onAdjustmentDateChange() {
+ this.loadAdjustmentOriginalValue()
+ },
+ loadAdjustmentOriginalValue() {
+ const year = this.adjustmentForm.year
+ const flowDate = this.adjustmentForm.flowDate
+ const requestId = ++this.adjustmentOriginalRequestId
+ this.adjustmentForm.originalValue = null
+ if (!year || !flowDate) {
+ this.adjustmentOriginalLoading = false
+ return
+ }
+ this.adjustmentOriginalLoading = true
+ api.get('/holiday/adjustments/source-value', { params: { year: year, flowDate: flowDate } })
+ .then(res => {
+ if (requestId !== this.adjustmentOriginalRequestId) return
+ if (res.code !== 200) throw new Error(res.message || '璋冩暣鍓嶅�煎姞杞藉け璐�')
+ this.adjustmentForm.originalValue = res.data === null || res.data === undefined || res.data === ''
+ ? null : Number(res.data)
+ })
+ .catch(err => {
+ if (requestId !== this.adjustmentOriginalRequestId) return
+ this.adjustmentForm.originalValue = null
+ this.showError(err, '璋冩暣鍓嶅�煎姞杞藉け璐�')
+ })
+ .finally(() => {
+ if (requestId === this.adjustmentOriginalRequestId) this.adjustmentOriginalLoading = false
+ })
+ },
+ saveAdjustment() {
+ const form = this.adjustmentForm
+ if (!form.year || !form.flowDate) {
+ this.$message.warning('璇烽�夋嫨骞翠唤鍜屾棩鏈�')
+ return
+ }
+ if (form.adjustedValue === null || form.adjustedValue === undefined || Number(form.adjustedValue) < 0) {
+ this.$message.warning('璇疯緭鍏ヤ笉浣庝簬 0 鐨勮皟鏁村悗杞︽祦閲�')
+ return
+ }
+ if (!form.reason || !form.reason.trim()) {
+ this.$message.warning('璇峰~鍐欒皟鏁村師鍥�')
+ return
+ }
+ this.adjustmentSaving = true
+ api.post('/holiday/adjustments/save', {
+ id: form.id,
+ year: form.year,
+ flowDate: form.flowDate,
+ adjustedValue: form.adjustedValue,
+ reason: form.reason
+ })
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '淇濆瓨澶辫触')
+ this.adjustmentDialogVisible = false
+ this.adjustmentYear = form.year
+ this.loadAdjustments()
+ this.loadOverview()
+ this.$message.success('璋冩暣宸蹭繚瀛�')
+ })
+ .catch(err => this.showError(err, '淇濆瓨澶辫触'))
+ .finally(() => { this.adjustmentSaving = false })
+ },
+ deleteAdjustment(row) {
+ this.$confirm('纭畾鍒犻櫎 ' + row.flowDate + ' 鐨勫浗鐪侀亾鏁版嵁璋冩暣鍚楋紵', '鍒犻櫎纭', { type: 'warning' })
+ .then(() => api.post('/holiday/adjustments/delete', null, { params: { id: row.id } }))
+ .then(res => {
+ if (res && res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+ this.loadAdjustments()
+ this.loadOverview()
+ this.$message.success('璋冩暣宸插垹闄�')
+ })
+ .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '鍒犻櫎澶辫触') })
+ },
+ onFileChange(file) {
+ this.selectedFile = file.raw
+ this.fileList = [file]
+ this.importMessage = ''
+ },
+ onFileRemove() {
+ this.selectedFile = null
+ this.fileList = []
+ },
+ importData(allowHistoricalOverwrite) {
+ if (!this.selectedFile) return
+ const allowOverwrite = allowHistoricalOverwrite === true || this.allowHistoricalOverwrite === true
+ const form = new FormData()
+ form.append('file', this.selectedFile)
+ form.append('year', this.year)
+ form.append('holidayType', this.holidayType)
+ form.append('allowHistoricalOverwrite', allowOverwrite ? 'true' : 'false')
+ this.importing = true
+ this.importMessage = ''
+ api.post('/holiday/import', form, { timeout: 120000, headers: { 'Content-Type': 'multipart/form-data' } })
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '瀵煎叆澶辫触')
+ const data = res.data || {}
+ if (data.confirmationRequired) {
+ const changes = data.historicalChanges || []
+ this.importMessage = ['妫�娴嬪埌鍘嗗彶鏃ユ湡鐨勬暟鎹彂鐢熷彉鍖栵紝灏氭湭瀵煎叆銆傝纭鏄惁鍏佽瑕嗙洊锛�'].concat(changes).join('\n')
+ this.$message.warning('妫�娴嬪埌鍘嗗彶鏁版嵁鍙戠敓鍙樺寲锛岃纭鏄惁鍏佽瀵煎叆')
+ this.confirmHistoricalImport(changes)
+ return
+ }
+ const lines = [
+ '瀵煎叆瀹屾垚锛氬娴� ' + (data.passengerRows || 0) + ' 鏉★紝杞︽祦 ' + (data.vehicleRows || 0) + ' 鏉★紝璺宠繃绌虹櫧 ' + (data.skippedRows || 0) + ' 鏉°��'
+ ]
+ if (data.errors && data.errors.length) {
+ lines.push('澶辫触鏄庣粏锛�')
+ data.errors.forEach(item => lines.push(item))
+ }
+ this.importMessage = lines.join('\n')
+ this.allowHistoricalOverwrite = false
+ this.$message.success('瀵煎叆瀹屾垚')
+ this.loadOverview()
+ })
+ .catch(err => {
+ this.allowHistoricalOverwrite = false
+ this.showError(err, '瀵煎叆澶辫触')
+ })
+ .finally(() => { this.importing = false })
+ },
+ confirmHistoricalImport(changes) {
+ const rows = (changes && changes.length ? changes : ['鍘嗗彶鏁版嵁鍙戠敓鍙樺寲']).map(this.escapeHtml)
+ const html = '<div>妫�娴嬪埌宸插彂甯冩棩鏈熺殑鏁版嵁鍙戠敓鍙樺寲銆傚厑璁稿鍏ュ皢瑕嗙洊杩欎簺鍘嗗彶鏁版嵁銆�</div>'
+ + '<pre style="max-height:320px;overflow:auto;white-space:pre-wrap;margin-top:10px;background:#f7f7f7;padding:8px;border:1px solid #e4e7ed;">'
+ + rows.join('\n') + '</pre>'
+ this.$confirm(html, '鍘嗗彶鏁版嵁鍙戠敓鍙樺寲', {
+ dangerouslyUseHTMLString: true,
+ confirmButtonText: '鍏佽瀵煎叆',
+ cancelButtonText: '涓嶅厑璁�',
+ type: 'warning',
+ distinguishCancelAndClose: true
+ }).then(() => {
+ this.allowHistoricalOverwrite = true
+ this.importData(true)
+ }).catch(() => {
+ this.allowHistoricalOverwrite = false
+ })
+ },
+ escapeHtml(value) {
+ return String(value === null || value === undefined ? '' : value)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ },
+ exportData() {
+ this.exporting = true
+ api.get('/holiday/export', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 120000 })
+ .then(res => { this.downloadBlob(res, this.year + '骞�' + this.holidayTypeLabel + '鏁版嵁.xlsx'); this.$message.success('瀵煎嚭鎴愬姛') })
+ .catch(err => this.handleBlobError(err, '瀵煎嚭澶辫触'))
+ .finally(() => { this.exporting = false })
+ },
+ downloadTemplate() {
+ const templatePath = '涓撻」缁熻/閲嶈鑺傚亣鏃�/2026骞村崄涓�鏁版嵁.xlsx'
+ const encodedPath = templatePath.split('/').map(encodeURIComponent).join('/')
+ api.get('/templates/' + encodedPath, { responseType: 'blob' })
+ .then(res => { this.downloadBlob(res, '2026骞村崄涓�鏁版嵁.xlsx'); this.$message.success('妯℃澘涓嬭浇鎴愬姛') })
+ .catch(err => this.handleBlobError(err, '妯℃澘涓嬭浇澶辫触'))
+ },
+ downloadBulletin() {
+ this.bulletinLoading = true
+ api.get('/holiday/bulletin', { params: { date: this.bulletinDate }, responseType: 'blob', timeout: 120000 })
+ .then(res => {
+ const day = Number((this.bulletinDate || '').slice(-2))
+ this.downloadBlob(res, '婀栧寳鐪佸浗搴嗗亣鏈熺患鍚堣繍杈撳娴佸揩鎶ワ紙10鏈�' + day + '鏃ワ級.docx')
+ this.$message.success('蹇姤涓嬭浇鎴愬姛')
+ })
+ .catch(err => this.handleBlobError(err, '蹇姤鐢熸垚澶辫触'))
+ .finally(() => { this.bulletinLoading = false })
+ },
+ downloadTrend() {
+ this.trendLoading = true
+ api.get('/holiday/trend', { params: { year: this.year, holidayType: this.holidayType }, responseType: 'blob', timeout: 180000 })
+ .then(res => {
+ this.downloadBlob(res, this.year + '骞�' + this.holidayTypeLabel + '鍋囨湡鍏ㄧ渷缁煎悎杩愯緭瀹㈡祦閲忚蛋鍔垮浘.docx')
+ this.$message.success('璧板娍鍥句笅杞芥垚鍔�')
+ })
+ .catch(err => this.handleBlobError(err, '璧板娍鍥剧敓鎴愬け璐�'))
+ .finally(() => { this.trendLoading = false })
+ },
+ downloadBlob(data, fileName) {
+ const blob = data instanceof Blob ? data : new Blob([data])
+ const url = window.URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ window.URL.revokeObjectURL(url)
+ },
+ handleBlobError(err, fallback) {
+ if (err && err.response && err.response.data instanceof Blob) {
+ err.response.data.text().then(text => {
+ try {
+ const parsed = JSON.parse(text)
+ this.$message.error(parsed.message || fallback)
+ } catch (e) {
+ this.$message.error(fallback)
+ }
+ })
+ return
+ }
+ this.showError(err, fallback)
+ },
+ showError(err, fallback) {
+ if (err && err._toast) return
+ const data = err && err.response && err.response.data
+ this.$message.error((data && data.message) || (err && err.message) || fallback)
+ },
+ onObservationFileChange(file) {
+ this.observationFile = file.raw
+ this.observationFileList = [file]
+ this.observationMessage = ''
+ },
+ onObservationFileRemove() {
+ this.observationFile = null
+ this.observationFileList = []
+ },
+ loadObservationDates() {
+ this.observationLoading = true
+ api.get('/observation/dates')
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+ this.observationDates = res.data || []
+ })
+ .catch(err => this.showError(err, '瑙傛祴绔欐暟鎹姞杞藉け璐�'))
+ .finally(() => { this.observationLoading = false })
+ },
+ importObservation() {
+ if (!this.observationFile) return
+ const form = new FormData()
+ form.append('file', this.observationFile)
+ this.observationImporting = true
+ this.observationMessage = ''
+ api.post('/observation/import', form, { timeout: 300000, headers: { 'Content-Type': 'multipart/form-data' } })
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '瀵煎叆澶辫触')
+ const data = res.data || {}
+ const dates = (data.dates && data.dates.length) ? data.dates.join('銆�') : '鈥�'
+ const lines = ['瀵煎叆瀹屾垚锛�' + (data.totalRows || 0) + ' 鏉★紝瑙傛祴绔� ' + (data.stationCount || 0)
+ + ' 涓紝鏃ユ湡 ' + dates + '锛岃烦杩囩┖鐧� ' + (data.skippedRows || 0) + ' 鏉°��']
+ if (data.errors && data.errors.length) {
+ lines.push('澶辫触鏄庣粏锛�')
+ data.errors.forEach(item => lines.push(item))
+ }
+ this.observationMessage = lines.join('\n')
+ this.$message.success('瑙傛祴绔欐暟鎹鍏ュ畬鎴�')
+ this.observationFile = null
+ this.observationFileList = []
+ this.loadObservationDates()
+ })
+ .catch(err => this.showError(err, '瑙傛祴绔欐暟鎹鍏ュけ璐�'))
+ .finally(() => { this.observationImporting = false })
+ },
+ viewObservation(date) {
+ this.observationDialogDate = date
+ this.observationDialogVisible = true
+ this.observationRowsLoading = true
+ this.observationRows = []
+ api.get('/observation/rows', { params: { date: date, limit: 2000 } })
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '鍔犺浇澶辫触')
+ this.observationRows = res.data || []
+ })
+ .catch(err => this.showError(err, '瑙傛祴绔欐槑缁嗗姞杞藉け璐�'))
+ .finally(() => { this.observationRowsLoading = false })
+ },
+ deleteObservation(date) {
+ this.$confirm('纭畾鍒犻櫎 ' + date + ' 鐨勮娴嬬珯鏁版嵁鍚楋紵', '鍒犻櫎纭', { type: 'warning' })
+ .then(() => api.post('/observation/delete', null, { params: { date: date } }))
+ .then(res => {
+ if (res && res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+ this.$message.success('宸插垹闄�')
+ this.loadObservationDates()
+ })
+ .catch(err => { if (err !== 'cancel' && err !== 'close') this.showError(err, '鍒犻櫎澶辫触') })
+ },
+ formatAdjustmentValue(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return number.toFixed(4)
+ },
+ formatOriginalAdjustmentValue(value) {
+ if (value === null || value === undefined || value === '') return ''
+ return this.formatAdjustmentValue(value)
+ },
+ formatDateTime(value) {
+ if (!value) return '鈥�'
+ return String(value).replace('T', ' ').slice(0, 19)
+ },
+ formatNumber(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ return Number(value).toFixed(2)
+ },
+ formatWanVehicles(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return (number / 10000).toFixed(4)
+ },
+ formatInteger(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return String(Math.round(number))
+ },
+ formatPlain(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return String(number)
+ },
+ formatCoefficient(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
+ },
+ formatRatio(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return (number * 100).toFixed(2) + '%'
+ }
+ }
+}
+</script>
+
+<style scoped>
+.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
+.operation-card { margin-top: 12px; }
+.operation-row { display: flex; gap: 36px; align-items: flex-start; flex-wrap: wrap; }
+.operation-item { display: flex; align-items: center; gap: 8px; }
+.operation-item .label { color: #606266; font-size: 14px; }
+.import-message { margin-top: 12px; padding: 10px 12px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
+.import-message pre { margin: 0; white-space: pre-wrap; font-family: inherit; color: #606266; }
+.hint { margin-left: 10px; color: #909399; font-size: 12px; }
+.danger-text { color: #f56c6c; }
+</style>
diff --git a/traffic-audit-web/src/views/Login.vue b/traffic-audit-web/src/views/Login.vue
index fbe98ac..c08362c 100644
--- a/traffic-audit-web/src/views/Login.vue
+++ b/traffic-audit-web/src/views/Login.vue
@@ -1,7 +1,7 @@
<template>
<div class="login-container">
<el-card class="login-card">
- <h2>浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁�</h2>
+ <h2>浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁�<span class="login-version" v-if="version">V{{ version }}</span></h2>
<el-form ref="form" :model="form" :rules="rules">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="鐢ㄦ埛鍚�" @keyup.enter.native="login"></el-input>
@@ -40,10 +40,17 @@
captchaId: '',
clickIds: [],
loggingIn: false,
- captchaRequired: false
+ captchaRequired: false,
+ version: ''
}
},
created() {
+ axios.get('/api/version', { timeout: 10000 })
+ .then(res => {
+ const d = res.data
+ if (d && d.data && d.data.version) this.version = d.data.version
+ })
+ .catch(() => {})
// 楠岃瘉鐮佹槸鍚﹀惎鐢ㄤ竴寰嬩互鍚庣 /api/auth/captcha-config 涓哄噯锛�
// 榛樿涓嶆樉绀猴紝鍙湁鍚庣鏄庣‘杩斿洖 required=true 鎵嶆覆鏌撻獙璇佺爜缁勪欢銆�
// 淇锛氭鍓嶉粯璁� true 涓旇姹傚け璐ユ椂闈欓粯蹇界暐锛屽垏鎹㈢綉缁滃悗璇ラ厤缃涓嶅埌锛�
@@ -114,5 +121,6 @@
.captcha-skip-tip { font-size: 12px; color: #909399; line-height: 1.6; padding: 8px 10px; background: #F5F7FA; border-radius: 4px; }
.login-card { width: 400px; }
.login-card h2 { text-align: center; margin-bottom: 18px; }
+.login-version { font-size: 13px; font-weight: normal; color: #909399; margin-left: 8px; vertical-align: 3px; }
.login-card .el-form-item { margin-bottom: 16px; }
</style>
diff --git a/traffic-audit-web/src/views/ReportExport.vue b/traffic-audit-web/src/views/ReportExport.vue
index d9e76d3..bb53610 100644
--- a/traffic-audit-web/src/views/ReportExport.vue
+++ b/traffic-audit-web/src/views/ReportExport.vue
@@ -10,8 +10,13 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
</el-menu>
</el-aside>
<el-main>
diff --git a/traffic-audit-web/src/views/RuleManage.vue b/traffic-audit-web/src/views/RuleManage.vue
index 260f61c..2322a65 100644
--- a/traffic-audit-web/src/views/RuleManage.vue
+++ b/traffic-audit-web/src/views/RuleManage.vue
@@ -10,8 +10,13 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
</el-menu>
</el-aside>
<el-main>
diff --git a/traffic-audit-web/src/views/SystemConfig.vue b/traffic-audit-web/src/views/SystemConfig.vue
new file mode 100644
index 0000000..5379745
--- /dev/null
+++ b/traffic-audit-web/src/views/SystemConfig.vue
@@ -0,0 +1,304 @@
+<template>
+ <el-container direction="vertical">
+ <app-header title="浜ら�氱粺璁℃姤琛ㄥ鏍哥郴缁� - 绯荤粺閰嶇疆"></app-header>
+ <el-container>
+ <el-aside width="200px">
+ <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect">
+ <el-menu-item index="/dashboard">宸ヤ綔鍙�</el-menu-item>
+ <el-menu-item index="/import">鏁版嵁瀵煎叆</el-menu-item>
+ <el-menu-item index="/data">鏁版嵁鏌ョ湅</el-menu-item>
+ <el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
+ <el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
+ <el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
+ <el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
+ <el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
+ </el-menu>
+ </el-aside>
+ <el-main>
+ <div class="page-scroll">
+ <h3>绯荤粺閰嶇疆</h3>
+ <el-tabs v-model="activeTab" @tab-click="loadActiveTab">
+ <el-tab-pane label="鑺傚亣鏃ョ鐞�" name="holidays">
+ <div class="toolbar">
+ <el-select v-model="holidayQuery.year" clearable placeholder="骞翠唤" style="width:130px" @change="loadHolidays">
+ <el-option v-for="year in years" :key="year" :label="year + '骞�'" :value="year"></el-option>
+ </el-select>
+ <el-select v-model="holidayQuery.holidayType" clearable placeholder="鑺傚亣鏃ュ垎绫�" style="width:150px" @change="loadHolidays">
+ <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+ </el-select>
+ <el-button type="primary" icon="el-icon-search" @click="loadHolidays">鏌ヨ</el-button>
+ <el-button type="success" icon="el-icon-plus" @click="openHolidayDialog()">鏂板鑺傚亣鏃�</el-button>
+ </div>
+ <el-table :data="holidays" border stripe size="small" v-loading="loadingHolidays" style="width:100%">
+ <el-table-column prop="year" label="骞翠唤" width="100" align="center"></el-table-column>
+ <el-table-column label="鑺傚亣鏃ュ垎绫�" width="140">
+ <template slot-scope="scope">{{ labelOf(holidayTypeOptions, scope.row.holidayType) }}</template>
+ </el-table-column>
+ <el-table-column prop="holidayDate" label="鏃ユ湡" width="150" align="center"></el-table-column>
+ <el-table-column label="鏃ユ湡绫诲瀷" width="120" align="center">
+ <template slot-scope="scope">{{ dayIndexLabel(scope.row.dayIndex) }}</template>
+ </el-table-column>
+ <el-table-column label="鎿嶄綔" width="150" align="center">
+ <template slot-scope="scope">
+ <el-button type="text" size="mini" @click="openHolidayDialog(scope.row)">缂栬緫</el-button>
+ <el-button type="text" size="mini" class="danger-text" @click="removeHoliday(scope.row)">鍒犻櫎</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ </el-tab-pane>
+
+ <el-tab-pane label="瀹㈣繍绯绘暟绠$悊" name="coefficients">
+ <div class="toolbar">
+ <el-select v-model="coefficientQuery.year" clearable placeholder="骞翠唤" style="width:130px" @change="loadCoefficients">
+ <el-option v-for="year in years" :key="year" :label="year + '骞�'" :value="year"></el-option>
+ </el-select>
+ <el-button type="primary" icon="el-icon-search" @click="loadCoefficients">鏌ヨ</el-button>
+ <el-button type="success" icon="el-icon-plus" @click="openCoefficientDialog()">鏂板瀹㈣繍绯绘暟</el-button>
+ </div>
+ <el-table :data="coefficients" border stripe size="small" v-loading="loadingCoefficients" style="width:100%">
+ <el-table-column prop="year" label="骞翠唤" width="120" align="center"></el-table-column>
+ <el-table-column label="鏃ユ湡鍒嗙被" width="160">
+ <template slot-scope="scope">{{ labelOf(dateCategoryOptions, scope.row.dateCategory) }}</template>
+ </el-table-column>
+ <el-table-column label="瀹㈣繍绯绘暟" width="160" align="center">
+ <template slot-scope="scope">{{ formatCoefficient(scope.row.coefficient) }}</template>
+ </el-table-column>
+ <el-table-column label="鎿嶄綔" width="150" align="center">
+ <template slot-scope="scope">
+ <el-button type="text" size="mini" @click="openCoefficientDialog(scope.row)">缂栬緫</el-button>
+ <el-button type="text" size="mini" class="danger-text" @click="removeCoefficient(scope.row)">鍒犻櫎</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ </el-tab-pane>
+ </el-tabs>
+ </div>
+
+ <el-dialog :title="holidayForm.id ? '缂栬緫鑺傚亣鏃�' : '鏂板鑺傚亣鏃�'" :visible.sync="holidayDialog" width="520px" v-dialog-drag>
+ <el-form ref="holidayForm" :model="holidayForm" :rules="holidayRules" label-width="110px">
+ <el-form-item label="骞翠唤" prop="year">
+ <el-select v-model="holidayForm.year" style="width:100%" @change="onHolidayYearChange">
+ <el-option v-for="year in years" :key="year" :label="year + '骞�'" :value="year"></el-option>
+ </el-select>
+ </el-form-item>
+ <el-form-item label="鑺傚亣鏃ュ垎绫�" prop="holidayType">
+ <el-select v-model="holidayForm.holidayType" style="width:100%">
+ <el-option v-for="item in holidayTypeOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+ </el-select>
+ </el-form-item>
+ <el-form-item label="鏃ユ湡" prop="holidayDate">
+ <el-date-picker v-model="holidayForm.holidayDate" type="date" value-format="yyyy-MM-dd"
+ style="width:100%" placeholder="閫夋嫨鏃ユ湡"></el-date-picker>
+ </el-form-item>
+ <el-form-item label="鏃ユ湡绫诲瀷" prop="dayIndex">
+ <el-select v-model="holidayForm.dayIndex" style="width:100%">
+ <el-option label="鍓嶄竴鏃�" :value="0"></el-option>
+ <el-option v-for="day in 31" :key="day" :label="'绗�' + day + '鏃�'" :value="day"></el-option>
+ </el-select>
+ </el-form-item>
+ </el-form>
+ <span slot="footer">
+ <el-button @click="holidayDialog = false">鍙栨秷</el-button>
+ <el-button type="primary" :loading="saving" @click="saveHoliday">淇濆瓨</el-button>
+ </span>
+ </el-dialog>
+
+ <el-dialog :title="coefficientForm.id ? '缂栬緫瀹㈣繍绯绘暟' : '鏂板瀹㈣繍绯绘暟'" :visible.sync="coefficientDialog" width="520px" v-dialog-drag>
+ <el-form ref="coefficientForm" :model="coefficientForm" :rules="coefficientRules" label-width="110px">
+ <el-form-item label="骞翠唤" prop="year">
+ <el-select v-model="coefficientForm.year" style="width:100%">
+ <el-option v-for="year in years" :key="year" :label="year + '骞�'" :value="year"></el-option>
+ </el-select>
+ </el-form-item>
+ <el-form-item label="鏃ユ湡鍒嗙被" prop="dateCategory">
+ <el-select v-model="coefficientForm.dateCategory" style="width:100%">
+ <el-option v-for="item in dateCategoryOptions" :key="item.value" :label="item.label" :value="item.value"></el-option>
+ </el-select>
+ </el-form-item>
+ <el-form-item label="瀹㈣繍绯绘暟" prop="coefficient">
+ <el-input-number v-model="coefficientForm.coefficient" :min="0.01" :precision="4" :step="0.1" style="width:100%"></el-input-number>
+ </el-form-item>
+ </el-form>
+ <span slot="footer">
+ <el-button @click="coefficientDialog = false">鍙栨秷</el-button>
+ <el-button type="primary" :loading="saving" @click="saveCoefficient">淇濆瓨</el-button>
+ </span>
+ </el-dialog>
+ </el-main>
+ </el-container>
+ </el-container>
+</template>
+
+<script>
+import api from '@/api'
+import AppHeader from '@/components/AppHeader.vue'
+
+export default {
+ components: { AppHeader },
+ name: 'SystemConfig',
+ data() {
+ return {
+ activeTab: 'holidays',
+ years: [2019, 2024, 2025, 2026, 2027, 2028, 2029, 2030],
+ holidayTypeOptions: [],
+ dateCategoryOptions: [],
+ holidayQuery: { year: 2026, holidayType: 'NATIONAL_DAY' },
+ coefficientQuery: { year: 2026 },
+ holidays: [],
+ coefficients: [],
+ loadingHolidays: false,
+ loadingCoefficients: false,
+ saving: false,
+ holidayDialog: false,
+ coefficientDialog: false,
+ holidayForm: { id: null, year: 2026, holidayType: 'NATIONAL_DAY', holidayDate: '', dayIndex: 1 },
+ coefficientForm: { id: null, year: 2026, dateCategory: 'HOLIDAY', coefficient: 2.4 },
+ holidayRules: {
+ year: [{ required: true, message: '璇烽�夋嫨骞翠唤', trigger: 'change' }],
+ holidayType: [{ required: true, message: '璇烽�夋嫨鑺傚亣鏃ュ垎绫�', trigger: 'change' }],
+ holidayDate: [{ required: true, message: '璇烽�夋嫨鏃ユ湡', trigger: 'change' }],
+ dayIndex: [{ required: true, message: '璇烽�夋嫨鏃ユ湡绫诲瀷', trigger: 'change' }]
+ },
+ coefficientRules: {
+ year: [{ required: true, message: '璇烽�夋嫨骞翠唤', trigger: 'change' }],
+ dateCategory: [{ required: true, message: '璇烽�夋嫨鏃ユ湡鍒嗙被', trigger: 'change' }],
+ coefficient: [{ required: true, message: '璇疯緭鍏ュ杩愮郴鏁�', trigger: 'change' }]
+ }
+ }
+ },
+ mounted() {
+ this.loadOptions()
+ this.loadHolidays()
+ this.loadCoefficients()
+ },
+ methods: {
+ dayIndexLabel(value) {
+ if (Number(value) === 0) return '鍓嶄竴鏃�'
+ return value === null || value === undefined || value === '' ? '鈥�' : '绗�' + value + '鏃�'
+ },
+ loadOptions() {
+ api.get('/holiday/config/options').then(res => {
+ const data = res.data || {}
+ this.holidayTypeOptions = this.mapOptions(data.holidayTypes)
+ this.dateCategoryOptions = this.mapOptions(data.dateCategories)
+ })
+ },
+ mapOptions(map) {
+ if (!map) return []
+ return Object.keys(map).map(key => ({ value: key, label: map[key] }))
+ },
+ labelOf(options, value) {
+ const item = (options || []).find(x => x.value === value)
+ return item ? item.label : value
+ },
+ loadActiveTab() {
+ if (this.activeTab === 'holidays') this.loadHolidays()
+ else this.loadCoefficients()
+ },
+ loadHolidays() {
+ this.loadingHolidays = true
+ api.get('/holiday/config/holidays', { params: this.holidayQuery })
+ .then(res => { this.holidays = res.data || [] })
+ .catch(e => { if (!e || !e._toast) this.$message.error('鑺傚亣鏃ュ姞杞藉け璐�') })
+ .finally(() => { this.loadingHolidays = false })
+ },
+ loadCoefficients() {
+ this.loadingCoefficients = true
+ api.get('/holiday/config/coefficients', { params: this.coefficientQuery })
+ .then(res => { this.coefficients = res.data || [] })
+ .catch(e => { if (!e || !e._toast) this.$message.error('瀹㈣繍绯绘暟鍔犺浇澶辫触') })
+ .finally(() => { this.loadingCoefficients = false })
+ },
+ openHolidayDialog(row) {
+ this.holidayDialog = true
+ this.$nextTick(() => {
+ this.holidayForm = row ? Object.assign({}, row) : { id: null, year: 2026, holidayType: 'NATIONAL_DAY', holidayDate: '', dayIndex: 1 }
+ if (this.$refs.holidayForm) this.$refs.holidayForm.clearValidate()
+ })
+ },
+ onHolidayYearChange(year) {
+ if (this.holidayForm.holidayDate && Number(this.holidayForm.holidayDate.slice(0, 4)) !== year) {
+ this.holidayForm.holidayDate = ''
+ }
+ },
+ saveHoliday() {
+ this.$refs.holidayForm.validate(valid => {
+ if (!valid) return
+ this.saving = true
+ api.post('/holiday/config/holidays/save', this.holidayForm)
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '淇濆瓨澶辫触')
+ this.$message.success('淇濆瓨鎴愬姛')
+ this.holidayDialog = false
+ this.loadHolidays()
+ })
+ .catch(err => this.showError(err, '淇濆瓨澶辫触'))
+ .finally(() => { this.saving = false })
+ })
+ },
+ removeHoliday(row) {
+ this.$confirm('纭畾鍒犻櫎璇ヨ妭鍋囨棩鏃ユ湡鍚楋紵', '鍒犻櫎纭', { type: 'warning' })
+ .then(() => api.post('/holiday/config/holidays/delete', null, { params: { id: row.id } }))
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+ this.$message.success('宸插垹闄�')
+ this.loadHolidays()
+ })
+ .catch(err => { if (err !== 'cancel') this.showError(err, '鍒犻櫎澶辫触') })
+ },
+ openCoefficientDialog(row) {
+ this.coefficientDialog = true
+ this.$nextTick(() => {
+ this.coefficientForm = row ? Object.assign({}, row) : { id: null, year: 2026, dateCategory: 'HOLIDAY', coefficient: 2.4 }
+ if (this.$refs.coefficientForm) this.$refs.coefficientForm.clearValidate()
+ })
+ },
+ saveCoefficient() {
+ this.$refs.coefficientForm.validate(valid => {
+ if (!valid) return
+ this.saving = true
+ api.post('/holiday/config/coefficients/save', this.coefficientForm)
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '淇濆瓨澶辫触')
+ this.$message.success('淇濆瓨鎴愬姛')
+ this.coefficientDialog = false
+ this.loadCoefficients()
+ })
+ .catch(err => this.showError(err, '淇濆瓨澶辫触'))
+ .finally(() => { this.saving = false })
+ })
+ },
+ removeCoefficient(row) {
+ this.$confirm('纭畾鍒犻櫎璇ュ杩愮郴鏁板悧锛�', '鍒犻櫎纭', { type: 'warning' })
+ .then(() => api.post('/holiday/config/coefficients/delete', null, { params: { id: row.id } }))
+ .then(res => {
+ if (res.code !== 200) throw new Error(res.message || '鍒犻櫎澶辫触')
+ this.$message.success('宸插垹闄�')
+ this.loadCoefficients()
+ })
+ .catch(err => { if (err !== 'cancel') this.showError(err, '鍒犻櫎澶辫触') })
+ },
+ formatCoefficient(value) {
+ if (value === null || value === undefined || value === '') return '鈥�'
+ const number = Number(value)
+ if (!Number.isFinite(number)) return value
+ return number.toFixed(15).replace(/0+$/, '').replace(/\.$/, '')
+ },
+ showError(err, fallback) {
+ if (err && err._toast) return
+ const data = err && err.response && err.response.data
+ this.$message.error((data && data.message) || (err && err.message) || fallback)
+ }
+ }
+}
+</script>
+
+<style scoped>
+.toolbar { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
+.danger-text { color: #f56c6c; }
+</style>
diff --git a/traffic-audit-web/src/views/UserManage.vue b/traffic-audit-web/src/views/UserManage.vue
index 4c2651a..b9b6cdc 100644
--- a/traffic-audit-web/src/views/UserManage.vue
+++ b/traffic-audit-web/src/views/UserManage.vue
@@ -10,8 +10,13 @@
<el-menu-item index="/explain">浼佷笟瑙i噴</el-menu-item>
<el-menu-item index="/audit">瀹℃牳缁撴灉</el-menu-item>
<el-menu-item index="/report">鎶ヨ〃鐢熸垚</el-menu-item>
+ <el-submenu index="/special-stats">
+ <template slot="title">涓撻」缁熻</template>
+ <el-menu-item index="/holiday-stat">鑺傚亣鏃�</el-menu-item>
+ </el-submenu>
<el-menu-item index="/rules">瑙勫垯绠$悊</el-menu-item>
<el-menu-item index="/users">鐢ㄦ埛绠$悊</el-menu-item>
+ <el-menu-item v-if="operatorIsAdmin" index="/system-config">绯荤粺閰嶇疆</el-menu-item>
</el-menu>
</el-aside>
<el-main>
--
Gitblit v1.9.1