From 02d714175f053bd68e4585ab1d277f14e745d827 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 16:00:04 +0800 Subject: [PATCH] feat: expose BI configuration readiness --- .env.example | 9 +++++ docker-compose.yml | 7 ++++ docs/bi-refactor-roadmap.md | 3 +- src/server/auth/middleware.ts | 2 +- src/server/index.ts | 4 +- src/server/readiness.test.ts | 50 +++++++++++++++++++++++++ src/server/readiness.ts | 64 ++++++++++++++++++++++++++++++++ src/server/routes/health.test.ts | 15 ++++++++ src/server/routes/health.ts | 17 +++++++++ 9 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/server/readiness.test.ts create mode 100644 src/server/readiness.ts create mode 100644 src/server/routes/health.test.ts create mode 100644 src/server/routes/health.ts diff --git a/.env.example b/.env.example index b2754ae..b42cc3f 100644 --- a/.env.example +++ b/.env.example @@ -39,4 +39,13 @@ HEATMAP_DB_SSL=false AMAP_WEB_KEY= AMAP_SECURITY_JS_CODE= +# Feedback attachment object storage +OSS_ENDPOINT= +OSS_REGION=oss-cn-shanghai +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_BUCKET= +OSS_BASE_DIR=/dos +OSS_HOST= + SERVER_PORT=3001 diff --git a/docker-compose.yml b/docker-compose.yml index df0e150..e144ea5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,13 @@ services: 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}" + OSS_ENDPOINT: "${OSS_ENDPOINT:?Set OSS_ENDPOINT in the deployment environment}" + OSS_REGION: "${OSS_REGION:-oss-cn-shanghai}" + OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID:?Set OSS_ACCESS_KEY_ID in the deployment environment}" + OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET:?Set OSS_ACCESS_KEY_SECRET in the deployment environment}" + OSS_BUCKET: "${OSS_BUCKET:?Set OSS_BUCKET in the deployment environment}" + OSS_BASE_DIR: "${OSS_BASE_DIR:-/dos}" + OSS_HOST: "${OSS_HOST:-}" 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:?Set HEATMAP_DB_USER in the deployment environment}" diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 2fdad00..a0e060d 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -171,7 +171,8 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。 - 轮换已经进入版本历史的氢能数据库凭据。 - 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。 -- 增加服务健康检查、数据源状态和错误率监控。 +- 已拆分进程存活检查与无敏感值的配置就绪检查。 +- 增加数据源连通性、最后成功时间和错误率监控。 ## 9. 每批验收门槛 diff --git a/src/server/auth/middleware.ts b/src/server/auth/middleware.ts index d8687b5..081334a 100644 --- a/src/server/auth/middleware.ts +++ b/src/server/auth/middleware.ts @@ -31,7 +31,7 @@ export async function authMiddleware(c: Context, next: Next) { } // 跳过不需要认证的路径 - if (path === '/api/health' || path.startsWith('/api/auth/')) { + if (path.startsWith('/api/health') || path.startsWith('/api/auth/')) { return next(); } diff --git a/src/server/index.ts b/src/server/index.ts index 21d05ff..746cbc2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -12,6 +12,7 @@ import feedbackRouter from './routes/feedback/index.js'; import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js'; import analyticsRouter from './routes/analytics/index.js'; +import healthRouter from './routes/health.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import authRouter from './auth/login.js'; import { authMiddleware } from './auth/middleware.js'; @@ -24,6 +25,7 @@ app.use('/api/*', cors()); // Auth 路由(不需要中间件) app.route('/api/auth', authRouter); +app.route('/api/health', healthRouter); // Auth 中间件(保护后续所有 /api/* 路由) app.use('/api/*', authMiddleware); @@ -38,8 +40,6 @@ app.route('/api/vehicle-heatmap', vehicleHeatmapRouter); app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter); app.route('/api/analytics', analyticsRouter); -app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); - // Serve static files in production app.use('/*', serveStatic({ root: './dist' })); app.use('/*', serveStatic({ root: './dist', path: 'index.html' })); diff --git a/src/server/readiness.test.ts b/src/server/readiness.test.ts new file mode 100644 index 0000000..bd17975 --- /dev/null +++ b/src/server/readiness.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildReadinessReport } from './readiness.js'; + +const completeEnv: NodeJS.ProcessEnv = { + DB_HOST: 'configured', + DB_USER: 'configured', + DB_PASSWORD: 'do-not-expose-secret', + DB_NAME: 'configured', + JWT_SECRET: 'configured', + ONEOS_MILEAGE_API_BASE_URL: 'configured', + ONEOS_MILEAGE_API_KEY: 'configured', + HYDROGEN_DB_HOST: 'configured', + HYDROGEN_DB_USER: 'configured', + HYDROGEN_DB_PASSWORD: 'configured', + HYDROGEN_DB_NAME: 'configured', + HEATMAP_DB_HOST: 'configured', + HEATMAP_DB_USER: 'configured', + HEATMAP_DB_PASSWORD: 'configured', + HEATMAP_DB_NAME: 'configured', + AMAP_WEB_KEY: 'configured', + AMAP_SECURITY_JS_CODE: 'configured', + OSS_ENDPOINT: 'configured', + OSS_ACCESS_KEY_ID: 'configured', + OSS_ACCESS_KEY_SECRET: 'configured', + OSS_BUCKET: 'configured', +}; + +test('reports ready only when core and integration configuration is complete', () => { + const report = buildReadinessReport(completeEnv, new Date('2026-08-07T08:00:00Z')); + assert.equal(report.status, 'ready'); + assert.equal(report.semantics, 'configuration-only'); + assert.equal(report.checkedAt, '2026-08-07T08:00:00.000Z'); +}); + +test('distinguishes optional integration gaps from core readiness failures', () => { + const degraded = buildReadinessReport({ ...completeEnv, HYDROGEN_DB_PASSWORD: '' }); + assert.equal(degraded.status, 'degraded'); + assert.equal(degraded.integrations.hydrogenDatabase, 'incomplete'); + + const notReady = buildReadinessReport({ ...completeEnv, DB_PASSWORD: '' }); + assert.equal(notReady.status, 'not-ready'); + assert.equal(notReady.core.assetDatabase, 'incomplete'); +}); + +test('does not expose configuration values or variable names', () => { + const serialized = JSON.stringify(buildReadinessReport(completeEnv)); + assert.equal(serialized.includes('do-not-expose-secret'), false); + assert.equal(serialized.includes('DB_PASSWORD'), false); +}); diff --git a/src/server/readiness.ts b/src/server/readiness.ts new file mode 100644 index 0000000..1050b94 --- /dev/null +++ b/src/server/readiness.ts @@ -0,0 +1,64 @@ +export type ConfigurationState = 'configured' | 'incomplete'; + +export interface ReadinessReport { + status: 'ready' | 'degraded' | 'not-ready'; + semantics: 'configuration-only'; + checkedAt: string; + core: { + assetDatabase: ConfigurationState; + authentication: ConfigurationState; + }; + integrations: { + oneOsMileage: ConfigurationState; + hydrogenDatabase: ConfigurationState; + heatmapDatabase: ConfigurationState; + mapSdk: ConfigurationState; + objectStorage: ConfigurationState; + }; +} + +function hasAll(env: NodeJS.ProcessEnv, names: readonly string[]): ConfigurationState { + return names.every(name => Boolean(env[name]?.trim())) ? 'configured' : 'incomplete'; +} + +export function buildReadinessReport( + env: NodeJS.ProcessEnv = process.env, + now = new Date(), +): ReadinessReport { + const core = { + assetDatabase: hasAll(env, ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']), + authentication: hasAll(env, ['JWT_SECRET']), + } as const; + const integrations = { + oneOsMileage: hasAll(env, ['ONEOS_MILEAGE_API_BASE_URL', 'ONEOS_MILEAGE_API_KEY']), + hydrogenDatabase: hasAll(env, [ + 'HYDROGEN_DB_HOST', + 'HYDROGEN_DB_USER', + 'HYDROGEN_DB_PASSWORD', + 'HYDROGEN_DB_NAME', + ]), + heatmapDatabase: hasAll(env, [ + 'HEATMAP_DB_HOST', + 'HEATMAP_DB_USER', + 'HEATMAP_DB_PASSWORD', + 'HEATMAP_DB_NAME', + ]), + mapSdk: hasAll(env, ['AMAP_WEB_KEY', 'AMAP_SECURITY_JS_CODE']), + objectStorage: hasAll(env, [ + 'OSS_ENDPOINT', + 'OSS_ACCESS_KEY_ID', + 'OSS_ACCESS_KEY_SECRET', + 'OSS_BUCKET', + ]), + } as const; + + const coreReady = Object.values(core).every(value => value === 'configured'); + const integrationsReady = Object.values(integrations).every(value => value === 'configured'); + return { + status: !coreReady ? 'not-ready' : integrationsReady ? 'ready' : 'degraded', + semantics: 'configuration-only', + checkedAt: now.toISOString(), + core, + integrations, + }; +} diff --git a/src/server/routes/health.test.ts b/src/server/routes/health.test.ts new file mode 100644 index 0000000..8064600 --- /dev/null +++ b/src/server/routes/health.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import app from './health.js'; + +test('separates liveness from configuration readiness', async () => { + const liveness = await app.request('/'); + const readiness = await app.request('/readiness'); + const liveBody = await liveness.json(); + const readyBody = await readiness.json(); + + assert.equal(liveness.status, 200); + assert.equal(liveBody.semantics, 'liveness'); + assert.equal(readyBody.semantics, 'configuration-only'); + assert.ok([200, 503].includes(readiness.status)); +}); diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts new file mode 100644 index 0000000..1e30aad --- /dev/null +++ b/src/server/routes/health.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono'; +import { buildReadinessReport } from '../readiness.js'; + +const app = new Hono(); + +app.get('/', (c) => c.json({ + status: 'ok', + semantics: 'liveness', + time: new Date().toISOString(), +})); + +app.get('/readiness', (c) => { + const report = buildReadinessReport(); + return c.json(report, report.status === 'not-ready' ? 503 : 200); +}); + +export default app;