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

@@ -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;
}
}