feat: expose BI configuration readiness
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -171,7 +171,8 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。
|
||||
- 轮换已经进入版本历史的氢能数据库凭据。
|
||||
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
|
||||
- 增加服务健康检查、数据源状态和错误率监控。
|
||||
- 已拆分进程存活检查与无敏感值的配置就绪检查。
|
||||
- 增加数据源连通性、最后成功时间和错误率监控。
|
||||
|
||||
## 9. 每批验收门槛
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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' }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user