feat(M01-A): 建立后端API基础骨架
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
NODE_ENV=development
|
||||
QIPAI_API_HOST=0.0.0.0
|
||||
QIPAI_API_PORT=3001
|
||||
QIPAI_API_VERSION=0.1.0
|
||||
QIPAI_API_CORS_ORIGINS=https://api.txyundm.cn,http://localhost:5173
|
||||
QIPAI_MYSQL_HOST=127.0.0.1
|
||||
QIPAI_MYSQL_PORT=3306
|
||||
QIPAI_MYSQL_DATABASE=qipai
|
||||
QIPAI_MYSQL_USER=qipai_app
|
||||
QIPAI_MYSQL_PASSWORD=
|
||||
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
||||
QIPAI_MQTT_USERNAME=
|
||||
QIPAI_MQTT_PASSWORD=
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@qipai/backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"test": "node tests/backend-contract.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"kysely": "^0.27.4",
|
||||
"mysql2": "^3.11.3",
|
||||
"pino": "^9.4.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.6",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
const packageJson = JSON.parse(read('package.json'));
|
||||
assert.equal(packageJson.dependencies.fastify.startsWith('^4.'), true);
|
||||
assert.equal(packageJson.dependencies.zod.startsWith('^3.'), true);
|
||||
assert.equal(packageJson.dependencies.mysql2.startsWith('^3.'), true);
|
||||
assert.equal(packageJson.dependencies.kysely.startsWith('^0.'), true);
|
||||
|
||||
const tsconfig = JSON.parse(read('tsconfig.json'));
|
||||
assert.equal(tsconfig.compilerOptions.strict, true);
|
||||
assert.equal(tsconfig.compilerOptions.moduleResolution, 'NodeNext');
|
||||
|
||||
const appSource = read('src/app.ts');
|
||||
assert.match(appSource, /genReqId/);
|
||||
assert.match(appSource, /x-trace-id/);
|
||||
assert.match(appSource, /setErrorHandler/);
|
||||
assert.match(appSource, /rateLimit/);
|
||||
assert.match(appSource, /cors/);
|
||||
|
||||
const configSource = read('src/config.ts');
|
||||
assert.match(configSource, /z\.object/);
|
||||
assert.match(configSource, /QIPAI_MQTT_URL/);
|
||||
assert.match(configSource, /QIPAI_MYSQL_PASSWORD/);
|
||||
|
||||
const healthSource = read('src/routes/health.ts');
|
||||
for (const route of [
|
||||
'/app-api/health',
|
||||
'/admin-api/health',
|
||||
'/app-api/ready',
|
||||
'/admin-api/ready',
|
||||
'/app-api/version',
|
||||
'/admin-api/version'
|
||||
]) {
|
||||
assert.match(healthSource, new RegExp(route));
|
||||
}
|
||||
|
||||
console.log('PASS: backend M01-A contract is present.');
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user