feat: 统一接口响应结构

This commit is contained in:
sunlei 2026-05-19 10:07:44 +08:00
parent 74dad7d70d
commit 33ec71e9a7
10 changed files with 193 additions and 61 deletions

10
API.md
View File

@ -12,13 +12,13 @@
}
```
失败时仍使用相同结构,常见为
失败时使用 `err` 承载错误信息,不返回成功结构里的 `data`
```json
{
"code": 400,
"msg": "操作失败",
"data": null
"err": "错误原因"
}
```
@ -315,12 +315,12 @@ Query
## Vben Admin 真实接口
这些接口用于 `Vue/kt-template-admin`,响应格式与 Vben 请求拦截器对齐:
这些接口用于 `Vue/kt-template-admin`,响应格式与项目统一响应结构对齐:
```json
{
"code": 0,
"message": "ok",
"code": 200,
"msg": "操作成功",
"data": {}
}
```

View File

@ -106,6 +106,16 @@ pnpm test:e2e # e2e 测试
}
```
失败时统一返回 `err` 字段,成功响应不包含 `err`
```json
{
"code": 400,
"msg": "操作失败",
"err": "错误原因"
}
```
## 核心规则
- `admin_component` 表保存组件/图表模板,`admin_dict` 表是统一字典翻译数据源,`Component.typeMsg` 和 `Component.componentTypeMsg` 查询后自动映射;旧 `/dict/*` 接口路径保持兼容。

View File

@ -98,11 +98,16 @@ export class AdminExampleController {
@Public()
status(@Query('status') status: string, @Res() res: Response) {
const code = Number(status) || 200;
if (code === 200) {
res.status(code).send(vbenSuccess(`${code}`));
return;
}
res.status(code).send({
code: -1,
data: null,
error: `${code}`,
message: `${code}`,
code,
msg: `${code}`,
err: `${code}`,
});
}
@ -110,8 +115,8 @@ export class AdminExampleController {
async bigint(@Res() res: Response) {
res.setHeader('Content-Type', 'application/json');
res.send(`{
"code": 0,
"message": "success",
"code": 200,
"msg": "操作成功",
"data": [
{
"id": 123456789012345678901234567890123456789012345678901234567890,
@ -132,13 +137,13 @@ export class AdminExampleController {
@Get('test')
@Public()
testGet() {
return 'Test get handler';
return vbenSuccess('Test get handler');
}
@Post('test')
@Public()
testPost() {
return 'Test post handler';
return vbenSuccess('Test post handler');
}
private sortRows(rows: DemoTableRow[], sortBy?: string, sortOrder?: string) {

View File

@ -1,12 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MinioModule } from 'nestjs-minio-client';
import { MinioClientModule } from './minio/minio.module';
import { SaveBodyInterceptor } from './common';
import { ApiExceptionFilter, SaveBodyInterceptor } from './common';
import { AdminModule } from './admin/admin.module';
import { WordpressModule } from './wordpress/wordpress.module';
@ -58,6 +58,10 @@ import { WordpressModule } from './wordpress/wordpress.module';
provide: APP_INTERCEPTOR,
useClass: SaveBodyInterceptor,
},
{
provide: APP_FILTER,
useClass: ApiExceptionFilter,
},
],
})
export class AppModule {}

View File

@ -1,17 +1,24 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type VbenResponse<T = any> = {
code: number;
code: 200;
data: T;
error: any;
message: string;
msg: string;
};
export const vbenSuccess = <T = any>(data: T): VbenResponse<T> => ({
code: 0,
export type ApiErrorResponse = {
code: number;
msg: string;
err: any;
};
export const vbenSuccess = <T = any>(
data: T,
msg = '操作成功',
): VbenResponse<T> => ({
code: 200,
data,
error: null,
message: 'ok',
msg,
});
export const vbenPage = <T = any>(items: T[], total: number) =>
@ -23,14 +30,12 @@ export const vbenPage = <T = any>(items: T[], total: number) =>
export const throwVbenError = (
message: string,
status = HttpStatus.BAD_REQUEST,
error: any = message,
err: any = message,
): never => {
throw new HttpException(
{
code: -1,
data: null,
error,
message,
msg: message,
err,
},
status,
);

View File

@ -0,0 +1,81 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { ApiErrorResponse } from '../admin-response';
type ExceptionBody = {
err?: unknown;
error?: unknown;
message?: unknown;
msg?: unknown;
};
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = this.getStatus(exception);
const body = this.getBody(exception);
response.status(status).json({
code: status,
msg: this.getMessage(status, body, exception),
err: this.getErr(status, body, exception),
} satisfies ApiErrorResponse);
}
private getStatus(exception: unknown) {
if (exception instanceof HttpException) {
return exception.getStatus();
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
private getBody(exception: unknown): ExceptionBody | string | null {
if (!(exception instanceof HttpException)) {
return null;
}
const body = exception.getResponse();
return typeof body === 'string' ? body : (body as ExceptionBody);
}
private getMessage(
status: number,
body: ExceptionBody | string | null,
exception: unknown,
) {
if (typeof body === 'string') return body;
if (body?.msg) return this.stringifyMessage(body.msg);
if (body?.message) return this.stringifyMessage(body.message);
if (exception instanceof Error && status < 500) return exception.message;
return status >= 500 ? 'Internal server error' : '操作失败';
}
private getErr(
status: number,
body: ExceptionBody | string | null,
exception: unknown,
) {
if (typeof body === 'string') return body;
if (body?.err !== undefined) return body.err;
if (body?.error !== undefined) return body.error;
if (body?.message !== undefined) return body.message;
if (exception instanceof Error) return exception.message;
return status >= 500 ? 'Internal server error' : '操作失败';
}
private stringifyMessage(message: unknown) {
return Array.isArray(message) ? message.join('; ') : String(message);
}
}

View File

@ -3,6 +3,7 @@ export * from './admin-tree';
export * from './decorators/current-admin-user.decorator';
export * from './decorators/decode-dict.decorator';
export * from './decorators/public.decorator';
export * from './filters/api-exception.filter';
export * from './interceptors/save-body.interceptor';
export * from './snowflake-id';
export * from './services/tool.service';

View File

@ -15,12 +15,19 @@ export class ToolsService {
}
res(code: number, msg: string, data: any): Res {
const retn: Res = {
if (code === 200) {
return {
code,
msg,
data,
};
}
return {
code,
msg,
data,
err: data,
};
return retn;
}
page<T = any>(list: T[], total: number): Page<T> {

16
src/types/res.d.ts vendored
View File

@ -1,8 +1,14 @@
type Res = {
code: number;
msg: string;
data: any;
};
type Res =
| {
code: 200;
msg: string;
data: any;
}
| {
code: number;
msg: string;
err: any;
};
type Page<T = any> = {
list: T[];

View File

@ -1,5 +1,5 @@
import { HttpException, HttpStatus, INestApplication } from '@nestjs/common';
import { APP_INTERCEPTOR, Reflector } from '@nestjs/core';
import { APP_FILTER, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request = require('supertest');
import { Readable } from 'stream';
@ -11,7 +11,11 @@ import { ComponentController } from '../src/admin/component/component.controller
import { ComponentService } from '../src/admin/component/component.service';
import { DictController } from '../src/admin/dict/dict.controller';
import { DictService } from '../src/admin/dict/dict.service';
import { SaveBodyInterceptor, ToolsService } from '../src/common';
import {
ApiExceptionFilter,
SaveBodyInterceptor,
ToolsService,
} from '../src/common';
import { MinioClientController } from '../src/minio/minio.controller';
import { MinioClientService } from '../src/minio/minio.service';
import { WordpressArticleController } from '../src/wordpress/wordpress-article.controller';
@ -117,10 +121,8 @@ const authServiceMock = {
const unauthorizedException = () =>
new HttpException(
{
code: -1,
data: null,
error: 'Unauthorized Exception',
message: 'Unauthorized Exception',
msg: 'Unauthorized Exception',
err: 'Unauthorized Exception',
},
HttpStatus.UNAUTHORIZED,
);
@ -564,7 +566,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressUser,
});
},
@ -584,7 +586,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressLoginResult.cookie,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: {
auth: wordpressLoginResult.auth,
user: wordpressUser,
@ -601,7 +603,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
expect.anything(),
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: true,
});
},
@ -630,7 +632,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: {
list: [wordpressArticle],
total: 1,
@ -651,7 +653,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressArticle,
});
},
@ -676,7 +678,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressArticle,
});
},
@ -700,7 +702,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressArticle,
});
},
@ -722,7 +724,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: true,
});
},
@ -751,7 +753,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: {
list: [wordpressTerm],
total: 1,
@ -772,7 +774,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressTerm,
});
},
@ -795,7 +797,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressTerm,
});
},
@ -819,7 +821,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressTerm,
});
},
@ -838,7 +840,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: true,
});
},
@ -867,7 +869,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: {
list: [wordpressTerm],
total: 1,
@ -888,7 +890,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressTerm,
});
},
@ -913,7 +915,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressTerm,
});
},
@ -939,7 +941,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: wordpressTerm,
});
},
@ -958,7 +960,7 @@ const routeTestCases: Record<string, RouteTestCase> = {
wordpressAuthContext,
);
expect(response.body).toMatchObject({
code: 0,
code: 200,
data: true,
});
},
@ -999,6 +1001,10 @@ describe('KT Template Online API (e2e)', () => {
provide: APP_INTERCEPTOR,
useClass: SaveBodyInterceptor,
},
{
provide: APP_FILTER,
useClass: ApiExceptionFilter,
},
],
}).compile();
@ -1052,7 +1058,7 @@ describe('KT Template Online API (e2e)', () => {
expect(response.body).toEqual({
code: 400,
msg: '操作失败',
data: false,
err: false,
});
});
@ -1062,7 +1068,14 @@ describe('KT Template Online API (e2e)', () => {
await request(app.getHttpServer())
.get('/dict/getDictByKey')
.query({ dictKey: 'COMPONENT_TYPE' })
.expect(401);
.expect(401)
.expect(({ body }) => {
expect(body).toEqual({
code: 401,
msg: 'Unauthorized Exception',
err: 'Unauthorized Exception',
});
});
expect(dictServiceMock.getDictByKey).not.toHaveBeenCalled();