feat(M01-A): 建立后端API基础骨架

This commit is contained in:
Codex
2026-06-16 20:37:55 +08:00
parent eb259ce2a4
commit 98b66d22f2
22 changed files with 437 additions and 19 deletions
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import Fastify, { type FastifyInstance } from 'fastify';
import { loadConfig, type AppConfig } from './config.js';
import { registerHealthRoutes } from './routes/health.js';
export interface BuildAppOptions {
config?: AppConfig;
}
declare module 'fastify' {
interface FastifyRequest {
traceId: string;
}
}
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
const app = Fastify({
logger: {
level: config.nodeEnv === 'test' ? 'silent' : 'info'
},
genReqId: () => randomUUID()
});
app.decorateRequest('traceId', '');
app.addHook('onRequest', async (request, reply) => {
const traceHeader = request.headers['x-trace-id'];
request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.id;
reply.header('x-trace-id', request.traceId);
});
await app.register(cors, {
origin: config.corsOrigins
});
await app.register(rateLimit, {
max: 120,
timeWindow: '1 minute'
});
app.setErrorHandler((error, request, reply) => {
request.log.error({ err: error, traceId: request.traceId }, 'request failed');
reply.status(500).send({
code: 'INTERNAL_ERROR',
message: 'Internal server error',
traceId: request.traceId
});
});
await registerHealthRoutes(app, config);
return app;
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
QIPAI_API_HOST: z.string().min(1).default('0.0.0.0'),
QIPAI_API_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
QIPAI_API_VERSION: z.string().min(1).default('0.1.0'),
QIPAI_API_CORS_ORIGINS: z.string().default('https://api.txyundm.cn'),
QIPAI_MYSQL_HOST: z.string().min(1).default('127.0.0.1'),
QIPAI_MYSQL_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
QIPAI_MYSQL_DATABASE: z.string().min(1).default('qipai'),
QIPAI_MYSQL_USER: z.string().min(1).default('qipai_app'),
QIPAI_MYSQL_PASSWORD: z.string().default(''),
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_USERNAME: z.string().default(''),
QIPAI_MQTT_PASSWORD: z.string().default('')
});
export type AppConfig = ReturnType<typeof loadConfig>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
const parsed = configSchema.parse(env);
return {
nodeEnv: parsed.NODE_ENV,
host: parsed.QIPAI_API_HOST,
port: parsed.QIPAI_API_PORT,
version: parsed.QIPAI_API_VERSION,
corsOrigins: parsed.QIPAI_API_CORS_ORIGINS.split(',').map((item) => item.trim()).filter(Boolean),
mysql: {
host: parsed.QIPAI_MYSQL_HOST,
port: parsed.QIPAI_MYSQL_PORT,
database: parsed.QIPAI_MYSQL_DATABASE,
user: parsed.QIPAI_MYSQL_USER,
passwordConfigured: parsed.QIPAI_MYSQL_PASSWORD.length > 0
},
mqtt: {
url: parsed.QIPAI_MQTT_URL,
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
passwordConfigured: parsed.QIPAI_MQTT_PASSWORD.length > 0
}
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyInstance } from 'fastify';
import type { AppConfig } from '../config.js';
interface HealthPayload {
ok: true;
service: 'qipai-api';
version: string;
traceId: string;
}
interface ReadyPayload extends HealthPayload {
checks: {
mysqlConfigured: boolean;
mqttConfigured: boolean;
};
}
export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig): Promise<void> {
const health = async (request: { traceId: string }): Promise<HealthPayload> => ({
ok: true,
service: 'qipai-api',
version: config.version,
traceId: request.traceId
});
const ready = async (request: { traceId: string }): Promise<ReadyPayload> => ({
...(await health(request)),
checks: {
mysqlConfigured: config.mysql.passwordConfigured,
mqttConfigured: config.mqtt.usernameConfigured && config.mqtt.passwordConfigured
}
});
app.get('/app-api/health', health);
app.get('/admin-api/health', health);
app.get('/app-api/ready', ready);
app.get('/admin-api/ready', ready);
app.get('/app-api/version', health);
app.get('/admin-api/version', health);
}
+12
View File
@@ -0,0 +1,12 @@
import { buildApp } from './app.js';
import { loadConfig } from './config.js';
const config = loadConfig();
const app = await buildApp({ config });
try {
await app.listen({ host: config.host, port: config.port });
} catch (error) {
app.log.error(error);
process.exit(1);
}