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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user