feat: expose BI configuration readiness

This commit is contained in:
kkfluous
2026-08-07 16:00:04 +08:00
parent 686fc4e2e2
commit 02d714175f
9 changed files with 167 additions and 4 deletions
+9
View File
@@ -39,4 +39,13 @@ HEATMAP_DB_SSL=false
AMAP_WEB_KEY= AMAP_WEB_KEY=
AMAP_SECURITY_JS_CODE= 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 SERVER_PORT=3001
+7
View File
@@ -28,6 +28,13 @@ services:
JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}" 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_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}" 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_HOST: "${HEATMAP_DB_HOST:?Set HEATMAP_DB_HOST in the deployment environment}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}" HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}" HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
+2 -1
View File
@@ -171,7 +171,8 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。 - 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。
- 轮换已经进入版本历史的氢能数据库凭据。 - 轮换已经进入版本历史的氢能数据库凭据。
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。 - 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
- 增加服务健康检查、数据源状态和错误率监控 - 已拆分进程存活检查与无敏感值的配置就绪检查
- 增加数据源连通性、最后成功时间和错误率监控。
## 9. 每批验收门槛 ## 9. 每批验收门槛
+1 -1
View File
@@ -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(); return next();
} }
+2 -2
View File
@@ -12,6 +12,7 @@ import feedbackRouter from './routes/feedback/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js'; import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
import analyticsRouter from './routes/analytics/index.js'; import analyticsRouter from './routes/analytics/index.js';
import healthRouter from './routes/health.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js'; import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js'; import { authMiddleware } from './auth/middleware.js';
@@ -24,6 +25,7 @@ app.use('/api/*', cors());
// Auth 路由(不需要中间件) // Auth 路由(不需要中间件)
app.route('/api/auth', authRouter); app.route('/api/auth', authRouter);
app.route('/api/health', healthRouter);
// Auth 中间件(保护后续所有 /api/* 路由) // Auth 中间件(保护后续所有 /api/* 路由)
app.use('/api/*', authMiddleware); app.use('/api/*', authMiddleware);
@@ -38,8 +40,6 @@ app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter); app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
app.route('/api/analytics', analyticsRouter); app.route('/api/analytics', analyticsRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
// Serve static files in production // Serve static files in production
app.use('/*', serveStatic({ root: './dist' })); app.use('/*', serveStatic({ root: './dist' }));
app.use('/*', serveStatic({ root: './dist', path: 'index.html' })); app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
+50
View File
@@ -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);
});
+64
View File
@@ -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,
};
}
+15
View File
@@ -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));
});
+17
View File
@@ -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;