diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b2754ae --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Core asset database +DB_HOST= +DB_PORT=3306 +DB_USER= +DB_PASSWORD= +DB_NAME= + +# Hydrogen ledger database +HYDROGEN_DB_HOST= +HYDROGEN_DB_PORT=3306 +HYDROGEN_DB_USER= +HYDROGEN_DB_PASSWORD= +HYDROGEN_DB_NAME= + +# Mileage reference database +MILEAGE_DB_HOST= +MILEAGE_DB_PORT=3306 +MILEAGE_DB_USER= +MILEAGE_DB_PASSWORD= +MILEAGE_DB_NAME= + +# OneOS mileage API +ONEOS_MILEAGE_API_BASE_URL=http://172.17.111.55:20310 +ONEOS_MILEAGE_API_KEY= +ONEOS_MILEAGE_API_TIMEOUT_MS=20000 + +# Authentication and external API +EXTERNAL_API_BASE=https://lnh2e.com +JWT_SECRET= +DEV_BYPASS_AUTH=0 + +# Vehicle heatmap database and map SDK +HEATMAP_DB_HOST= +HEATMAP_DB_PORT=36453 +HEATMAP_DB_USER= +HEATMAP_DB_PASSWORD= +HEATMAP_DB_NAME=lingniu_vehicle_data +HEATMAP_DB_SSL=false +AMAP_WEB_KEY= +AMAP_SECURITY_JS_CODE= + +SERVER_PORT=3001 diff --git a/docker-compose.yml b/docker-compose.yml index 6f235dc..df0e150 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,35 +5,33 @@ services: image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 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" - HYDROGEN_DB_HOST: "47.99.185.173" - HYDROGEN_DB_PORT: "3306" - HYDROGEN_DB_USER: "root" - HYDROGEN_DB_PASSWORD: "lnMysql." - HYDROGEN_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" - # ECS production accesses OneOS over the private network. Configure the key - # as a Portainer stack environment variable; never commit it to this file. + DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}" + DB_PORT: "${DB_PORT:-3306}" + DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}" + DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}" + DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}" + HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}" + HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}" + HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}" + HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}" + HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}" + MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}" + MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}" + MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}" + MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}" + MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}" ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" - ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}" + ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY:?Set ONEOS_MILEAGE_API_KEY in the deployment environment}" 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" - AMAP_WEB_KEY: "${AMAP_WEB_KEY}" - AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" - HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" + SERVER_PORT: "${SERVER_PORT:-8111}" + EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}" + JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}" + AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}" + AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}" + HEATMAP_DB_HOST: "${HEATMAP_DB_HOST:?Set HEATMAP_DB_HOST in the deployment environment}" HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}" - HEATMAP_DB_USER: "${HEATMAP_DB_USER}" - HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" + HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}" + HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD:?Set HEATMAP_DB_PASSWORD in the deployment environment}" HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" deploy: diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 19e8079..c6db161 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -167,9 +167,9 @@ SUM(fee) / NULLIF(SUM(kwh), 0) ### 阶段 D:部署与安全 -- 将数据库地址、账号和密码从受版本控制文件迁移到部署密钥。 +- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。 - 轮换已经进入版本历史的氢能数据库凭据。 -- 为开发、测试和生产分别提供无敏感值的配置模板。 +- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。 - 增加服务健康检查、数据源状态和错误率监控。 ## 9. 每批验收门槛 @@ -188,4 +188,4 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 氢能数据库连接尚未恢复,真实数据对账未完成。 - ETC 供应商未配置、未执行首次同步,记录与账单下钻无数据可验收。 -- 氢能连接信息仍存在于版本控制文件,必须迁移并轮换;文档不记录任何连接值。 +- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。 diff --git a/src/server/auth/login.ts b/src/server/auth/login.ts index cfa608a..0cf1039 100644 --- a/src/server/auth/login.ts +++ b/src/server/auth/login.ts @@ -1,13 +1,14 @@ import { Hono } from 'hono'; import jwt from 'jsonwebtoken'; import pool from '../db.js'; +import { requireEnv } from '../config.js'; import type { AuthUser, JwtPayload, PermissionLevel } from './types.js'; import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js'; const app = new Hono(); const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com'; -const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; +const JWT_SECRET = requireEnv('JWT_SECRET'); /** GET /api/auth/exchange?jumpToken=xxx — 一步完成:换取用户信息 + 签发 JWT */ app.get('/exchange', async (c) => { diff --git a/src/server/auth/middleware.ts b/src/server/auth/middleware.ts index 898fad9..d8687b5 100644 --- a/src/server/auth/middleware.ts +++ b/src/server/auth/middleware.ts @@ -1,8 +1,9 @@ import type { Context, Next } from 'hono'; import jwt from 'jsonwebtoken'; +import { requireEnv } from '../config.js'; import type { JwtPayload, AuthUser } from './types.js'; -const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; +const JWT_SECRET = requireEnv('JWT_SECRET'); // 临时:跳过所有认证(保留完整逻辑便于快速恢复) const BYPASS_AUTH = false; diff --git a/src/server/config.test.ts b/src/server/config.test.ts new file mode 100644 index 0000000..49f9700 --- /dev/null +++ b/src/server/config.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { envPort, MissingEnvironmentError, requireEnv } from './config.js'; + +test('requireEnv returns a trimmed value', () => { + assert.equal(requireEnv('TOKEN', { TOKEN: ' configured ' }), 'configured'); +}); + +test('requireEnv identifies the missing variable without exposing values', () => { + assert.throws( + () => requireEnv('TOKEN', {}), + (error: unknown) => { + assert.ok(error instanceof MissingEnvironmentError); + assert.equal(error.code, 'MISSING_ENVIRONMENT_VARIABLE'); + assert.equal(error.variableName, 'TOKEN'); + return true; + }, + ); +}); + +test('envPort validates configured ports', () => { + assert.equal(envPort('PORT', 3306, {}), 3306); + assert.equal(envPort('PORT', 3306, { PORT: '8111' }), 8111); + assert.throws(() => envPort('PORT', 3306, { PORT: '70000' })); + assert.throws(() => envPort('PORT', 3306, { PORT: 'invalid' })); +}); diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..1654404 --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,33 @@ +import 'dotenv/config'; + +export class MissingEnvironmentError extends Error { + readonly code = 'MISSING_ENVIRONMENT_VARIABLE'; + + constructor(readonly variableName: string) { + super(`Missing required environment variable: ${variableName}`); + this.name = 'MissingEnvironmentError'; + } +} + +export function requireEnv( + name: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const value = env[name]?.trim(); + if (!value) throw new MissingEnvironmentError(name); + return value; +} + +export function envPort( + name: string, + fallback: number, + env: NodeJS.ProcessEnv = process.env, +): number { + const raw = env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > 65535) { + throw new Error(`Invalid port in environment variable: ${name}`); + } + return value; +} diff --git a/src/server/hydrogen-db.ts b/src/server/hydrogen-db.ts index ff300c0..a243e24 100644 --- a/src/server/hydrogen-db.ts +++ b/src/server/hydrogen-db.ts @@ -1,17 +1,20 @@ -import mysql from 'mysql2/promise'; -import dotenv from 'dotenv'; +import mysql, { type Pool } from 'mysql2/promise'; +import { envPort, requireEnv } from './config.js'; -dotenv.config(); +let hydrogenPool: Pool | null = null; -const hydrogenPool = mysql.createPool({ - host: process.env.HYDROGEN_DB_HOST || '47.99.185.173', - port: Number(process.env.HYDROGEN_DB_PORT) || 3306, - user: process.env.HYDROGEN_DB_USER || 'root', - password: process.env.HYDROGEN_DB_PASSWORD || 'lnMysql.', - database: process.env.HYDROGEN_DB_NAME || 'ln_asset_management', - waitForConnections: true, - connectionLimit: 5, - queueLimit: 0, -}); +export function getHydrogenPool(): Pool { + if (hydrogenPool) return hydrogenPool; -export default hydrogenPool; + hydrogenPool = mysql.createPool({ + host: requireEnv('HYDROGEN_DB_HOST'), + port: envPort('HYDROGEN_DB_PORT', 3306), + user: requireEnv('HYDROGEN_DB_USER'), + password: requireEnv('HYDROGEN_DB_PASSWORD'), + database: requireEnv('HYDROGEN_DB_NAME'), + waitForConnections: true, + connectionLimit: 5, + queueLimit: 0, + }); + return hydrogenPool; +} diff --git a/src/server/mileage-db.ts b/src/server/mileage-db.ts index a6f68e0..2db3252 100644 --- a/src/server/mileage-db.ts +++ b/src/server/mileage-db.ts @@ -1,17 +1,20 @@ -import mysql from 'mysql2/promise'; -import dotenv from 'dotenv'; +import mysql, { type Pool } from 'mysql2/promise'; +import { envPort, requireEnv } from './config.js'; -dotenv.config(); +let mileagePool: Pool | null = null; -const mileagePool = mysql.createPool({ - host: process.env.MILEAGE_DB_HOST || '101.133.130.65', - port: Number(process.env.MILEAGE_DB_PORT) || 3306, - user: process.env.MILEAGE_DB_USER || 'bi_reader_02', - password: process.env.MILEAGE_DB_PASSWORD || 'bi_reader_02_Pass', - database: process.env.MILEAGE_DB_NAME || 'hydrogen_energy', - waitForConnections: true, - connectionLimit: 5, - queueLimit: 0, -}); +export function getMileagePool(): Pool { + if (mileagePool) return mileagePool; -export default mileagePool; + mileagePool = mysql.createPool({ + host: requireEnv('MILEAGE_DB_HOST'), + port: envPort('MILEAGE_DB_PORT', 3306), + user: requireEnv('MILEAGE_DB_USER'), + password: requireEnv('MILEAGE_DB_PASSWORD'), + database: requireEnv('MILEAGE_DB_NAME'), + waitForConnections: true, + connectionLimit: 5, + queueLimit: 0, + }); + return mileagePool; +} diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index fbf3c15..82471fb 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; import pool from '../../db.js'; -import hydrogenPool from '../../hydrogen-db.js'; +import { getHydrogenPool } from '../../hydrogen-db.js'; import { cached } from './cache.js'; import { parseHydrogenCustomerKind, @@ -147,6 +147,7 @@ app.get('/hydrogen/overview', async (c) => { const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear; const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => { + const hydrogenPool = getHydrogenPool(); // 可选年份(数据自 HYDROGEN_MIN_DATE 起) const [yearListRows] = await hydrogenPool.query( `SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y @@ -412,6 +413,7 @@ app.get('/hydrogen/daily', async (c) => { const force = c.req.query('force') === '1'; const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}`, async () => { + const hydrogenPool = getHydrogenPool(); const whereParts = [ HYDROGEN_BASE_WHERE_B,