feat: 增强 Swagger 响应示例

This commit is contained in:
sunlei 2026-06-03 07:30:25 +08:00
parent 8ae097e44d
commit 29663ef369
4 changed files with 833 additions and 27 deletions

View File

@ -37,7 +37,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mqtt": "^5.15.1", "mqtt": "^5.15.1",
"mysql2": "^3.22.3", "mysql2": "^3.22.3",
"nestjs-knife4j-plus": "^1.0.8", "nestjs-knife4j-plus": "^1.0.9",
"nestjs-minio-client": "^2.2.0", "nestjs-minio-client": "^2.2.0",
"reflect-metadata": "^0.1.14", "reflect-metadata": "^0.1.14",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",

View File

@ -42,8 +42,8 @@ importers:
specifier: ^3.22.3 specifier: ^3.22.3
version: 3.22.3(@types/node@18.11.18) version: 3.22.3(@types/node@18.11.18)
nestjs-knife4j-plus: nestjs-knife4j-plus:
specifier: ^1.0.8 specifier: ^1.0.9
version: 1.0.8(@nestjs/common@9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(express@4.18.2) version: 1.0.9(@nestjs/common@9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(express@4.18.2)
nestjs-minio-client: nestjs-minio-client:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0(@nestjs/common@9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@9.4.3) version: 2.2.0(@nestjs/common@9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@9.4.3)
@ -1567,6 +1567,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0: es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1937,6 +1941,10 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
@ -2656,8 +2664,8 @@ packages:
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
nestjs-knife4j-plus@1.0.8: nestjs-knife4j-plus@1.0.9:
resolution: {integrity: sha512-oXUBQwrEzeuOyqei32I/zIHGo1V8icL0pJaX2dOJSqi0VKXy63pXXJhZ/hVD/XX3Rg/dvLqg5IHgnm7nRksesw==} resolution: {integrity: sha512-MgMrfXgJfRgwW43BbMAKvInxluuvdNjSiYmkBvQvUawJMwn6+Op4W8/iYG1Q7u4D7mefWtM4zwVA7gBHErWCzg==}
peerDependencies: peerDependencies:
'@fastify/static': '*' '@fastify/static': '*'
'@nestjs/common': '*' '@nestjs/common': '*'
@ -3066,8 +3074,8 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.8.0: semver@7.8.1:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@ -3480,8 +3488,8 @@ packages:
resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typed-array-length@1.0.7: typed-array-length@1.0.8:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typedarray@0.0.6: typedarray@0.0.6:
@ -3660,6 +3668,10 @@ packages:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
which-typed-array@1.1.21:
resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==}
engines: {node: '>= 0.4'}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -5537,7 +5549,7 @@ snapshots:
data-view-byte-offset: 1.0.1 data-view-byte-offset: 1.0.1
es-define-property: 1.0.1 es-define-property: 1.0.1
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
es-set-tostringtag: 2.1.0 es-set-tostringtag: 2.1.0
es-to-primitive: 1.3.0 es-to-primitive: 1.3.0
function.prototype.name: 1.1.8 function.prototype.name: 1.1.8
@ -5549,7 +5561,7 @@ snapshots:
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
has-proto: 1.2.0 has-proto: 1.2.0
has-symbols: 1.1.0 has-symbols: 1.1.0
hasown: 2.0.3 hasown: 2.0.4
internal-slot: 1.1.0 internal-slot: 1.1.0
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
is-callable: 1.2.7 is-callable: 1.2.7
@ -5578,9 +5590,9 @@ snapshots:
typed-array-buffer: 1.0.3 typed-array-buffer: 1.0.3
typed-array-byte-length: 1.0.3 typed-array-byte-length: 1.0.3
typed-array-byte-offset: 1.0.4 typed-array-byte-offset: 1.0.4
typed-array-length: 1.0.7 typed-array-length: 1.0.8
unbox-primitive: 1.1.0 unbox-primitive: 1.1.0
which-typed-array: 1.1.20 which-typed-array: 1.1.21
optional: true optional: true
es-aggregate-error@1.0.14: es-aggregate-error@1.0.14:
@ -5605,6 +5617,11 @@ snapshots:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
optional: true
es-set-tostringtag@2.1.0: es-set-tostringtag@2.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@ -5951,7 +5968,7 @@ snapshots:
call-bound: 1.0.4 call-bound: 1.0.4
define-properties: 1.2.1 define-properties: 1.2.1
functions-have-names: 1.2.3 functions-have-names: 1.2.3
hasown: 2.0.3 hasown: 2.0.4
is-callable: 1.2.7 is-callable: 1.2.7
optional: true optional: true
@ -6085,6 +6102,11 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
optional: true
help-me@5.0.0: {} help-me@5.0.0: {}
html-escaper@2.0.2: {} html-escaper@2.0.2: {}
@ -6194,7 +6216,7 @@ snapshots:
internal-slot@1.1.0: internal-slot@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
hasown: 2.0.3 hasown: 2.0.4
side-channel: 1.1.0 side-channel: 1.1.0
optional: true optional: true
@ -6792,7 +6814,7 @@ snapshots:
lodash.isstring: 4.0.1 lodash.isstring: 4.0.1
lodash.once: 4.1.1 lodash.once: 4.1.1
ms: 2.1.3 ms: 2.1.3
semver: 7.8.0 semver: 7.8.1
optional: true optional: true
jwa@2.0.1: jwa@2.0.1:
@ -7052,7 +7074,7 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
nestjs-knife4j-plus@1.0.8(@nestjs/common@9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(express@4.18.2): nestjs-knife4j-plus@1.0.9(@nestjs/common@9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2))(express@4.18.2):
dependencies: dependencies:
'@nestjs/common': 9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2) '@nestjs/common': 9.4.3(reflect-metadata@0.1.14)(rxjs@7.8.2)
express: 4.18.2 express: 4.18.2
@ -7104,7 +7126,7 @@ snapshots:
call-bind: 1.0.9 call-bind: 1.0.9
call-bound: 1.0.4 call-bound: 1.0.4
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
has-symbols: 1.1.0 has-symbols: 1.1.0
object-keys: 1.1.1 object-keys: 1.1.1
optional: true optional: true
@ -7349,7 +7371,7 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-abstract: 1.24.2 es-abstract: 1.24.2
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
get-proto: 1.0.1 get-proto: 1.0.1
which-builtin-type: 1.2.1 which-builtin-type: 1.2.1
@ -7465,7 +7487,7 @@ snapshots:
semver@7.7.4: {} semver@7.7.4: {}
semver@7.8.0: semver@7.8.1:
optional: true optional: true
send@0.18.0: send@0.18.0:
@ -7516,7 +7538,7 @@ snapshots:
dependencies: dependencies:
dunder-proto: 1.0.1 dunder-proto: 1.0.1
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
optional: true optional: true
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
@ -7654,7 +7676,7 @@ snapshots:
define-data-property: 1.1.4 define-data-property: 1.1.4
define-properties: 1.2.1 define-properties: 1.2.1
es-abstract: 1.24.2 es-abstract: 1.24.2
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
optional: true optional: true
@ -7663,14 +7685,14 @@ snapshots:
call-bind: 1.0.9 call-bind: 1.0.9
call-bound: 1.0.4 call-bound: 1.0.4
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
optional: true optional: true
string.prototype.trimstart@1.0.8: string.prototype.trimstart@1.0.8:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.2
optional: true optional: true
string_decoder@1.1.1: string_decoder@1.1.1:
@ -7931,7 +7953,7 @@ snapshots:
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
optional: true optional: true
typed-array-length@1.0.7: typed-array-length@1.0.8:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
for-each: 0.3.5 for-each: 0.3.5
@ -8108,7 +8130,7 @@ snapshots:
isarray: 2.0.5 isarray: 2.0.5
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
which-collection: 1.0.2 which-collection: 1.0.2
which-typed-array: 1.1.20 which-typed-array: 1.1.21
optional: true optional: true
which-collection@1.0.2: which-collection@1.0.2:
@ -8129,6 +8151,17 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
which-typed-array@1.1.21:
dependencies:
available-typed-arrays: 1.0.7
call-bind: 1.0.9
call-bound: 1.0.4
for-each: 0.3.5
get-proto: 1.0.1
gopd: 1.2.0
has-tostringtag: 1.0.2
optional: true
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0

View File

@ -1,7 +1,10 @@
import { applyDecorators, Type } from '@nestjs/common'; import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger'; import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger';
import type { OpenAPIObject } from '@nestjs/swagger';
type SwaggerSchema = Record<string, any>; type SwaggerSchema = Record<string, any>;
type SwaggerOperation = Record<string, any>;
type SwaggerComponents = NonNullable<OpenAPIObject['components']>;
type ApiResponseOptions = { type ApiResponseOptions = {
description?: string; description?: string;
@ -179,3 +182,770 @@ export const ApiFileDownloadResponse = (description = '文件下载成功') =>
}, },
}), }),
); );
const operationMethods = [
'get',
'post',
'put',
'delete',
'patch',
'options',
'head',
];
const standardErrorSchema = {
type: 'object',
required: ['code', 'msg', 'err'],
properties: {
code: {
type: 'integer',
description: '错误状态码',
example: 400,
},
msg: {
type: 'string',
description: '错误提示',
example: '操作失败',
},
err: {
description: '错误详情',
example: 'Bad Request',
},
},
};
export const applySwaggerResponseExamples = (document: OpenAPIObject) => {
const components = ensureDocumentComponents(document);
components.schemas.KtApiErrorResponse ||= standardErrorSchema;
Object.entries(document.paths).forEach(([path, pathItem]) => {
Object.entries(pathItem || {}).forEach(([method, operation]) => {
if (!operationMethods.includes(method)) return;
applyOperationResponseExamples(document, path, method, operation as any);
});
});
return document;
};
function applyOperationResponseExamples(
document: OpenAPIObject,
path: string,
method: string,
operation: SwaggerOperation,
) {
operation.responses ||= {};
if (path === '/') {
operation.responses['301'] = {
description: '重定向到 Swagger 文档',
};
return;
}
if (isBinaryResponsePath(path)) {
if (!operation.responses['200']?.content) {
operation.responses['200'] = {
description: '文件流响应',
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
};
}
applyErrorResponses(operation);
return;
}
const dataExample = getOperationDataExample(path, method, operation);
const successSchema = createOperationSuccessSchema(
document,
path,
method,
dataExample,
);
const successResponse = buildSuccessResponse(dataExample, successSchema);
const currentResponse = operation.responses['200'];
operation.responses['200'] = mergeJsonResponse(
currentResponse,
successResponse,
);
applyErrorResponses(operation);
}
function applyErrorResponses(operation: SwaggerOperation) {
operation.responses['400'] ||= buildErrorResponse(
400,
'Bad Request',
'请求参数不合法',
);
operation.responses['401'] ||= buildErrorResponse(
401,
'Unauthorized',
'未登录或登录已过期',
);
operation.responses['500'] ||= buildErrorResponse(
500,
'Internal Server Error',
'服务内部错误',
);
}
function buildSuccessResponse(dataExample: any, schema: SwaggerSchema) {
const example = getResponseExample(dataExample);
return {
description: '操作成功',
content: {
'application/json': {
schema,
example,
examples: {
success: {
summary: '成功响应',
value: example,
},
},
},
},
};
}
function buildErrorResponse(status: number, summary: string, message: string) {
return {
description: message,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/KtApiErrorResponse',
},
example: {
code: status,
msg: message,
err: summary,
},
examples: {
error: {
summary,
value: {
code: status,
msg: message,
err: summary,
},
},
},
},
},
};
}
function createOperationSuccessSchema(
document: OpenAPIObject,
path: string,
method: string,
dataExample: any,
) {
const components = ensureDocumentComponents(document);
const componentName = `${toPascalCase(method)}${toPascalCase(path)}Response`;
const dataSchemaName = `${componentName}Data`;
const dataSchema = schemaFromExample(
dataExample,
'data',
components,
dataSchemaName,
);
components.schemas[dataSchemaName] = dataSchema;
const schema = buildSuccessSchema(dataExample, dataSchemaName);
components.schemas[componentName] = schema;
return {
$ref: `#/components/schemas/${componentName}`,
};
}
function buildSuccessSchema(
dataExample: any,
dataSchemaName: string,
): SwaggerSchema {
const example = getResponseExample(dataExample);
return {
type: 'object',
required: ['code', 'msg', 'data'],
description: '统一成功响应结构',
example,
properties: {
code: {
type: 'integer',
description: '成功状态码,固定为 200',
example: 200,
},
msg: {
type: 'string',
description: '成功提示',
example: '操作成功',
},
data: {
allOf: [
{
$ref: `#/components/schemas/${dataSchemaName}`,
},
],
description: '业务数据;成功响应不会返回 err 字段',
example: dataExample,
},
},
};
}
function mergeJsonResponse(currentResponse: any, standardResponse: any) {
if (!currentResponse?.content?.['application/json']) {
return {
...standardResponse,
description: currentResponse?.description || standardResponse.description,
};
}
const jsonContent = currentResponse.content['application/json'];
return {
...currentResponse,
description: currentResponse.description || standardResponse.description,
content: {
...currentResponse.content,
'application/json': {
...jsonContent,
schema: standardResponse.content['application/json'].schema,
example:
jsonContent.example ||
standardResponse.content['application/json'].example,
examples: {
...standardResponse.content['application/json'].examples,
...jsonContent.examples,
},
},
},
};
}
function schemaFromExample(
example: any,
propertyName = 'data',
components?: SwaggerComponents,
schemaName?: string,
): SwaggerSchema {
if (Array.isArray(example)) {
const itemSchemaName = schemaName
? `${schemaName}${toPascalCase(getArrayItemName(propertyName))}`
: undefined;
const itemSchema =
example.length > 0
? schemaFromExample(
example[0],
getArrayItemName(propertyName),
components,
itemSchemaName,
)
: { type: 'object' };
if (components && itemSchemaName && itemSchema.type === 'object') {
components.schemas[itemSchemaName] = itemSchema;
}
return {
type: 'array',
description: getPropertyDescription(propertyName),
example,
items:
components && itemSchemaName && itemSchema.type === 'object'
? {
$ref: `#/components/schemas/${itemSchemaName}`,
}
: itemSchema,
};
}
if (example === null) {
return {
nullable: true,
description: getPropertyDescription(propertyName),
example: null,
};
}
if (typeof example === 'boolean') {
return {
type: 'boolean',
description: getPropertyDescription(propertyName),
example,
};
}
if (typeof example === 'number') {
return {
type: Number.isInteger(example) ? 'integer' : 'number',
description: getPropertyDescription(propertyName),
example,
};
}
if (typeof example === 'string') {
return {
type: 'string',
description: getPropertyDescription(propertyName),
example,
};
}
if (typeof example === 'object') {
const properties = Object.entries(example).reduce<
Record<string, SwaggerSchema>
>((acc, [key, value]) => {
acc[key] = schemaFromExample(
value,
key,
components,
schemaName ? `${schemaName}${toPascalCase(key)}` : undefined,
);
return acc;
}, {});
return {
type: 'object',
description: getPropertyDescription(propertyName),
required: Object.keys(properties),
example,
properties,
};
}
return {
type: 'object',
description: getPropertyDescription(propertyName),
};
}
function ensureDocumentComponents(document: OpenAPIObject): SwaggerComponents {
document.components ||= {};
document.components.schemas ||= {};
return document.components;
}
function toPascalCase(value: string) {
return value
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((item) => `${item.charAt(0).toUpperCase()}${item.slice(1)}`)
.join('');
}
function getArrayItemName(propertyName: string) {
if (propertyName === 'items') return 'item';
if (propertyName.endsWith('s')) return propertyName.slice(0, -1);
return `${propertyName}Item`;
}
function getPropertyDescription(propertyName: string) {
const descriptionMap: Record<string, string> = {
['access' + 'Token']: 'Admin 访问令牌',
accountCount: '账号总数',
available: '是否可用',
bucketName: 'Bucket 名称',
categories: 'WordPress 分类 ID 列表',
code: '响应状态码',
command: '命令触发词',
commandId: '在线命令 ID',
connectionRole: 'OneBot 连接角色',
count: '数量',
data: '业务数据',
description: '描述',
enabled: '是否启用',
err: '错误详情',
etag: '对象 ETag',
expireAt: '过期时间',
id: '唯一 ID',
image: '图片地址',
items: '列表数据',
key: '唯一键',
keyword: '匹配关键词',
lastHeartbeatAt: '最后心跳时间',
lastMessage: '最后一条消息',
lastModified: '最后修改时间',
matchType: '匹配方式',
message: '消息内容',
mimeType: '文件 MIME 类型',
mode: '过滤模式',
msg: '响应消息',
name: '名称',
nickname: '昵称',
objectName: '对象名称',
onlineAccountCount: '在线账号数',
path: '路由路径',
pluginKey: '插件能力 Key',
preciseUser: '是否精确到 QQ 号',
qrcode: '二维码内容',
realName: '真实姓名',
['refresh' + 'Token']: '刷新令牌',
reply: '回复内容',
replyContent: '回复内容',
roles: '角色列表',
selfId: '机器人 QQ 号',
sessionId: '扫码会话 ID',
size: '文件大小',
slug: 'WordPress slug',
status: '状态',
tags: 'WordPress 标签 ID 列表',
targetId: '目标 ID',
targetType: '目标类型',
timezone: '时区',
title: '标题',
todayMessageCount: '今日消息数',
todaySendCount: '今日发送数',
total: '总条数',
triggerMode: '触发方式',
type: '类型',
url: '访问地址',
userId: '用户 QQ 号',
username: '用户名',
wordpressAuth: 'WordPress 授权信息',
wordpressAvailable: 'WordPress 是否可用',
wordpressError: 'WordPress 登录错误',
};
return descriptionMap[propertyName] || propertyName;
}
function getOperationDataExample(
path: string,
method: string,
operation: SwaggerOperation,
) {
const normalizedPath = path.toLowerCase();
const summary = operation.summary || operation.description || '';
if (normalizedPath.includes('/auth/login')) return adminLoginExample();
if (normalizedPath.includes('/auth/refresh')) return '<access-token>';
if (normalizedPath.includes('/auth/codes')) {
return ['QqBotAccountCreateButton', 'QqBotPermissionCreateButton'];
}
if (normalizedPath.includes('/scan/')) return qqbotScanExample();
if (normalizedPath.includes('/dashboard/summary')) return dashboardExample();
if (isPageResponsePath(normalizedPath)) {
return {
items: [itemExampleByPath(normalizedPath)],
total: 1,
};
}
if (isArrayResponsePath(normalizedPath))
return [itemExampleByPath(normalizedPath)];
if (isBooleanResponsePath(normalizedPath, method, summary)) return true;
if (normalizedPath.includes('/check')) return { available: true };
if (normalizedPath.includes('/config')) return permissionConfigExample();
if (normalizedPath.includes('/health')) return [pluginHealthExample()];
if (normalizedPath.includes('/test'))
return { matched: true, reply: '测试回复' };
if (normalizedPath.includes('/upload')) return minioUploadExample();
if (normalizedPath.includes('/url')) {
return 'http://127.0.0.1:9000/kt-template-online/uploads/demo.png';
}
return itemExampleByPath(normalizedPath);
}
function isPageResponsePath(path: string) {
if (path.startsWith('/wordpress/')) return false;
return (
path.endsWith('/list') ||
path.endsWith('/log/list') ||
path.includes('/allowlist') ||
path.includes('/blocklist')
);
}
function isArrayResponsePath(path: string) {
return (
(path.startsWith('/wordpress/') && path.endsWith('/list')) ||
path.includes('/alllist') ||
path.includes('/enabled') ||
path.includes('/options') ||
path.includes('/codes') ||
path.includes('/menu/all') ||
path.includes('/operation/list') ||
path.includes('/event/list') ||
path.includes('/dict/')
);
}
function isBooleanResponsePath(path: string, method: string, summary: string) {
return (
method === 'delete' ||
path.includes('/delete') ||
path.includes('/remove') ||
path.includes('/toggle') ||
path.includes('/kick') ||
path.includes('/cancel') ||
path.includes('/bind/') ||
path.includes('/unbind/') ||
summary.includes('是否')
);
}
function isBinaryResponsePath(path: string) {
return path.includes('/download') || path.includes('/resource-proxy');
}
function itemExampleByPath(path: string) {
if (path.includes('/qqbot/account')) return qqbotAccountExample();
if (path.includes('/qqbot/command')) return qqbotCommandExample();
if (path.includes('/qqbot/rule')) return qqbotRuleExample();
if (path.includes('/qqbot/message')) return qqbotMessageExample();
if (path.includes('/qqbot/conversation')) return qqbotConversationExample();
if (path.includes('/qqbot/permission')) return qqbotPermissionExample();
if (path.includes('/qqbot/plugin')) return qqbotPluginExample();
if (path.includes('/qqbot/send')) return qqbotSendLogExample();
if (path.includes('/wordpress/article')) return wordpressArticleExample();
if (path.includes('/wordpress/category'))
return wordpressTaxonomyExample('NAS');
if (path.includes('/wordpress/tag'))
return wordpressTaxonomyExample('Docker');
if (path.includes('/system/menu') || path.includes('/menu/'))
return adminMenuExample();
if (path.includes('/system/dept')) return adminDeptExample();
if (path.includes('/system/role')) return adminRoleExample();
if (path.includes('/component')) return componentExample();
if (path.includes('/user')) return adminUserExample();
if (path.includes('/timezone')) return { timezone: 'Asia/Shanghai' };
if (path.includes('/minio')) return minioObjectExample();
return {
id: '1000000000000000001',
name: 'KT 示例数据',
status: 1,
};
}
function adminLoginExample() {
return {
id: '1000000000000000001',
username: 'admin',
realName: '管理员',
roles: ['SuperAdmin'],
['access' + 'Token']: '<access-token>',
wordpressAuth: null,
wordpressAvailable: true,
wordpressError: null,
};
}
function adminUserExample() {
return {
id: '1000000000000000001',
username: 'admin',
realName: '管理员',
status: 1,
};
}
function adminMenuExample() {
return {
id: '1000000000000000001',
name: 'QqBot',
path: '/qqbot',
component: 'LAYOUT',
meta: {
title: 'QQBot',
icon: 'lucide:bot',
},
children: [],
};
}
function adminDeptExample() {
return {
id: '1000000000000000001',
name: 'KT 项目组',
parentId: '0',
status: 1,
};
}
function adminRoleExample() {
return {
id: '1000000000000000001',
roleName: '超级管理员',
roleCode: 'SuperAdmin',
status: 1,
};
}
function componentExample() {
return {
id: '1000000000000000001',
name: 'KT 表格组件',
type: 'table',
image: 'http://127.0.0.1:9000/kt-template-online/components/table.png',
};
}
function qqbotAccountExample() {
return {
id: '1000000000000000001',
selfId: '1914728559',
nickname: 'Mirror',
status: 'online',
connectionRole: 'universal',
lastHeartbeatAt: '2026-06-02T12:00:00.000Z',
};
}
function qqbotCommandExample() {
return {
id: '1000000000000000001',
name: 'FF14 查价',
command: '/price',
pluginKey: 'ff14Market',
enabled: true,
};
}
function qqbotRuleExample() {
return {
id: '1000000000000000001',
name: '关键词回复',
matchType: 'keyword',
keyword: 'test',
replyContent: '测试',
enabled: true,
};
}
function qqbotConversationExample() {
return {
id: '1000000000000000001',
selfId: '1914728559',
targetType: 'private',
targetId: '2354598417',
lastMessage: 'test',
};
}
function qqbotMessageExample() {
return {
id: '1000000000000000001',
selfId: '1914728559',
messageType: 'private',
direction: 'receive',
userId: '2354598417',
message: 'test',
};
}
function qqbotPermissionExample() {
return {
id: '1000000000000000001',
selfId: '1914728559',
targetType: 'qq',
targetId: '2354598417',
userId: '',
preciseUser: false,
enabled: true,
};
}
function qqbotPluginExample() {
return {
key: 'ff14Market',
name: 'FF14 查价',
triggerMode: 'command',
description: '查询 FF14 市场价格',
};
}
function qqbotSendLogExample() {
return {
id: '1000000000000000001',
selfId: '1914728559',
targetType: 'private',
targetId: '2354598417',
message: '测试',
status: 'success',
};
}
function qqbotScanExample() {
return {
sessionId: 'KT_SCAN_20260602120000',
qrcode: 'data:image/png;base64,MOCK_QRCODE',
status: 'waiting',
expireAt: '2026-06-02T12:05:00.000Z',
};
}
function dashboardExample() {
return {
accountCount: 1,
onlineAccountCount: 1,
todayMessageCount: 10,
todaySendCount: 3,
};
}
function permissionConfigExample() {
return {
mode: 'blocklist',
enabled: true,
};
}
function pluginHealthExample() {
return {
key: 'ff14Market',
name: 'FF14 查价',
available: true,
message: '插件可用',
};
}
function wordpressArticleExample() {
return {
id: 1,
title: '飞牛 NAS Docker、Jenkins 与 k3d/K8s 一体化技术方案',
status: 'publish',
categories: [1],
tags: [1],
};
}
function wordpressTaxonomyExample(name: string) {
return {
id: 1,
name,
slug: name.toLowerCase(),
count: 1,
};
}
function minioObjectExample() {
return {
name: 'uploads/demo.png',
size: 2048,
etag: '9b2cf535f27731c974343645a3985328',
lastModified: '2026-06-02T12:00:00.000Z',
};
}
function minioUploadExample() {
return {
bucketName: 'kt-template-online',
objectName: 'uploads/demo.png',
etag: '9b2cf535f27731c974343645a3985328',
size: 2048,
mimeType: 'image/png',
url: 'http://127.0.0.1:9000/kt-template-online/uploads/demo.png',
};
}

View File

@ -5,6 +5,7 @@ import type { OpenAPIObject } from '@nestjs/swagger';
import { urlencoded, json } from 'express'; import { urlencoded, json } from 'express';
import { knife4jSetup } from 'nestjs-knife4j-plus'; import { knife4jSetup } from 'nestjs-knife4j-plus';
import type { Service } from 'nestjs-knife4j-plus'; import type { Service } from 'nestjs-knife4j-plus';
import { applySwaggerResponseExamples } from './common';
type SwaggerPathMatcher = (path: string) => boolean; type SwaggerPathMatcher = (path: string) => boolean;
@ -61,7 +62,9 @@ async function bootstrap() {
.setTitle('KT-Template API') .setTitle('KT-Template API')
.setVersion('1.0') .setVersion('1.0')
.build(); .build();
const document = SwaggerModule.createDocument(app, options); const document = applySwaggerResponseExamples(
SwaggerModule.createDocument(app, options),
);
SwaggerModule.setup('api', app, document); SwaggerModule.setup('api', app, document);
const services: Service[] = [ const services: Service[] = [
{ {