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 ```json
{ {
"code": 400, "code": 400,
"msg": "操作失败", "msg": "操作失败",
"data": null "err": "错误原因"
} }
``` ```
@ -315,12 +315,12 @@ Query
## Vben Admin 真实接口 ## Vben Admin 真实接口
这些接口用于 `Vue/kt-template-admin`,响应格式与 Vben 请求拦截器对齐: 这些接口用于 `Vue/kt-template-admin`,响应格式与项目统一响应结构对齐:
```json ```json
{ {
"code": 0, "code": 200,
"message": "ok", "msg": "操作成功",
"data": {} "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/*` 接口路径保持兼容。 - `admin_component` 表保存组件/图表模板,`admin_dict` 表是统一字典翻译数据源,`Component.typeMsg` 和 `Component.componentTypeMsg` 查询后自动映射;旧 `/dict/*` 接口路径保持兼容。

View File

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

View File

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

View File

@ -1,17 +1,24 @@
import { HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus } from '@nestjs/common';
export type VbenResponse<T = any> = { export type VbenResponse<T = any> = {
code: number; code: 200;
data: T; data: T;
error: any; msg: string;
message: string;
}; };
export const vbenSuccess = <T = any>(data: T): VbenResponse<T> => ({ export type ApiErrorResponse = {
code: 0, code: number;
msg: string;
err: any;
};
export const vbenSuccess = <T = any>(
data: T,
msg = '操作成功',
): VbenResponse<T> => ({
code: 200,
data, data,
error: null, msg,
message: 'ok',
}); });
export const vbenPage = <T = any>(items: T[], total: number) => 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 = ( export const throwVbenError = (
message: string, message: string,
status = HttpStatus.BAD_REQUEST, status = HttpStatus.BAD_REQUEST,
error: any = message, err: any = message,
): never => { ): never => {
throw new HttpException( throw new HttpException(
{ {
code: -1, msg: message,
data: null, err,
error,
message,
}, },
status, 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/current-admin-user.decorator';
export * from './decorators/decode-dict.decorator'; export * from './decorators/decode-dict.decorator';
export * from './decorators/public.decorator'; export * from './decorators/public.decorator';
export * from './filters/api-exception.filter';
export * from './interceptors/save-body.interceptor'; export * from './interceptors/save-body.interceptor';
export * from './snowflake-id'; export * from './snowflake-id';
export * from './services/tool.service'; export * from './services/tool.service';

View File

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

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

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

View File

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