feat(api): 搭建 API 与 Worker 骨架及契约基线

NestJS 11 + Fastify 统一错误体与 trace_id,/health 真实探活 DB/Redis,OpenAPI 3.1 契约含 20 个错误码;shared 包提供错误码、权限点、ULID、分页与通用 schema;worker 完成队列进程骨架。
This commit is contained in:
Stev_Wang committed 2026-11-02 17:20:46 +08:00
1 parent 09dbb27b66
commit 23067edb8f
70 files changed
+2445

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
{
"name": "@lingxi/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --coverage",
"test:int": "vitest run --config vitest.int.config.ts"
},
"dependencies": {
"@fastify/cors": "11.3.1",
"@fastify/helmet": "13.2.0",
"@fastify/static": "10.1.8",
"@lingxi/shared": "workspace:*",
"@nestjs/common": "11.2.8",
"@nestjs/core": "11.2.8",
"@nestjs/platform-fastify": "11.2.8",
"@nestjs/swagger": "11.4.7",
"@prisma/client": "6.19.3",
"dotenv": "18.0.9",
"fastify": "5.12.7",
"ioredis": "5.11.1",
"nestjs-pino": "5.3.1",
"pino": "10.4.0",
"pino-http": "11.0.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2",
"zod": "3.25.76"
},
"devDependencies": {
"@nestjs/testing": "11.2.8",
"@types/node": "24.19.2",
"@vitest/coverage-v8": "3.2.7",
"pino-pretty": "13.2.0",
"tsx": "4.23.15",
"typescript": "5.9.3",
"vitest": "3.2.7"
}
}
+51
View File
@@ -0,0 +1,51 @@
import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
import { APP_CONFIG, AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/env';
import { PrismaModule } from './infra/prisma/prisma.module';
import { RedisModule } from './infra/redis/redis.module';
import { PlatformModule } from './modules/platform/platform.module';
@Module({
imports: [
AppConfigModule,
LoggerModule.forRootAsync({
imports: [AppConfigModule],
inject: [APP_CONFIG],
useFactory: (config: AppConfig) => ({
pinoHttp: {
level: config.logLevel,
genReqId: (request, response) => {
const header = request.headers[REQUEST_ID_HEADER];
const existing = Array.isArray(header) ? header[0] : header;
const traceId = typeof existing === 'string' && existing.length > 0 ? existing : ulid();
response.setHeader(REQUEST_ID_HEADER, traceId);
return traceId;
},
customProps: (request) => ({ trace_id: request.id }),
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
censor: '[redacted]',
},
autoLogging: {
ignore: (request) => request.url === '/health',
},
...(config.nodeEnv === 'development' && process.env.VITEST === undefined
? {
transport: {
target: 'pino-pretty',
options: { singleLine: true, translateTime: 'SYS:HH:MM:ss.l' },
},
}
: {}),
},
}),
}),
PrismaModule,
RedisModule,
PlatformModule,
],
})
export class AppModule {}
+44
View File
@@ -0,0 +1,44 @@
import helmet from '@fastify/helmet';
import { API_PREFIX } from '@lingxi/shared';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { APP_CONFIG } from './config/config.module';
import type { AppConfig } from './config/env';
import { buildOpenApiDocument, setupOpenApi } from './openapi/document';
export interface CreatedApiApp {
app: NestFastifyApplication;
config: AppConfig;
}
/** 创建并配置 API 应用(测试与 OpenAPI 导出复用,不负责监听端口) */
export async function createApp(): Promise<CreatedApiApp> {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ trustProxy: true, bodyLimit: 1024 * 1024 }),
{ bufferLogs: true },
);
const config = app.get<AppConfig>(APP_CONFIG);
app.useLogger(app.get(Logger));
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
app.useGlobalFilters(new AllExceptionsFilter());
app.enableCors({
origin: config.webOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// 安全响应头;Swagger UI 需要内联脚本,CSP 在关闭文档页后再开启
await app.register(helmet, { contentSecurityPolicy: false });
app.enableShutdownHooks();
return { app, config };
}
export { buildOpenApiDocument, setupOpenApi };
@@ -0,0 +1,25 @@
import { ERROR_MESSAGES, type ErrorCode, httpStatusOf } from '@lingxi/shared';
export interface AppExceptionOptions {
code: ErrorCode;
message?: string;
details?: unknown;
}
/**
* 业务异常:由统一异常过滤器序列化为 { code, message, details, trace_id }。
* 业务代码禁止直接 throw new Error 表达可预期错误。
*/
export class AppException extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly details?: unknown;
constructor(options: AppExceptionOptions) {
super(options.message ?? ERROR_MESSAGES[options.code]);
this.name = 'AppException';
this.code = options.code;
this.status = httpStatusOf(options.code);
this.details = options.details;
}
}
@@ -0,0 +1,46 @@
import { ErrorCode } from '@lingxi/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, type TestApp } from '../../testing/test-app';
describe('统一异常过滤器(冒烟)', () => {
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp({ includeErrorRoutes: true });
});
afterAll(async () => {
await testApp.app.close();
});
it('AppException 输出业务错误码与 trace_id', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/app-error' });
expect(response.statusCode).toBe(409);
const body = response.json<{ code: number; message: string; trace_id: string }>();
expect(body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
expect(body.message).toBe('当前状态不允许该操作');
expect(body.trace_id.length).toBeGreaterThan(0);
});
it('HttpException 404 映射为 40401', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/http-404' });
expect(response.statusCode).toBe(404);
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
});
it('未知异常收敛为 50001,不泄露内部信息', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/boom' });
expect(response.statusCode).toBe(500);
const body = response.json<{ code: number; message: string; trace_id: string }>();
expect(body.code).toBe(ErrorCode.INTERNAL_ERROR);
expect(body.message).toBe('服务内部错误');
expect(body.trace_id.length).toBeGreaterThan(0);
});
it('未注册路由返回统一 404 错误体', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/not-exists' });
expect(response.statusCode).toBe(404);
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
});
});
@@ -0,0 +1,121 @@
import { type ApiErrorBody, ErrorCode, httpStatusOf, isErrorCode } from '@lingxi/shared';
import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException } from '@nestjs/common';
import { AppException } from '../errors/app-exception';
interface RequestWithTrace {
id?: string;
url?: string;
method?: string;
log?: {
error: (payload: unknown, message?: string) => void;
};
}
interface ReplyLike {
status: (code: number) => { send: (body: unknown) => void };
}
function codeFromHttpStatus(status: number): ErrorCode {
switch (status) {
case 400:
return ErrorCode.PARAM_INVALID;
case 401:
return ErrorCode.UNAUTHENTICATED;
case 403:
return ErrorCode.PERMISSION_DENIED;
case 404:
return ErrorCode.NOT_FOUND;
case 409:
return ErrorCode.STATE_NOT_ALLOWED;
case 422:
return ErrorCode.BUSINESS_RULE_VIOLATION;
case 429:
return ErrorCode.RATE_LIMITED;
case 502:
case 503:
return ErrorCode.DEPENDENCY_UNAVAILABLE;
case 504:
return ErrorCode.TIMEOUT;
default:
return status >= 400 && status < 500 ? ErrorCode.PARAM_INVALID : ErrorCode.INTERNAL_ERROR;
}
}
function messageFromHttpException(exception: HttpException): string {
const response = exception.getResponse();
if (typeof response === 'string') {
return response;
}
if ('message' in response) {
const message = response.message;
if (typeof message === 'string') {
return message;
}
if (Array.isArray(message) && message.length > 0 && typeof message[0] === 'string') {
return message.join('; ');
}
}
return exception.message;
}
/**
* 全局异常过滤器:所有错误统一输出 { code, message, details, trace_id }(工程规范 04)。
* trace_id 与 Pino 的 request id 同源,响应用户报障时可反查日志。
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const request = context.getRequest<RequestWithTrace>();
const reply = context.getResponse<ReplyLike>();
const traceId = request.id ?? '';
let status = 500;
let body: ApiErrorBody;
if (exception instanceof AppException) {
status = exception.status;
body = {
code: exception.code,
message: exception.message,
...(exception.details === undefined ? {} : { details: exception.details }),
trace_id: traceId,
};
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const code = codeFromHttpStatus(status);
body = {
code,
message: status >= 500 ? '服务内部错误' : messageFromHttpException(exception),
trace_id: traceId,
};
} else {
body = {
code: ErrorCode.INTERNAL_ERROR,
message: '服务内部错误',
trace_id: traceId,
};
}
if (status >= 500) {
request.log?.error(
{
err: exception,
trace_id: traceId,
method: request.method,
url: request.url,
code: body.code,
},
'request failed',
);
}
reply.status(status).send(body);
}
}
/** 供测试或未来的 OpenAPI 生成复用:确认错误码与 HTTP 状态一致 */
export function httpStatusForCode(code: number): number {
return isErrorCode(code) ? httpStatusOf(code) : 500;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
/** OpenAPI 错误响应模型(工程规范 04 统一错误体) */
export class ErrorResponseDto {
@ApiProperty({ type: Number, description: '错误码(见 ErrorCode 常量)', example: 42202 })
code!: number;
@ApiProperty({ type: String, description: '可直接展示给用户的提示', example: '内容触发合规拦截' })
message!: string;
@ApiProperty({
type: Object,
description: '补充信息(字段错误、命中规则等),结构由错误码决定',
required: false,
example: { issues: [{ path: 'name', message: 'Required' }] },
})
details?: unknown;
@ApiProperty({
type: String,
description: '请求追踪 ID,与日志同源',
example: '01JABCDEF0123456789ABCDEFG',
})
trace_id!: string;
}
@@ -0,0 +1,39 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { AppException } from '../errors/app-exception';
import { ZodValidationPipe } from './zod-validation.pipe';
const schema = z.object({
name: z.string().min(2),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
describe('ZodValidationPipe', () => {
it('通过校验并返回转换后的值', () => {
const pipe = new ZodValidationPipe(schema);
expect(pipe.transform({ name: '素材', limit: '30' })).toEqual({ name: '素材', limit: 30 });
});
it('缺省值生效', () => {
const pipe = new ZodValidationPipe(schema);
expect(pipe.transform({ name: '素材' })).toEqual({ name: '素材', limit: 20 });
});
it('校验失败抛 40001,details 带字段错误', () => {
const pipe = new ZodValidationPipe(schema);
try {
pipe.transform({ name: 'x' });
throw new Error('应当抛错');
} catch (error) {
expect(error).toBeInstanceOf(AppException);
const appError = error instanceof AppException ? error : null;
expect(appError?.code).toBe(ErrorCode.PARAM_INVALID);
expect(appError?.status).toBe(400);
expect(appError?.details).toMatchObject({
issues: [{ path: 'name' }],
});
}
});
});
@@ -0,0 +1,30 @@
import { ErrorCode } from '@lingxi/shared';
import { type PipeTransform } from '@nestjs/common';
import type { ZodSchema } from 'zod';
import { AppException } from '../errors/app-exception';
/**
* Zod 校验管道(静态 DTO):body / query / params 共用。
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
*/
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '参数校验失败',
details: {
issues: result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
},
});
}
return result.data;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { type AppConfig, loadEnv } from './env';
export const APP_CONFIG = Symbol('APP_CONFIG');
@Global()
@Module({
providers: [
{
provide: APP_CONFIG,
useFactory: (): AppConfig => loadEnv(),
},
],
exports: [APP_CONFIG],
})
export class AppConfigModule {}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { loadEnv } from './env';
const validEnv: NodeJS.ProcessEnv = {
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
REDIS_URL: 'redis://127.0.0.1:16379',
WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173',
};
describe('loadEnv', () => {
it('解析必填项并给可选项默认值', () => {
const config = loadEnv(validEnv);
expect(config.nodeEnv).toBe('test');
expect(config.isProduction).toBe(false);
expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0');
expect(config.logLevel).toBe('info');
expect(config.maxUploadMb).toBe(500);
expect(config.featureVideo).toBe(false);
expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']);
});
it('字符串数字会被转成 number', () => {
const config = loadEnv({ ...validEnv, PORT: '8080', MAX_UPLOAD_MB: '200' });
expect(config.port).toBe(8080);
expect(config.maxUploadMb).toBe(200);
});
it('缺少 DATABASE_URL 时抛错并带字段名', () => {
expect(() => loadEnv({ ...validEnv, DATABASE_URL: undefined })).toThrow(/DATABASE_URL/);
});
it('拒绝非 postgres 连接串', () => {
expect(() => loadEnv({ ...validEnv, DATABASE_URL: 'mysql://localhost/db' })).toThrow(
/DATABASE_URL/,
);
});
it('拒绝非法 LOG_LEVEL', () => {
expect(() => loadEnv({ ...validEnv, LOG_LEVEL: 'verbose' })).toThrow(/LOG_LEVEL/);
});
it('FEATURE_VIDEO=true 转为布尔值', () => {
expect(loadEnv({ ...validEnv, FEATURE_VIDEO: 'true' }).featureVideo).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
HOST: z.string().min(1).default('0.0.0.0'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
DATABASE_URL: z
.string()
.min(1)
.refine(
(value) => value.startsWith('postgresql://') || value.startsWith('postgres://'),
'DATABASE_URL 必须是 postgresql:// 连接串',
),
REDIS_URL: z
.string()
.min(1)
.refine(
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
'REDIS_URL 必须是 redis:// 连接串',
),
WEB_ORIGIN: z.string().default('http://127.0.0.1:5173'),
MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10240).default(500),
FEATURE_VIDEO: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
});
export type RawEnv = z.infer<typeof envSchema>;
export interface AppConfig {
nodeEnv: RawEnv['NODE_ENV'];
isProduction: boolean;
port: number;
host: string;
logLevel: RawEnv['LOG_LEVEL'];
databaseUrl: string;
redisUrl: string;
webOrigins: string[];
maxUploadMb: number;
featureVideo: boolean;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
const result = envSchema.safeParse(source);
if (!result.success) {
const details = result.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ');
throw new Error(`环境变量校验失败:${details}`);
}
const env = result.data;
return {
nodeEnv: env.NODE_ENV,
isProduction: env.NODE_ENV === 'production',
port: env.PORT,
host: env.HOST,
logLevel: env.LOG_LEVEL,
databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
webOrigins: env.WEB_ORIGIN.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
maxUploadMb: env.MAX_UPLOAD_MB,
featureVideo: env.FEATURE_VIDEO,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { config as loadDotenvFile } from 'dotenv';
/**
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
* 只做加载,不做校验(校验见 config/env.ts)。
*/
export function loadDotenv(): void {
for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file);
if (existsSync(path)) {
loadDotenvFile({ path });
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from './bootstrap';
import { loadDotenv } from './config/load-dotenv';
/**
* 集成测试:需要 pnpm compose:up 后真实 PG / Redis。
* 单独运行:pnpm test:int
*/
describe('GET /health(集成)', () => {
let created: CreatedApiApp;
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
});
afterAll(async () => {
await created.app.close();
});
it('真实依赖健康时返回 200', async () => {
const response = await created.app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(200);
const body = response.json<{
status: string;
checks: { database: { status: string }; redis: { status: string } };
}>();
expect(body.status).toBe('ok');
expect(body.checks.database.status).toBe('up');
expect(body.checks.redis.status).toBe('up');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { ErrorCode } from '@lingxi/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, type TestApp } from './testing/test-app';
describe('GET /health(冒烟)', () => {
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp();
});
afterAll(async () => {
await testApp.app.close();
});
it('依赖健康时返回 200 与检查明细', async () => {
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(200);
const body = response.json<{
status: string;
checks: { database: { status: string }; redis: { status: string } };
}>();
expect(body.status).toBe('ok');
expect(body.checks.database.status).toBe('up');
expect(body.checks.redis.status).toBe('up');
});
});
describe('GET /health(依赖异常)', () => {
it('数据库不可用时返回 503 与统一错误体', async () => {
const testApp = await createTestApp({ databaseUp: false });
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(503);
const body = response.json<{ code: number; message: string; trace_id: string }>();
expect(body.code).toBe(ErrorCode.DEPENDENCY_UNAVAILABLE);
expect(body.message).toBe('依赖服务不可用');
expect(body.trace_id.length).toBeGreaterThan(0);
await testApp.app.close();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { testConfig } from '../testing/test-app';
import { PrismaService } from './prisma/prisma.service';
import { RedisService } from './redis/redis.service';
describe('PrismaService', () => {
it('关闭应用时断开 Prisma 连接', async () => {
const service = new PrismaService(testConfig);
const disconnect = vi.spyOn(service, '$disconnect').mockResolvedValue();
await service.onModuleDestroy();
expect(disconnect).toHaveBeenCalledTimes(1);
});
});
describe('RedisService', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('ping 返回 PONG', async () => {
const service = new RedisService(testConfig);
const client = service.getClient();
vi.spyOn(client, 'connect').mockResolvedValue(undefined);
vi.spyOn(client, 'ping').mockResolvedValue('PONG');
await expect(service.ping()).resolves.toBe('PONG');
});
it('关闭应用时断开 Redis 连接', () => {
const service = new RedisService(testConfig);
const client = service.getClient();
const disconnect = vi.spyOn(client, 'disconnect').mockReturnValue(undefined);
service.onModuleDestroy();
expect(disconnect).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import { PRISMA } from '../tokens';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [
{
provide: PRISMA,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): PrismaService => new PrismaService(config),
},
],
exports: [PRISMA],
})
export class PrismaModule {}
@@ -0,0 +1,24 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
/**
* Prisma 封装。
* 不主动 $connect:Prisma 首次查询时才建连接,这样 OpenAPI 导出、单元测试不需要真实数据库;
* /health 会执行 SELECT 1 做真实检查。
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
constructor(@Inject(APP_CONFIG) config: AppConfig) {
super({
datasources: { db: { url: config.databaseUrl } },
log: config.isProduction ? ['error'] : ['warn', 'error'],
});
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import { REDIS } from '../tokens';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [
{
provide: REDIS,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): RedisService => new RedisService(config),
},
],
exports: [REDIS],
})
export class RedisModule {}
+44
View File
@@ -0,0 +1,44 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
/**
* Redis 客户端封装。
* lazyConnect:不在应用启动时连接,避免 OpenAPI 导出 / 单测依赖真实 Redis;
* /health 调 ping 时才会真正连接。
*/
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
constructor(@Inject(APP_CONFIG) config: AppConfig) {
this.client = new Redis(config.redisUrl, {
lazyConnect: true,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
this.client.on('error', () => {
// 连接错误由健康检查与请求日志暴露,这里只避免 ioredis 抛未处理异常
});
}
getClient(): Redis {
return this.client;
}
async ping(): Promise<'PONG'> {
if (this.client.status === 'wait') {
await this.client.connect();
}
await this.client.ping();
return 'PONG';
}
onModuleDestroy(): void {
if (this.client.status !== 'end') {
this.client.disconnect();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
/** 依赖注入 token:避免跨模块直接依赖具体实现 */
export const PRISMA = Symbol('PRISMA');
export const REDIS = Symbol('REDIS');
+24
View File
@@ -0,0 +1,24 @@
import 'reflect-metadata';
import { Logger } from 'nestjs-pino';
import { createApp, setupOpenApi } from './bootstrap';
import { loadDotenv } from './config/load-dotenv';
async function bootstrap(): Promise<void> {
loadDotenv();
const { app, config } = await createApp();
await app.init();
setupOpenApi(app, config);
await app.listen(config.port, config.host);
const logger = app.get(Logger);
logger.log(`灵犀 API 已启动:http://${config.host}:${config.port}(文档 /api/docs)`);
}
void bootstrap().catch((error: unknown) => {
process.stderr.write(
`API 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
});
@@ -0,0 +1,25 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { HealthService } from './health.service';
import { type HealthReport, HealthResponseDto } from './health.types';
@ApiTags('platform')
@Controller('health')
export class HealthController {
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
@Get()
@ApiOperation({
summary: '健康检查',
description: '真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。',
})
@ApiOkResponse({ type: HealthResponseDto, description: '全部依赖健康' })
@ApiResponse({
status: 503,
description: '依赖不可用(错误码 50002),details 内附各依赖检查结果',
})
async check(): Promise<HealthReport> {
return this.healthService.check();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}
@@ -0,0 +1,94 @@
import { ErrorCode, nowIso } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../../common/errors/app-exception';
import { APP_CONFIG } from '../../../config/config.module';
import type { AppConfig } from '../../../config/env';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import type { RedisService } from '../../../infra/redis/redis.service';
import { PRISMA } from '../../../infra/tokens';
import { REDIS } from '../../../infra/tokens';
import type { ComponentHealth, HealthReport } from './health.types';
const API_VERSION = '0.1.0';
const CHECK_TIMEOUT_MS = 2000;
function withTimeout<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`检查超时(${String(timeoutMs)}ms)`));
}, timeoutMs);
void task.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
/**
* 健康检查:真实检查数据库(SELECT 1)与 Redis(PING),不只看端口(技术架构 §4.1.1)。
* 任一依赖不可用时返回 503 + 统一错误体,details 里带完整检查快照。
*/
@Injectable()
export class HealthService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(REDIS) private readonly redis: RedisService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
async check(): Promise<HealthReport> {
const [database, redis] = await Promise.all([this.checkDatabase(), this.checkRedis()]);
const report: HealthReport = {
status: database.status === 'up' && redis.status === 'up' ? 'ok' : 'degraded',
env: this.config.nodeEnv,
version: API_VERSION,
uptimeSeconds: Math.round(process.uptime()),
checks: { database, redis },
timestamp: nowIso(),
};
if (report.status === 'degraded') {
throw new AppException({
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
message: '依赖服务不可用',
details: report.checks,
});
}
return report;
}
private async checkDatabase(): Promise<ComponentHealth> {
const startedAt = Date.now();
try {
await withTimeout(this.prisma.$queryRaw`SELECT 1`, CHECK_TIMEOUT_MS);
return { status: 'up', latencyMs: Date.now() - startedAt };
} catch (error) {
return {
status: 'down',
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : '未知错误',
};
}
}
private async checkRedis(): Promise<ComponentHealth> {
const startedAt = Date.now();
try {
await withTimeout(this.redis.ping(), CHECK_TIMEOUT_MS);
return { status: 'up', latencyMs: Date.now() - startedAt };
} catch (error) {
return {
status: 'down',
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : '未知错误',
};
}
}
}
@@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
export interface ComponentHealth {
status: 'up' | 'down';
latencyMs: number;
error?: string;
}
export interface HealthReport {
status: 'ok' | 'degraded';
env: string;
version: string;
uptimeSeconds: number;
checks: {
database: ComponentHealth;
redis: ComponentHealth;
};
timestamp: string;
}
export class ComponentHealthDto {
@ApiProperty({ type: String, enum: ['up', 'down'], example: 'up' })
status!: 'up' | 'down';
@ApiProperty({ type: Number, description: '检查耗时(毫秒)', example: 3 })
latencyMs!: number;
@ApiProperty({ type: String, required: false, description: '失败原因(仅 down 时返回)' })
error?: string;
}
export class HealthChecksDto {
@ApiProperty({ type: ComponentHealthDto })
database!: ComponentHealthDto;
@ApiProperty({ type: ComponentHealthDto })
redis!: ComponentHealthDto;
}
export class HealthResponseDto {
@ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' })
status!: 'ok' | 'degraded';
@ApiProperty({ type: String, example: 'development' })
env!: string;
@ApiProperty({ type: String, example: '0.1.0' })
version!: string;
@ApiProperty({ type: Number, description: '进程运行秒数', example: 128 })
uptimeSeconds!: number;
@ApiProperty({ type: HealthChecksDto })
checks!: HealthChecksDto;
@ApiProperty({ type: String, example: '2026-11-02T08:00:00.000Z' })
timestamp!: string;
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { HealthModule } from './health/health.module';
/**
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。
*/
@Global()
@Module({
imports: [HealthModule],
exports: [HealthModule],
})
export class PlatformModule {}
+36
View File
@@ -0,0 +1,36 @@
import { ErrorCode } from '@lingxi/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, type TestApp,testConfig } from '../testing/test-app';
import { buildOpenApiDocument } from './document';
describe('OpenAPI 产物(冒烟)', () => {
let testApp: TestApp;
beforeAll(async () => {
testApp = await createTestApp();
});
afterAll(async () => {
await testApp.app.close();
});
it('版本为 3.1 且包含健康检查路由', () => {
const document = buildOpenApiDocument(testApp.app, testConfig);
expect(document.openapi).toBe('3.1.0');
expect(Object.keys(document.paths).some((path) => path.endsWith('/health'))).toBe(true);
expect(document.info.version).toBe('0.1.0');
});
it('包含全部错误码枚举与统一错误体', () => {
const document = buildOpenApiDocument(testApp.app, testConfig);
const schemas = document.components?.schemas ?? {};
expect(schemas.ErrorCode).toBeDefined();
expect(schemas.ErrorResponse).toBeDefined();
const errorCodeSchema = schemas.ErrorCode;
expect(errorCodeSchema && 'enum' in errorCodeSchema ? errorCodeSchema.enum : []).toHaveLength(
Object.values(ErrorCode).length,
);
expect('x-error-codes' in document).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { ErrorCode } from '@lingxi/shared';
import { type INestApplication } from '@nestjs/common';
import type { OpenAPIObject } from '@nestjs/swagger';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ErrorResponseDto } from '../common/http/error-response.dto';
import type { AppConfig } from '../config/env';
/**
* 生成 OpenAPI 文档。
* - 版本固定 3.1(技术架构:OpenAPI 3.1 + oasdiff breaking 检测)
* - 错误码以 ErrorCode schema + x-error-codes 扩展显式写入产物,供前端 codegen 与契约比对
*/
export function buildOpenApiDocument(app: INestApplication, config: AppConfig): OpenAPIObject {
const builder = new DocumentBuilder()
.setTitle('灵犀 API')
.setDescription(
[
'信息流广告素材生产平台后端 API。',
'',
'统一约定:',
'- 前缀 /api/v1(健康检查 /health 除外)',
'- 错误体 { code, message, details, trace_id }',
'- 列表响应 { items, next_cursor }',
].join('\n'),
)
.setVersion('0.1.0')
.setOpenAPIVersion('3.1.0')
.addServer('/api/v1', 'API v1')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, builder, {
extraModels: [ErrorResponseDto],
});
const components = document.components ?? {};
const schemas = components.schemas ?? {};
schemas.ErrorCode = {
type: 'integer',
description: '业务错误码(只允许使用 @lingxi/shared 中登记的错误码)',
enum: Object.values(ErrorCode),
};
schemas.ErrorResponse = {
type: 'object',
required: ['code', 'message', 'trace_id'],
properties: {
code: { $ref: '#/components/schemas/ErrorCode' },
message: { type: 'string' },
details: { description: '补充信息,结构由错误码决定' },
trace_id: { type: 'string' },
},
};
document.components = { ...components, schemas };
Object.assign(document, {
'x-error-codes': Object.values(ErrorCode).map((code) => ({ code })),
'x-api-config': {
env: config.nodeEnv,
featureVideo: config.featureVideo,
},
});
return document;
}
export function setupOpenApi(app: INestApplication, config: AppConfig): OpenAPIObject {
const document = buildOpenApiDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: 'api/openapi.json',
});
return document;
}
+30
View File
@@ -0,0 +1,30 @@
import 'reflect-metadata';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createApp } from '../bootstrap';
import { loadDotenv } from '../config/load-dotenv';
import { buildOpenApiDocument } from '../openapi/document';
async function main(): Promise<void> {
loadDotenv();
const { app, config } = await createApp();
await app.init();
const document = buildOpenApiDocument(app, config);
const outputDir = resolve(process.cwd(), 'openapi');
const outputFile = resolve(outputDir, 'lingxi.openapi.json');
await mkdir(outputDir, { recursive: true });
await writeFile(outputFile, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
process.stdout.write(`OpenAPI 已导出:${outputFile}\n`);
await app.close();
}
void main().catch((error: unknown) => {
process.stderr.write(
`OpenAPI 导出失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
});
+91
View File
@@ -0,0 +1,91 @@
import { API_PREFIX, ErrorCode } from '@lingxi/shared';
import { Controller, Get, NotFoundException } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { type Mock, vi } from 'vitest';
import { AppException } from '../common/errors/app-exception';
import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter';
import { APP_CONFIG } from '../config/config.module';
import type { AppConfig } from '../config/env';
import { PRISMA, REDIS } from '../infra/tokens';
import { HealthController } from '../modules/platform/health/health.controller';
import { HealthService } from '../modules/platform/health/health.service';
export const testConfig: AppConfig = {
nodeEnv: 'test',
isProduction: false,
port: 3000,
host: '127.0.0.1',
logLevel: 'error',
databaseUrl: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
redisUrl: 'redis://127.0.0.1:16379',
webOrigins: ['http://127.0.0.1:5173'],
maxUploadMb: 500,
featureVideo: false,
};
@Controller('__test')
class ThrowingController {
@Get('app-error')
appError(): never {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '当前状态不允许该操作' });
}
@Get('http-404')
notFound(): never {
throw new NotFoundException('资源不存在');
}
@Get('boom')
boom(): never {
throw new Error('unexpected failure');
}
}
export interface TestAppOptions {
databaseUp?: boolean;
redisUp?: boolean;
includeErrorRoutes?: boolean;
}
export interface TestApp {
app: NestFastifyApplication;
prisma: { $queryRaw: Mock };
redis: { ping: Mock };
}
export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
const prisma = {
$queryRaw:
options.databaseUp === false
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:15432'))
: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
};
const redis = {
ping:
options.redisUp === false
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379'))
: vi.fn().mockResolvedValue('PONG'),
};
const moduleRef = await Test.createTestingModule({
controllers:
options.includeErrorRoutes === true
? [HealthController, ThrowingController]
: [HealthController],
providers: [
HealthService,
{ provide: PRISMA, useValue: prisma },
{ provide: REDIS, useValue: redis },
{ provide: APP_CONFIG, useValue: testConfig },
],
}).compile();
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
app.useGlobalFilters(new AllExceptionsFilter());
await app.init();
return { app, prisma, redis };
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"declarationMap": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["src/**/*.spec.ts", "src/**/*.int.spec.ts", "src/scripts/**"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false
},
"include": ["src", "vitest.config.ts", "vitest.int.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
exclude: ['**/*.int.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'json-summary'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'],
thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 },
},
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.int.spec.ts'],
testTimeout: 30_000,
hookTimeout: 30_000,
},
});