From 9a9c9d08e1fb33819b938c31a36d4d69abe92923 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Fri, 11 Sep 2026 10:19:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(stage4):=20=E9=9B=86=E4=B8=AD=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E5=90=AF=E5=8A=A8=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=AE=88=E5=8D=AB=20fail-closed=EF=BC=8C?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E7=A7=BB=E5=87=BA=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置分层 - 新增 src/server/config.ts:环境变量集中读取 + 启动期校验。 删除公开兜底密钥 ln-bi-default-secret:SSO 模式下 JWT_SECRET 缺失或 短于 32 字符时 assertRuntimeConfig() 直接拒绝启动(已实测)。 - index.ts 不再自己读端口/环境;auth/config.ts 与 auth/login.ts 改为引用集中配置。 权限守卫 - energy / scheduling / hydrogen-heatmap 的守卫由 “user 存在才校验” 改为 “无角色即拒绝”:user 缺失时按无权限处理,不再静默放行(fail-closed)。 - /api/ele/* 此前完全无鉴权,任何已登录用户都能写入电费表;现按能源域 (BI-LEADER-ENERGY) 守卫。 接口契约 - 未匹配的 /api/* 由 200 text/html(SPA) 改为 404 application/json; 未认证时仍是 401,避免向未授权调用方暴露路由是否存在。 - 新增全局 onError 返回 JSON 500。 凭据治理(此前均为 git 跟踪文件中的明文) - Dockerfile 删除烧进镜像的 JWT_SECRET,改为必须运行时注入。 - docker-compose.yml 删除生产库口令/JWT 密钥/失效的 MILEAGE_DB_*, 改为强制注入写法;补齐 OSS_* 与 NODE_ENV/DEV_BYPASS_AUTH/BI_AUTH_*。 - woodpecker.yml 删除 Harbor base64 凭据改用 secret,pull_request 不再推镜像。 - 删除 scripts-tmp/(含生产库 root 口令)与已跟踪的 .DS_Store;.gitignore 补全。 - 文档中残留的里程库口令改为占位符。 lint / test(128) / build 全绿。 --- .DS_Store | Bin 8196 -> 0 bytes .gitignore | 2 + Dockerfile | 4 +- docker-compose.yml | 42 +++-- docs/.DS_Store | Bin 8196 -> 0 bytes docs/superpowers/.DS_Store | Bin 10244 -> 0 bytes .../plans/2026-04-01-mileage-module.md | 2 +- .../specs/2026-04-01-mileage-module-design.md | 2 +- scripts-tmp/excel_plates.txt | 178 ------------------ scripts-tmp/find_extra.ts | 60 ------ src/server/app.ts | 11 ++ src/server/auth/config.ts | 28 ++- src/server/auth/login.ts | 8 +- src/server/config.ts | 65 +++++++ src/server/index.ts | 12 +- src/server/routes/ele/index.ts | 12 ++ src/server/routes/energy/index.ts | 6 +- src/server/routes/hydrogen-heatmap.ts | 2 +- src/server/routes/scheduling/index.ts | 8 +- woodpecker.yml | 31 +-- 20 files changed, 176 insertions(+), 297 deletions(-) delete mode 100644 .DS_Store delete mode 100644 docs/.DS_Store delete mode 100644 docs/superpowers/.DS_Store delete mode 100644 scripts-tmp/excel_plates.txt delete mode 100644 scripts-tmp/find_extra.ts create mode 100644 src/server/config.ts diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index eec867796ed82c702bb07018cb7c042e6172c0bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMTWl0n7(U;$&>1?=0a{tG1DkG%fGw03Xt^cZn{q2fwx!#0S$20uIxw9nJG0wj zscB4n0nzxR@fLaV7I{!n;)^DVqK}Fu7~=y*qrPZ@FDeiIGiMfhfy4)66z3#!{`23? zng5^fKeK0+F^0ChwwAF7V@#yWqe?k-w`iW;yJj>YkdzVy`LoQ3Gn_H)4DlCcScifz z17QZj41^g7GZ1Fr-;e=%XM0V&&3m6~!#>PFn1TOg2E6?tN|#6D0iEE~-#V!Cj{roy zBY@v%tat;W(SXJSI>9MvsDZl@<*o>h7~t+Cj|O(}fKG7A-5G+z2ZEImoKWDePV+~7 zbA~vlVIO87%)rbHc;!>dG-k6bb0(kPGsC9gxD5?oLMSPnHM>kK6U)Vei9vfL;ilcZ z)0WQd_qZP0HZ#R>Iu{E3brjD6ImVJtcfM6S7bEk!LdU8Cb zQ)uVkBT5p*c)lveBh4w7=c;|l{(=2+N?A~#8}A-=4cl_|={bjJN~v%t!0lPv+LO~g zeP7l#^Fx-CQs!sVW;So8UA@&b6UIY2$ts*|&Nj_GwqtnH^e0_?)b(^TRLKFy&bjUW zWJ!bO=T=JI(f8^!sZr27Xlkc&T5yog&0Dmrc13+-^Okt$_O1smS19urER?I{0m`K1 z8V?$Ju0QQ$bxSj>BfTSr<_`8149CcrdXJSJ(xv&RlP*_87B9KGT2;BhO^2dsGM_Py z=MDFS5)(y#ji_29AJFKYx@^r`MH7|tG!L!5T&+>}dz$u~PQ4a5tW@undmIX0RMOmV zwcH^093u!wio+#M@_Mz`OlNdcQZ&9!i@ZtgEAaeF%1$oaDp#q=tUWYDAvzM5wW{sv zA#d4TX~*pz)-CUrE^*y`q#JMs(>c9+gdiB)(GHUOkCfz|Q>SLQwy&gY?55qxPGAft@hDE=G|u30JcZ}+0$#*fyoxvQCeGt+yoXEp1fSw_T*bHe4&UQv z{Ek0xU6>^-5+cH4p+=|^>V?%pv(O@J5;hCFg>E4!q=ln`EsXL2gkq^3{p?@bXl?NZWbah%w8%MeN^;nRw)atW3?g`i!v)dNnOJZ}-n>PkLR3lvc4?eg<}=mR9i24IB}M$ZP=1A7Wk0ZA z*mXkrJS;>6_n?8W-G*)0?V~$^UK~OShmnDXAy}|+oN#^;<9HYoc!W^?7~%X0Jc(!U zES@8*zl4|Z3SPtOg!FfC0q^1ie29zqIKceZg!rFuZ5oQZiumOezfMMRA(698+d4wB zBI>7_-%(r%k0#=s|2uF0{r`@NISeMuK$wB489-@AqN9yux7+i)vv!oO!*qGgvzy@5 wccE^|hXCSl{9#DrD4BXb@qkWnN)l@S_=f=h2l-$4sU5!m!}q_B&^xpH6HA9^ZvX%Q diff --git a/.gitignore b/.gitignore index fdacec8..fd06fec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist .env .env.local .worktrees +.DS_Store +scripts-tmp diff --git a/Dockerfile b/Dockerfile index fd95d26..7f87a9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,9 @@ COPY src/shared ./src/shared COPY tsconfig.json ./ EXPOSE 3001 +ENV NODE_ENV=production ENV SERVER_PORT=3001 ENV EXTERNAL_API_BASE=https://lnh2e.com -ENV JWT_SECRET=ln-bi-jwt-prod-secret +# JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。 +# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。 CMD ["npm", "run", "start"] diff --git a/docker-compose.yml b/docker-compose.yml index b080be3..fe8fdba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,29 @@ version: '3.8' +# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。 +# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。 +# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。 + services: ln-bi: - image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 + image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.1.15 network_mode: host environment: - DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" - DB_PORT: "3306" - DB_USER: "oneos_db_prod" - DB_PASSWORD: "adASHJcviqwjkbn23ngt1" - DB_NAME: "ln_asset_management" - MILEAGE_DB_HOST: "101.133.130.65" - MILEAGE_DB_PORT: "3306" - MILEAGE_DB_USER: "bi_reader_02" - MILEAGE_DB_PASSWORD: "bi_reader_02_Pass" - MILEAGE_DB_NAME: "hydrogen_energy" + NODE_ENV: "production" + # 生产必须保持为 0:该开关会绕过全部认证。 + DEV_BYPASS_AUTH: "0" + DB_HOST: "${DB_HOST:?请注入主业务库地址}" + DB_PORT: "${DB_PORT:-3306}" + DB_USER: "${DB_USER:?请注入主业务库只读账号}" + DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}" + DB_NAME: "${DB_NAME:-ln_asset_management}" + # 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。 + HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}" + HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}" + HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}" + HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}" + HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}" + HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}" # ECS production accesses OneOS over the private network. Configure the key # as a Portainer stack environment variable; never commit it to this file. ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" @@ -22,7 +31,9 @@ services: ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}" SERVER_PORT: "8111" EXTERNAL_API_BASE: "https://lnh2e.com" - JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" + BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}" + BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}" + JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}" AMAP_WEB_KEY: "${AMAP_WEB_KEY}" AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" @@ -31,6 +42,13 @@ services: HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" + # 用户反馈截图上传所需;此前缺失会导致反馈上传必然失败。 + OSS_REGION: "${OSS_REGION}" + OSS_ENDPOINT: "${OSS_ENDPOINT}" + OSS_BUCKET: "${OSS_BUCKET}" + OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID}" + OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET}" + OSS_BASE_DIR: "${OSS_BASE_DIR}" deploy: replicas: 1 restart_policy: diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 894578ac335e82154852446654c6d960af3d5d38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMU2GIp6u#fI&>1?=0a{tG1DkG%fGw03X!%RFe+m>RvMt?~pJjJvqyy8LvNO9| zEH#aZFZ^hH()bs7@-OnBqQnj7ELY1YcAhJTrF|q|i5FAkIzhJ@?#u z&b{}XIp4iKTgDhV^4e;~B8)MSsz;SdYHm}!d9U75gkYqcC`g~N92;deD>9vWVF^7D zdLZ;b=z-7!p$G0p573+K6>*w(pU;ME=z-7!|4R>e_d}GbN8ZZEE@kLRtBYnH6G% zSSjvL4BN$on|AX~M>@CHr+f1QS=-EySWZfrn@yY9yqR|OcGFB4kLfg4FQ&y zH+Rcaa>%iBZl}Lk(xCOZl#+M!-8wC56!b1y+NqvmDx|Y>7A&b>*4W&-Dc-$x$0L`j zl)3Zf%Qf;4`PFibM-4qUn0B(dr5V(ss8OlMA~RtprC zs}IV34jC^hX)d@@Zj$?sP(VnE!$(@=wQ9ea&giD3XndbGd4oDo;Qp7C9zM8Pu2GX& zdt`)6bR<63u6C*iyluCq9k+K>x4a`=kSz&gs2H3drC@+i2APq%`g+b!&!e z`;+92opq`@=q=Z-X}%4MA`7e3gxW7EUy3P3F|2|?AfV66u^`4upC%Sx!Mm}Kbl&kD z9g8<{PNGLoWzDRMZD$7weBYDo&M3=5uwWe)z`}L6I zFqF+T_M5y1m!qDb$+7VPw6J%N54Kne$ufrb%SuyK^4{x~M^I41D~ z#r!FX`ls;>p2rJ#ks|+9yoT5DCf=gBe;?=Z0Y1hjxPZ?B1bj#F|0}LeiRm4sXyz1u zOdr#QM9wm8>kvtl5OK5eUCEX4Q$@V|zkB-M|L@APhldJ15PIOwcmU;HiLMS>Yr4D3 z%eA9a4^s7tCpXEduR@KVjuZ9MaiZ7%VMzTbiF!KmfKGBsBUJwL9|G?DAKu~nAHM(h H-249z(Q=1o diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store deleted file mode 100644 index 5115dd2fcf9c16235253b2aa2a53c261b73cc794..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeHMU2GIp6u#fIr89J(1GG}G1Dh^Hz!pjiwEQL8Kjpv3wsc#5mff9^4oqjt&Td;Q zHI0ccAR3=E{zabri#(_(@kJ9w(MLrSjPU`ZQC~E{7nKLk+&f!ni-{(Z0GUbdJ@?#u z&b{}X`OciZcNt@7%^Ry3i!jDyDn8Xzs&4Z5<9?a;NI{Q32-0WFOAmX4Y1>UtlMOpU z4}=~FJrH^z^g!r=+rtBtv;9Xd6Ev(t4}=~FJuu+`|N9W3;?r0_#|7n|4yyPOfM_vw z^M~4W4&XYGfW`tkE+|)}ImPJ#fhvL{2823|M|^Rjv4D;XD%2T-IzzBBf)fh(>ZBLp z;tVlC!#eap=z-}T@WZBr4Kkahm^118em*^DTVAfd{%c4@#U(RJIYS*~CiCW-9tu^hu4ZgumH;c?x#7?2fN zNorN2qmApDY9oznR*%(2M%Opg*GASft{WRu`|{IO|6i9XNG z=GypV35(_zQd-_K_n0)PHDtEa)Lz9D^PrraJ%90%Wpxcrn_?YXcRqBfT$?j@o?5B) zkuRN`^{{1TdsAMcG+7L{ebMntbs_ZzgQovz`pV#wM#nuk_js@LfI{E@b-NwpCuEZ6T>yF4;p z)>1-nrCP6c9VHMbnkRZ1)wO!Jol2Xwq8VbHW_5$!GbH@4Xxl~SX0=jJWZZ!PGSO2+ zUyI(RAM}^qk@9k#gQnwe>7p2WfX4c~{#4fN93~J3JK9FQ{FPF_uhd~!IhPNTH+I$N zdaplSi(zmZ7FI1N*W-G(tbHLTHO+E{ECN2gR)K#xT6i_l*b32%erSpYcXTvXFF1vg zo6Z_oJKMnyu`C;9r`faY410%NV4twB*mvw_c9s1BUEfWE~!&WNGa)vHXKx;C)emmjx^4TN6Kt1f^YE&uyk~x7>55frnm4yhz);|}0M#1< zfTMm=tZ>wGR7^qaB_4-nG`lHW%n{j-TCFsYjxLcYT(pvaUACOq?b-~% zR@beRDKs^Svubriqe20w6$NZv6R{ACx^a_2p{NxH?4lU4j5F2M?Hx4DMNR&{aDJIx zVL!3o*fqlWY|KLy?n6DHyA@loi{m?vZX84shmeMW0XT4RjF5f;V|Wzfc#LrV1R?z? zJdNk@JYFEQzk*ls8s5O0g!T7u9`EBLe2feDG(i2gg!x}^bqbDm77#2bexHovp?KD@ zUFR_M`p7R~9;HZ=W$vN`Ibl(lAj1S1CdgZxAa4w|FhSBU#4thL`,库名 `hydrogen_energy` - `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source) ## 架构 diff --git a/scripts-tmp/excel_plates.txt b/scripts-tmp/excel_plates.txt deleted file mode 100644 index f518b34..0000000 --- a/scripts-tmp/excel_plates.txt +++ /dev/null @@ -1,178 +0,0 @@ -沪A00113F -沪A00220F -沪A00333F -沪A00607F -沪A01056F -沪A01311F -沪A01775F -沪A01813F -沪A01855F -沪A02303F -沪A02311F -沪A02326F -沪A02361F -沪A02720F -沪A03086F -沪A03397F -沪A03565F -沪A03620F -沪A03659F -沪A03801F -沪A03870F -沪A05035F -沪A05113F -沪A05223F -沪A05501F -沪A05675F -沪A05697F -沪A05830F -沪A06335F -沪A06599F -沪A06695F -沪A07006F -沪A07153F -沪A07806F -沪A08037F -沪A08150F -沪A08315F -沪A08598F -沪A08786F -沪A09100F -沪A09251F -沪A09276F -沪A09303F -沪A09313F -沪A09322F -沪A09689F -沪A30010F -沪A30399F -沪A31031F -沪A31211F -沪A31281F -沪A31308F -沪A31381F -沪A31613F -沪A32269F -沪A33216F -沪A35236F -沪A35798F -沪A35879F -沪A35898F -沪A36133F -沪A36169F -沪A36569F -沪A36980F -沪A37785F -沪A38795F -沪A39287F -沪A39289F -沪A39585F -沪A39608F -沪A39626F -沪A39815F -沪A39835F -沪A39912F -沪A50026F -沪A50069F -沪A50309F -沪A51580F -沪A51612F -沪A51677F -沪A51893F -沪A52331F -沪A52511F -沪A53309F -沪A53322F -沪A53506F -沪A53960F -沪A55179F -沪A55297F -沪A55339F -沪A55666F -沪A55695F -沪A56122F -沪A56701F -沪A56959F -沪A56988F -沪A57139F -沪A57167F -沪A57198F -沪A57838F -沪A57850F -沪A57895F -沪A58087F -沪A58159F -沪A58185F -沪A58307F -沪A58533F -沪A58538F -沪A58593F -沪A58922F -沪A59095F -沪A59510F -沪A59613F -沪A59682F -沪A59799F -沪A59932F -沪A60339F -沪A60691F -沪A60820F -沪A61187F -沪A61193F -沪A61312F -沪A61559F -沪A61600F -沪A61711F -沪A61738F -沪A62322F -沪A62772F -沪A62928F -沪A63013F -沪A63305F -沪A63522F -沪A63660F -沪A63697F -沪A65036F -沪A65181F -沪A65522F -沪A65995F -沪A66216F -沪A66256F -沪A66329F -沪A66593F -沪A66710F -沪A66921F -沪A67018F -沪A67033F -沪A67872F -沪A68115F -沪A68139F -沪A68332F -沪A68613F -沪A68658F -沪A68752F -沪A69311F -沪A69826F -沪A69997F -沪A85021F -沪A89315F -沪A89385F -沪A89662F -浙F00885F -浙F08889F -浙F09898F -粤A00255F -粤A02683F -粤A02956F -粤A03502F -粤A03532F -粤A03569F -粤A05106F -粤A05391F -粤A05428F -粤A05839F -粤A05985F -粤A05995F -粤A06569F -粤A06931F -粤A06932F diff --git a/scripts-tmp/find_extra.ts b/scripts-tmp/find_extra.ts deleted file mode 100644 index d145050..0000000 --- a/scripts-tmp/find_extra.ts +++ /dev/null @@ -1,60 +0,0 @@ -import mysql from 'mysql2/promise'; -import fs from 'node:fs'; - -const pool = mysql.createPool({ - host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com', - port: 3306, - user: 'root', - password: 'LN#Passw0rd@2026', - database: 'lingniu_prod', - connectTimeout: 15000, ssl: { rejectUnauthorized: false }, -}); - -async function main() { - const excelPlates = new Set( - fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim()) - ); - console.log('excel plates:', excelPlates.size); - - // 按 dept-stats 逻辑查金可鹏 18T Operating - const [rows] = await pool.query(` - SELECT truck.plate_number AS plate, - dic_type.dic_name AS type_label, - dic_status.dic_name AS status_label, - cus.customer_name AS customer, - org_truck.org_name AS subject_org - FROM tab_truck truck - LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.is_deleted=0 - LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0 - LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0 - LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0 - LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0 - LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0 - LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0 - WHERE truck.is_deleted=0 AND truck.is_operation=1 - AND u.user_name='金可鹏' - AND dic_type.dic_name LIKE '%18吨%' - AND dic_status.dic_name IN ('租赁','自营','挂靠') - ORDER BY truck.plate_number - `); - - console.log('DB 金可鹏 18T operating:', rows.length); - const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim())); - - const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort(); - const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort(); - - console.log('\n=== DB 有但 Excel 没有(多出来的) ==='); - console.log('数量:', extra.length); - for (const p of extra) { - const r = (rows as any[]).find((x) => x.plate === p); - console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org); - } - - console.log('\n=== Excel 有但 DB 没有 ==='); - console.log('数量:', missing.length); - for (const p of missing) console.log(' ', p); - - await pool.end(); -} -main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/server/app.ts b/src/server/app.ts index bfb4fb6..ba4b554 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -41,6 +41,17 @@ export function createApp(): Hono { time: new Date().toISOString(), })); + // 未匹配的 /api/* 必须返回 JSON 404。若落到下面的静态回退,会返回 + // 200 + index.html,让前端 res.ok 为真、再在 res.json() 处抛解析错误, + // 把"路由不存在"伪装成数据问题。 + app.all('/api/*', (context) => context.json({ error: 'Not Found' }, 404)); + + // 兜底错误处理:统一返回 JSON,避免把栈信息或 HTML 暴露给调用方。 + app.onError((error, context) => { + console.error(`[server] unhandled error on ${context.req.method} ${context.req.path}:`, error); + return context.json({ error: 'Internal Server Error' }, 500); + }); + // 生产环境由同一进程托管 Vite 构建产物,并回退到单页应用入口。 app.use('/*', serveStatic({ root: './dist' })); app.use('/*', serveStatic({ root: './dist', path: 'index.html' })); diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts index 91b9966..03b3384 100644 --- a/src/server/auth/config.ts +++ b/src/server/auth/config.ts @@ -1,24 +1,32 @@ import { createHmac } from 'node:crypto'; import jwt from 'jsonwebtoken'; import type { JwtPayload } from './types.js'; +import { authMode, jwtSecret } from '../config.js'; -export function authMode() { - const mode = process.env.BI_AUTH_MODE || 'sso'; - if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE'); - return mode; -} +// 认证方式统一由 server/config.ts 读取与校验,这里只做转发, +// 让既有的 `from './config.js'` 引用保持有效。 +export { authMode }; export function passwordConfig() { const password = process.env.BI_AUTH_PASSWORD || ''; - const secret = process.env.JWT_SECRET || ''; - if (!password.trim() || secret.length < 32) throw new Error('Password authentication is not configured securely'); - return { password, key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex') }; + const secret = jwtSecret(); + if (!password.trim() || secret.length < 32) { + throw new Error('Password authentication is not configured securely'); + } + return { + password, + key: createHmac('sha256', secret).update(`bi-password:${password}`).digest('hex'), + }; } export function verifyAuthToken(token: string): JwtPayload { const passwordMode = authMode() === 'password'; - const key = passwordMode ? passwordConfig().key : process.env.JWT_SECRET || 'ln-bi-default-secret'; + // 不再有公开兜底密钥:SSO 模式下 JWT_SECRET 缺失会在启动期被 assertRuntimeConfig 拦下。 + const key = passwordMode ? passwordConfig().key : jwtSecret(); + if (!key) throw new Error('JWT_SECRET is not configured'); const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload; - if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') throw new Error('Authentication mode changed'); + if (passwordMode ? payload.authMethod !== 'password' : payload.authMethod === 'password') { + throw new Error('Authentication mode changed'); + } return payload; } diff --git a/src/server/auth/login.ts b/src/server/auth/login.ts index 376f7eb..cba7bce 100644 --- a/src/server/auth/login.ts +++ b/src/server/auth/login.ts @@ -5,13 +5,11 @@ import type { AuthUser, JwtPayload, PermissionLevel } from './types.js'; import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js'; import { authMode, verifyAuthToken } from './config.js'; import { passwordRouter } from './password.js'; +import { jwtSecret, serverConfig } from '../config.js'; const app = new Hono(); app.route('/', passwordRouter()); -const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com'; -const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; - /** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */ app.get('/exchange', async (c) => { if (authMode() !== 'sso') return c.json({ message: '当前使用固定密码登录' }, 403); @@ -20,7 +18,7 @@ app.get('/exchange', async (c) => { try { const res = await fetch( - `${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}` + `${serverConfig.externalApiBase}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}` ); const data = await res.json() as { code: number; @@ -77,7 +75,7 @@ app.get('/exchange', async (c) => { roles: roleNames, }; - const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' }); + const token = jwt.sign(payload, jwtSecret(), { expiresIn: '8h' }); const authUser: AuthUser = { ...payload }; return c.json({ token, user: authUser }); diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..54fe18f --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,65 @@ +import dotenv from 'dotenv'; + +// ESM 的 import 会被提升到调用方语句之前执行,所以这里显式加载一次环境文件, +// 保证本模块下面的常量真的读到了 .env。dotenv 是幂等的(不覆盖已有变量), +// 各数据库模块仍保留自己的 dotenv 调用以规避模块初始化顺序问题。 +dotenv.config(); + +function readNumber(value: string | undefined, fallback: number): number { + if (value === undefined || value.trim() === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +/** 认证方式:`sso` 走业务系统跳转换票,`password` 只用固定密码。 */ +export type AuthMode = 'sso' | 'password'; + +export function authMode(): AuthMode { + const mode = process.env.BI_AUTH_MODE || 'sso'; + if (mode !== 'sso' && mode !== 'password') throw new Error('Invalid BI_AUTH_MODE'); + return mode; +} + +export const serverConfig = { + port: readNumber(process.env.SERVER_PORT, 3001), + externalApiBase: process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com', +} as const; + +/** + * JWT 签名密钥。 + * 刻意不提供兜底值:历史上 `'ln-bi-default-secret'` 这类公开常量会让任何人 + * 离线伪造全权限令牌。缺失或过短一律由 {@link assertRuntimeConfig} 拒绝启动。 + */ +export function jwtSecret(): string { + return process.env.JWT_SECRET || ''; +} + +/** 生产只读预览:DB_READ_ONLY=1 时禁止业务写入且不启动后台任务。 */ +export const isDbReadOnly = () => process.env.DB_READ_ONLY === '1'; +/** 氢能查询只读:对氢能连接池启用 SELECT/SHOW/WITH/EXPLAIN 白名单。 */ +export const isHydrogenReadOnly = () => process.env.HYDROGEN_DB_READ_ONLY === '1'; + +const MIN_SECRET_LENGTH = 32; + +/** + * 启动期配置校验。在监听端口之前调用,让配置错误在启动时暴露而不是在第一次请求时。 + * 只检查"缺失会导致安全问题"的项,不检查业务数据库等可选能力。 + */ +export function assertRuntimeConfig(): void { + if (authMode() === 'sso') { + const secret = jwtSecret(); + if (secret.length < MIN_SECRET_LENGTH) { + throw new Error( + `JWT_SECRET must be set to a random value of at least ${MIN_SECRET_LENGTH} characters ` + + '(SSO 模式下缺失会让任何人都能伪造令牌)', + ); + } + } + if (authMode() === 'password') { + const password = (process.env.BI_AUTH_PASSWORD || '').trim(); + if (!password) throw new Error('BI_AUTH_PASSWORD must be set when BI_AUTH_MODE=password'); + if (jwtSecret().length < MIN_SECRET_LENGTH) { + throw new Error(`JWT_SECRET must be at least ${MIN_SECRET_LENGTH} characters`); + } + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 43e47a3..d87a0c1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,15 +1,15 @@ import { serve } from '@hono/node-server'; -import dotenv from 'dotenv'; import { createApp } from './app.js'; import { startBackgroundServices } from './bootstrap.js'; +import { assertRuntimeConfig, serverConfig } from './config.js'; -dotenv.config(); +// 配置错误在启动期暴露,而不是等到第一次请求才 500。 +assertRuntimeConfig(); const app = createApp(); -const port = Number(process.env.SERVER_PORT) || 3001; -console.log(`Server starting on port ${port}...`); +console.log(`Server starting on port ${serverConfig.port}...`); startBackgroundServices(); -serve({ fetch: app.fetch, port }, () => { - console.log(`Server running at http://localhost:${port}`); +serve({ fetch: app.fetch, port: serverConfig.port }, () => { + console.log(`Server running at http://localhost:${serverConfig.port}`); }); diff --git a/src/server/routes/ele/index.ts b/src/server/routes/ele/index.ts index 7801e61..dd8bbbb 100644 --- a/src/server/routes/ele/index.ts +++ b/src/server/routes/ele/index.ts @@ -2,10 +2,22 @@ import { Hono } from 'hono'; import type { RowDataPacket, ResultSetHeader } from 'mysql2'; import * as XLSX from 'xlsx'; import pool from '../../db.js'; +import type { AuthUser } from '../../auth/types.js'; +import { canAccessEnergy } from '../../auth/types.js'; import { ensureChargeRecordTable } from './migration.js'; const app = new Hono(); +// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端 +// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。 +app.use('*', async (c, next) => { + const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; + if (!canAccessEnergy(user?.roles)) { + return c.json({ error: 'Forbidden: 电能导入需要 BI-LEADER-ENERGY 角色' }, 403); + } + return next(); +}); + // 与 xlsx 列名对齐 const COL = { orderNo: '订单编号', diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 5098cd0..eabe40a 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -14,11 +14,11 @@ import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js'; const app = new Hono(); -// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行; -// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色 +// 模块级访问守卫(fail-closed):必须持有 BI-LEADER-ENERGY 或「所有权限」。 +// 认证中间件挂在本路由之前,因此 user 缺失本身就是异常,按无权限处理。 app.use('*', async (c, next) => { const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined; - if (user && !canAccessEnergy(user.roles)) { + if (!canAccessEnergy(user?.roles)) { return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403); } return next(); diff --git a/src/server/routes/hydrogen-heatmap.ts b/src/server/routes/hydrogen-heatmap.ts index f9bc7e9..8b3d751 100644 --- a/src/server/routes/hydrogen-heatmap.ts +++ b/src/server/routes/hydrogen-heatmap.ts @@ -82,7 +82,7 @@ const router = new Hono(); router.use('*', async (c, next) => { const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined; - if (user && !canAccessEnergy(user.roles)) { + if (!canAccessEnergy(user?.roles)) { return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403); } return next(); diff --git a/src/server/routes/scheduling/index.ts b/src/server/routes/scheduling/index.ts index 44c0880..299c56e 100644 --- a/src/server/routes/scheduling/index.ts +++ b/src/server/routes/scheduling/index.ts @@ -6,12 +6,12 @@ import { canAccessScheduling } from '../../auth/types.js'; const app = new Hono(); -// Module-level access guard. When auth middleware is active, `user` is set and -// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role). -// When auth is bypassed (dev), `user` is undefined and requests pass through. +// Module-level access guard (fail-closed): requires BI-SCHEDULE-OPT. +// Auth middleware runs before this router, so a missing `user` is an anomaly and +// must be treated as "no permission" rather than silently allowed. app.use('*', async (c, next) => { const user = (c as any).get('user') as AuthUser | undefined; - if (user && !canAccessScheduling(user.roles)) { + if (!canAccessScheduling(user?.roles)) { return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403); } return next(); diff --git a/woodpecker.yml b/woodpecker.yml index e8f7a7e..86e849b 100644 --- a/woodpecker.yml +++ b/woodpecker.yml @@ -1,3 +1,7 @@ +# 凭据通过 Woodpecker Secret 注入(harbor_user / harbor_password), +# 不再把 Harbor 账号密码 base64 后写进流水线脚本。 +# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。 + steps: - name: npm-build image: node:22-alpine @@ -18,7 +22,7 @@ steps: npm run build # 获取分支名 - BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -) + BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-manual} | tr / -) echo "Branch name: $BRANCH_NAME" # 版本号: 分支名-package.json版本 @@ -29,10 +33,10 @@ steps: - name: docker-build image: docker:24.0.5-cli + depends_on: [npm-build] when: event: - push - - pull_request - manual branch: - master @@ -40,25 +44,22 @@ steps: - main volumes: - /var/run/docker.sock:/var/run/docker.sock + environment: + HARBOR_USER: + from_secret: harbor_user + HARBOR_PASSWORD: + from_secret: harbor_password commands: | PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt) MODULE_NAME=ln-bi + REGISTRY=harbor.lnh2e.com + PROJECT=lingniu-v1 echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION" cd $CI_WORKSPACE + docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION . - docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION . + echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USER" --password-stdin - mkdir -p /root/.docker - cat > /root/.docker/config.json <