feat: 补全接口文档注释, 实现minio接口

This commit is contained in:
sunlei 2026-05-13 13:52:46 +08:00
parent 9240968c82
commit f236d86ff3
16 changed files with 1207 additions and 511 deletions

5
.env
View File

@ -5,7 +5,8 @@ DB_PASSWORD=qwqvqaqeq2333KT
DB_DATABASE=shy_template
DB_SYNC=true
MINIO_ENDPOINT=192.168.1.206
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online

View File

@ -8,4 +8,5 @@ DB_SYNC=false
MINIO_ENDPOINT=192.168.1.206
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online

295
API.md Normal file
View File

@ -0,0 +1,295 @@
# KT Template Online API
后端服务默认监听 `48085`Swagger 地址为 `/api`OpenAPI JSON 地址为 `/api-json`。接口除文件下载外,统一返回 `{ code, msg, data }`
## 通用响应
```json
{
"code": 200,
"msg": "操作成功",
"data": {}
}
```
失败时仍使用相同结构,常见为:
```json
{
"code": 400,
"msg": "操作失败",
"data": null
}
```
## Root
### GET `/`
重定向到 Swagger 文档页 `/api#/`HTTP 状态码为 `301`
## 数据结构
### Component
| 字段 | 类型 | 说明 |
| ---------------- | ------- | ---------------------------- |
| id | string | 组件 ID新增时由后端生成 |
| name | string | 组件名称 |
| type | number | 一级类型,`1` 图表,`2` 组件 |
| componentType | number | 二级类型 |
| typeMsg | string | 一级类型文本,列表接口返回 |
| componentTypeMsg | string | 二级类型文本,列表接口返回 |
| image | string | 封面图 |
| template | string | playground 序列化模板内容 |
| createTime | string | 创建时间 |
| updateTime | string | 更新时间 |
| is_deleted | boolean | 逻辑删除标记 |
### 字典
`COMPONENT_TYPE`
| label | value |
| ----- | ----- |
| 图表 | 1 |
| 组件 | 2 |
`CHART``未分类(-1)`、`折线图(1)`、`柱状图(2)`、`饼图(3)`、`散点图(4)`、`地图(5)`、`K线图(6)`、`雷达图(7)`、`盒须图(8)`、`热力图(9)`、`关系图(10)`、`路径图(11)`、`树图(12)`、`矩树图(13)`、`旭日图(14)`、`平行坐标系(15)`、`桑基图(16)`、`漏斗图(17)`、`仪表盘(18)`、`象形图(19)`、`河流图(20)`、`水球(21)`、`词云(22)`。
`COMPONENT``未分类(-1)`、`表格(23)`、`表单(24)`、`容器(25)`。
## Component
### GET `/component/allList`
获取全部组件。
响应示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": [
{
"id": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9",
"name": "基础折线图",
"type": 1,
"componentType": 1,
"typeMsg": "图表",
"componentTypeMsg": "折线图",
"image": "",
"template": "%7B%22version%22%3A%221.0%22%7D",
"createTime": "2026-05-13T02:30:00.000Z",
"updateTime": "2026-05-13T02:30:00.000Z",
"is_deleted": false
}
]
}
```
### GET `/component/list`
分页获取组件列表。
Query
| 参数 | 类型 | 必填 | 说明 |
| ------------- | ------ | ---- | ------------ |
| pageNo | number | 是 | 页码 |
| pageSize | number | 是 | 每页条数 |
| name | string | 否 | 名称模糊搜索 |
| type | number | 否 | 一级类型 |
| componentType | number | 否 | 二级类型 |
响应 `data``{ list: Component[], total: number }`。
### GET `/component/detail`
获取组件详情。
Query
| 参数 | 类型 | 必填 | 说明 |
| ---- | ------ | ---- | ------- |
| id | string | 是 | 组件 ID |
响应 `data``Component`。
### POST `/component/save`
新增组件。`SaveMiddleware` 会删除 body 中的 `id`,新增时不需要传 `id`
Body
```json
{
"name": "基础折线图",
"type": 1,
"componentType": 1,
"image": "",
"template": "%7B%22version%22%3A%221.0%22%7D"
}
```
响应示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9"
}
```
### POST `/component/update`
编辑组件。
Body
```json
{
"id": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9",
"name": "基础折线图",
"type": 1,
"componentType": 1,
"image": "",
"template": "%7B%22version%22%3A%221.0%22%7D"
}
```
响应 `data``true` 表示更新成功。
### POST `/component/remove`
逻辑删除组件。
Query
| 参数 | 类型 | 必填 | 说明 |
| ---- | ------ | ---- | ------- |
| id | string | 是 | 组件 ID |
响应 `data``true` 表示删除成功。
## Dict
### GET `/dict/getDictByKey`
根据字典 key 获取字典。
Query
| 参数 | 类型 | 必填 | 可选值 |
| ------- | ------ | ---- | -------------------------------------- |
| dictKey | string | 是 | `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
响应示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": [
{
"label": "图表",
"value": 1
},
{
"label": "组件",
"value": 2
}
]
}
```
### GET `/dict/getComponentDictByType`
根据一级类型获取二级类型字典。
Query
| 参数 | 类型 | 必填 | 说明 |
| ---- | ------ | ---- | ------------------ |
| type | number | 是 | `1` 图表,`2` 组件 |
响应 `data``Array<{ label: string; value: number }>`。
## MinIO
### GET `/minio/check`
检查 MinIO 连接和 bucket 状态。
Query`bucketName?: string`
响应 `data``{ bucketName: string; exists: boolean }`。
### POST `/minio/bucket`
创建 bucket已存在时跳过。
Query`bucketName?: string`
响应 `data`bucket 名称。
### POST `/minio/upload`
上传文件,请求类型为 `multipart/form-data`
Body
| 参数 | 类型 | 必填 | 说明 |
| ---------- | ------ | ---- | ---------------------- |
| file | File | 是 | 文件 |
| bucketName | string | 否 | bucket 名称 |
| objectName | string | 否 | 对象名,不传时自动生成 |
响应示例:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"bucketName": "kt-template-online",
"objectName": "uploads/1715580000000-a1b2c3-demo.png",
"etag": "9b2cf535f27731c974343645a3985328",
"size": 2048,
"mimeType": "image/png",
"url": "http://127.0.0.1:9000/kt-template-online/uploads/demo.png"
}
}
```
### GET `/minio/list`
获取文件列表。
Query`bucketName?: string`、`prefix?: string`、`recursive?: string`
响应 `data`MinIO 对象数组,常见字段为 `name`、`size`、`etag`、`lastModified`。
### GET `/minio/url`
获取文件临时访问地址。
Query`objectName: string`、`bucketName?: string`、`expiry?: string`
响应 `data`:临时访问 URL。
### GET `/minio/download`
下载文件,直接返回文件流。
Query`objectName: string`、`bucketName?: string`
### DELETE `/minio/remove`
删除文件。
Query`objectName: string`、`bucketName?: string`
响应 `data``true` 表示删除成功。

View File

@ -1,21 +1,24 @@
{
"name": "kt-template-online-api",
"version": "0.0.1",
"description": "",
"description": "kt-template-online server API",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "nest build && NODE_ENV=prod node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"start:prod": "nest build && cross-env NODE_ENV=prod node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:ci": "jest --runInBand --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
@ -23,29 +26,14 @@
"@nestjs/common": "^9.4.3",
"@nestjs/config": "^2.3.4",
"@nestjs/core": "^9.4.3",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.4.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^9.0.1",
"@types/dotenv": "^8.2.3",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@types/multer": "^1.4.13",
"cross-env": "^7.0.3",
"dotenv": "^16.6.1",
"express-session": "^1.19.0",
"lodash": "^4.18.1",
"minio": "^8.0.7",
"mssql": "^9.3.2",
"multer": "1.4.5-lts.1",
"lodash": "^4.17.21",
"mysql2": "^3.22.3",
"nestjs-knife4j-plus": "^1.0.7",
"nestjs-knife4j-plus": "^1.0.8",
"nestjs-minio-client": "^2.2.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.2",
"svg-captcha": "^1.4.0",
@ -56,11 +44,9 @@
"@nestjs/schematics": "^9.2.0",
"@nestjs/testing": "^9.4.3",
"@types/express": "^4.17.25",
"@types/express-session": "^1.19.0",
"@types/jest": "29.2.4",
"@types/lodash": "^4.17.24",
"@types/node": "18.11.18",
"@types/passport-jwt": "^3.0.13",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^2.0.16",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
@ -95,4 +81,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { Controller, Get, Redirect } from '@nestjs/common';
import { ApiMovedPermanentlyResponse, ApiOperation } from '@nestjs/swagger';
import { AppService } from './app.service';
@Controller()
@ -7,6 +8,10 @@ export class AppController {
@Get()
@Redirect('/api#/', 301)
@ApiOperation({ summary: '重定向到Swagger文档' })
@ApiMovedPermanentlyResponse({
description: '重定向到 /api#/',
})
getHome() {
return { url: '/api#/' };
}

View File

@ -6,11 +6,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { MinioModule } from 'nestjs-minio-client';
import { ComponentModule } from './component/component.module';
import { DictModule } from './dict/dict.module';
import { MinioClientModule } from './minio/minio.module';
import { SaveMiddleware } from './middleware/save.middleware';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env${
process.env.NODE_ENV ? `.${process.env.NODE_ENV}` : ''
}`,
@ -47,6 +49,7 @@ import { SaveMiddleware } from './middleware/save.middleware';
}),
ComponentModule,
DictModule,
MinioClientModule,
],
providers: [AppService, ConfigService],
})

View File

@ -0,0 +1,181 @@
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger';
type SwaggerSchema = Record<string, any>;
type ApiResponseOptions = {
description?: string;
schema?: SwaggerSchema;
example: any;
};
const primitiveTypeMap = {
string: String,
number: Number,
boolean: Boolean,
};
const setClassName = (target: Type<any>, name: string) => {
Object.defineProperty(target, 'name', {
value: name,
});
return target;
};
export class PaginatedDto<TData> {
@ApiProperty()
total: number;
@ApiProperty({
type: Array,
})
list: TData[];
}
export class ApiResponseDto<TData> {
@ApiProperty({
example: 200,
})
code: number;
@ApiProperty({
example: '操作成功',
})
msg: string;
@ApiProperty()
data: TData;
}
const getResponseExample = (example: any) => ({
code: 200,
msg: '操作成功',
data: example,
});
export const ApiSuccessResponse = ({
description = '操作成功',
schema,
example,
}: ApiResponseOptions) => {
const primitiveType = primitiveTypeMap[schema?.type] || Object;
class ApiSuccessResponseDto extends ApiResponseDto<any> {
@ApiProperty({
type: primitiveType,
description: schema?.description,
})
declare data: any;
}
setClassName(ApiSuccessResponseDto, `ApiResponseOf${primitiveType.name}`);
return applyDecorators(
ApiExtraModels(ApiSuccessResponseDto),
ApiOkResponse({
description,
type: ApiSuccessResponseDto,
example: getResponseExample(example),
}),
);
};
export const ApiModelResponse = <TModel extends Type<any>>(
model: TModel,
example: any,
description?: string,
) => {
class ApiModelResponseDto extends ApiResponseDto<TModel> {
@ApiProperty({
type: model,
})
declare data: TModel;
}
setClassName(ApiModelResponseDto, `ApiResponseOf${model.name}`);
return applyDecorators(
ApiExtraModels(ApiModelResponseDto, model),
ApiOkResponse({
description: description || '操作成功',
type: ApiModelResponseDto,
example: getResponseExample(example),
}),
);
};
export const ApiArrayResponse = <TModel extends Type<any>>(
model: TModel,
example: any[],
description?: string,
) => {
class ApiArrayResponseDto extends ApiResponseDto<TModel[]> {
@ApiProperty({
type: [model],
})
declare data: TModel[];
}
setClassName(ApiArrayResponseDto, `ApiResponseOf${model.name}Array`);
return applyDecorators(
ApiExtraModels(ApiArrayResponseDto, model),
ApiOkResponse({
description: description || '操作成功',
type: ApiArrayResponseDto,
example: getResponseExample(example),
}),
);
};
export const ApiPageResponse = <TModel extends Type<any>>(
model: TModel,
example: any[],
total = 1,
description?: string,
) => {
class PageResponseDto extends PaginatedDto<TModel> {
@ApiProperty({
type: [model],
})
declare list: TModel[];
}
class ApiPageResponseDto extends ApiResponseDto<PageResponseDto> {
@ApiProperty({
type: PageResponseDto,
})
declare data: PageResponseDto;
}
setClassName(PageResponseDto, `PaginatedResponseOf${model.name}`);
setClassName(ApiPageResponseDto, `ApiResponseOfPaginated${model.name}`);
return applyDecorators(
ApiExtraModels(ApiPageResponseDto, PageResponseDto, PaginatedDto, model),
ApiOkResponse({
description: description || '操作成功',
type: ApiPageResponseDto,
example: getResponseExample({
list: example,
total,
}),
}),
);
};
export const ApiFileDownloadResponse = (description = '文件下载成功') =>
applyDecorators(
ApiOkResponse({
description,
content: {
'application/octet-stream': {
schema: {
type: 'string',
format: 'binary',
},
},
},
}),
);

View File

@ -10,7 +10,6 @@ import {
} from '@nestjs/common';
import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiQuery,
@ -20,8 +19,28 @@ import {
import { ToolsService } from '@/utils/tool.service';
import { ComponentService } from './component.service';
import { Component } from './component.entity';
import { PaginatedDto } from '@/utils/constant';
import { ComponentDto } from './component.dto';
import {
PaginatedDto,
ApiArrayResponse,
ApiModelResponse,
ApiPageResponse,
ApiSuccessResponse,
} from '@/common/swagger-response';
const componentExample = {
id: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9',
name: '基础折线图',
type: 1,
componentType: 1,
typeMsg: '图表',
componentTypeMsg: '折线图',
image: '',
template: '%7B%22version%22%3A%221.0%22%7D',
createTime: '2026-05-13T02:30:00.000Z',
updateTime: '2026-05-13T02:30:00.000Z',
is_deleted: false,
};
class CompPageDto
extends PartialType(Component)
@ -39,16 +58,9 @@ class CompPageDto
pageSize: number;
}
class CompPageResDto extends PaginatedDto {
@ApiProperty({
type: [ComponentDto],
})
list: ComponentDto[];
}
@Controller('component')
@ApiTags('component')
@ApiExtraModels(PaginatedDto, ComponentDto)
@ApiExtraModels(PaginatedDto)
export class ComponentController {
constructor(
private readonly toolsService: ToolsService,
@ -57,7 +69,7 @@ export class ComponentController {
@Get('allList')
@ApiOperation({ summary: '获取组件列表' })
@ApiOkResponse({ type: [ComponentDto] })
@ApiArrayResponse(ComponentDto, [componentExample])
async getAllList(@Res() res) {
const list = await this.componentService.all();
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
@ -66,13 +78,11 @@ export class ComponentController {
@Get('list')
@ApiOperation({ summary: '获取组件列表分页' })
@ApiQuery({ type: [CompPageDto] })
@ApiOkResponse({
type: CompPageResDto,
})
@ApiPageResponse(ComponentDto, [componentExample], 1)
async getList(
@Res() res,
@Query() { pageNo, pageSize, ...args }: PageParams<ComponentDto>,
): Promise<CompPageResDto> {
): Promise<PaginatedDto<ComponentDto>> {
const list = await this.componentService.page({
pageNo,
pageSize,
@ -85,6 +95,13 @@ export class ComponentController {
@Post('save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '保存组件' })
@ApiSuccessResponse({
schema: {
type: 'string',
description: '新增组件ID',
},
example: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9',
})
async save(@Res() res, @Body() component: Component) {
const save = await this.componentService.save(component);
@ -101,6 +118,12 @@ export class ComponentController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除组件' })
@ApiQuery({ name: 'id', type: String })
@ApiSuccessResponse({
schema: {
type: 'boolean',
},
example: true,
})
async remove(@Res() res, @Query('id') id) {
const remove = await this.componentService.remove(id);
@ -118,6 +141,12 @@ export class ComponentController {
@Post('update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑组件' })
@ApiSuccessResponse({
schema: {
type: 'boolean',
},
example: true,
})
async update(@Res() res, @Body() component: Component) {
const update = await this.componentService.update(component);
@ -135,7 +164,7 @@ export class ComponentController {
@Get('detail')
@ApiOperation({ summary: '组件详情' })
@ApiQuery({ name: 'id', type: String })
@ApiOkResponse({ type: ComponentDto })
@ApiModelResponse(ComponentDto, componentExample)
async detail(@Res() res, @Query('id') id) {
const detail = await this.componentService.find(id);

View File

@ -1,8 +1,39 @@
import { Controller, Get, HttpStatus, ParseIntPipe, Query, Res } from '@nestjs/common';
import {
Controller,
Get,
HttpStatus,
ParseIntPipe,
Query,
Res,
} from '@nestjs/common';
import { ToolsService } from '@/utils/tool.service';
import { DictService } from './dict.service';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ComponentTypeEnum, DictKeyEnum, DictKeyType } from '@/utils/constant';
import { ApiArrayResponse } from '@/common/swagger-response';
import { DictDto } from './dict.dto';
const componentTypeDictExample = [
{
label: '图表',
value: 1,
},
{
label: '组件',
value: 2,
},
];
const chartDictExample = [
{
label: '未分类',
value: -1,
},
{
label: '折线图',
value: 1,
},
];
@ApiTags('dict')
@Controller('dict')
@ -14,31 +45,21 @@ export class DictController {
@ApiOperation({ summary: '根据key获取字典' })
@ApiQuery({ name: 'dictKey', enum: DictKeyEnum })
@ApiArrayResponse(DictDto, componentTypeDictExample)
@Get('getDictByKey')
async getDictByKey(@Res() res, @Query('dictKey') dictKey: DictKeyType) {
const dict = this.toolsService.getDictByKey(dictKey)
const dict = this.toolsService.getDictByKey(dictKey);
return res.send(
this.toolsService.res(
HttpStatus.OK,
'操作成功',
dict,
),
);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', dict));
}
@ApiOperation({ summary: '根据组件类型获取组件字典' })
@ApiQuery({ name: 'type', enum: ComponentTypeEnum })
@ApiArrayResponse(DictDto, chartDictExample)
@Get('getComponentDictByType')
async getComponentDictByType(@Res() res, @Query('type', ParseIntPipe) type) {
const dict = await this.dictService.getComponentDictByType(type)
const dict = await this.dictService.getComponentDictByType(type);
return res.send(
this.toolsService.res(
HttpStatus.OK,
'操作成功',
dict,
),
);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', dict));
}
}

13
src/dict/dict.dto.ts Normal file
View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
export class DictDto {
@ApiProperty({
example: '图表',
})
label: string;
@ApiProperty({
example: 1,
})
value: number;
}

View File

@ -1,16 +1,226 @@
import {
Controller,
Body,
Delete,
Get,
HttpStatus,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ToolsService } from 'src/utils/tool.service';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBody,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { Response } from 'express';
import { ToolsService } from '@/utils/tool.service';
import { MinioClientService } from './minio.service';
import type { MinioUploadFile } from './minio.service';
import {
ApiFileDownloadResponse,
ApiArrayResponse,
ApiModelResponse,
ApiSuccessResponse,
} from '@/common/swagger-response';
import {
MinioBucketStatusDto,
MinioObjectDto,
MinioUploadResultDto,
} from './minio.dto';
@Controller('minio')
@ApiTags('minio')
export class MinioClientController {
constructor(
private readonly toolsService: ToolsService,
private readonly minioClientService: MinioClientService,
) {} //注入服务
@Get('check')
@ApiOperation({ summary: '检查MinIO连接和Bucket状态' })
@ApiQuery({ name: 'bucketName', required: false })
@ApiModelResponse(MinioBucketStatusDto, {
bucketName: 'kt-template-online',
exists: true,
})
async check(@Res() res, @Query('bucketName') bucketName?: string) {
const result = await this.minioClientService.checkConnection(bucketName);
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('bucket')
@ApiOperation({ summary: '创建Bucket存在则跳过' })
@ApiQuery({ name: 'bucketName', required: false })
@ApiSuccessResponse({
schema: {
type: 'string',
description: 'Bucket名称',
},
example: 'kt-template-online',
})
async createBucket(@Res() res, @Query('bucketName') bucketName?: string) {
const result = await this.minioClientService.ensureBucket(bucketName);
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: '上传文件到MinIO' })
@ApiConsumes('multipart/form-data')
@ApiModelResponse(MinioUploadResultDto, {
bucketName: 'kt-template-online',
objectName: 'uploads/1715580000000-a1b2c3-demo.png',
etag: '9b2cf535f27731c974343645a3985328',
size: 2048,
mimeType: 'image/png',
url: 'http://127.0.0.1:9000/kt-template-online/uploads/demo.png',
})
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
bucketName: {
type: 'string',
},
objectName: {
type: 'string',
},
},
required: ['file'],
},
})
async upload(
@Res() res,
@UploadedFile() file: MinioUploadFile,
@Body('bucketName') bucketName?: string,
@Body('objectName') objectName?: string,
) {
const result = await this.minioClientService.uploadObject({
bucketName,
objectName,
file,
});
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Get('list')
@ApiOperation({ summary: '获取MinIO文件列表' })
@ApiQuery({ name: 'bucketName', required: false })
@ApiQuery({ name: 'prefix', required: false })
@ApiQuery({ name: 'recursive', required: false })
@ApiArrayResponse(MinioObjectDto, [
{
name: 'uploads/demo.png',
size: 2048,
etag: '9b2cf535f27731c974343645a3985328',
lastModified: '2026-05-13T02:30:00.000Z',
},
])
async list(
@Res() res,
@Query('bucketName') bucketName?: string,
@Query('prefix') prefix?: string,
@Query('recursive') recursive?: string,
) {
const result = await this.minioClientService.listObjects({
bucketName,
prefix,
recursive: recursive !== 'false',
});
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Get('url')
@ApiOperation({ summary: '获取文件临时访问地址' })
@ApiQuery({ name: 'objectName' })
@ApiQuery({ name: 'bucketName', required: false })
@ApiQuery({ name: 'expiry', required: false })
@ApiSuccessResponse({
schema: {
type: 'string',
description: '文件临时访问地址',
},
example:
'http://127.0.0.1:9000/kt-template-online/uploads/demo.png?X-Amz-Algorithm=AWS4-HMAC-SHA256',
})
async getUrl(
@Res() res,
@Query('objectName') objectName: string,
@Query('bucketName') bucketName?: string,
@Query('expiry') expiry?: string,
) {
const result = await this.minioClientService.getPresignedUrl(
objectName,
bucketName,
expiry ? Number(expiry) : undefined,
);
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Get('download')
@ApiOperation({ summary: '下载MinIO文件' })
@ApiQuery({ name: 'objectName' })
@ApiQuery({ name: 'bucketName', required: false })
@ApiFileDownloadResponse()
async download(
@Res() res: Response,
@Query('objectName') objectName: string,
@Query('bucketName') bucketName?: string,
) {
const { stream, stat } = await this.minioClientService.getObject(
objectName,
bucketName,
);
res.setHeader(
'Content-Type',
stat.metaData?.['content-type'] || 'application/octet-stream',
);
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(
objectName.split('/').pop(),
)}"`,
);
stream.pipe(res);
}
@Delete('remove')
@ApiOperation({ summary: '删除MinIO文件' })
@ApiQuery({ name: 'objectName' })
@ApiQuery({ name: 'bucketName', required: false })
@ApiSuccessResponse({
schema: {
type: 'boolean',
},
example: true,
})
async remove(
@Res() res,
@Query('objectName') objectName: string,
@Query('bucketName') bucketName?: string,
) {
const result = await this.minioClientService.removeObject(
objectName,
bucketName,
);
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
}

67
src/minio/minio.dto.ts Normal file
View File

@ -0,0 +1,67 @@
import { ApiProperty } from '@nestjs/swagger';
export class MinioBucketStatusDto {
@ApiProperty({
example: 'kt-template-online',
})
bucketName: string;
@ApiProperty({
example: true,
})
exists: boolean;
}
export class MinioUploadResultDto {
@ApiProperty({
example: 'kt-template-online',
})
bucketName: string;
@ApiProperty({
example: 'uploads/1715580000000-a1b2c3-demo.png',
})
objectName: string;
@ApiProperty({
example: '9b2cf535f27731c974343645a3985328',
})
etag: string;
@ApiProperty({
example: 2048,
})
size: number;
@ApiProperty({
example: 'image/png',
})
mimeType: string;
@ApiProperty({
example: 'http://127.0.0.1:9000/kt-template-online/uploads/demo.png',
})
url: string;
}
export class MinioObjectDto {
@ApiProperty({
example: 'uploads/demo.png',
})
name: string;
@ApiProperty({
example: 2048,
})
size: number;
@ApiProperty({
example: '9b2cf535f27731c974343645a3985328',
})
etag: string;
@ApiProperty({
example: '2026-05-13T02:30:00.000Z',
})
lastModified: string;
}

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MinioClientController } from './minio.controller';
import { MinioClientService } from './minio.service';
import { ToolsService } from 'src/utils/tool.service';
import { MinioModule } from 'nestjs-minio-client';
import { ToolsService } from '@/utils/tool.service';
@Module({
imports: [MinioModule],
imports: [ConfigModule],
controllers: [MinioClientController],
providers: [MinioClientService, ToolsService],
exports: [MinioClientService],

View File

@ -1,11 +1,179 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MinioService } from 'nestjs-minio-client';
import type { Readable } from 'stream';
export type MinioUploadFile = {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
};
type UploadObjectOptions = {
bucketName?: string;
objectName?: string;
file: MinioUploadFile;
};
type ListObjectOptions = {
bucketName?: string;
prefix?: string;
recursive?: boolean;
};
type MinioObjectResult = {
stream: Readable;
stat: {
size: number;
etag: string;
lastModified: Date;
metaData: Record<string, any>;
versionId?: string | null;
};
bucketName: string;
objectName: string;
};
import { ToolsService } from 'src/utils/tool.service';
@Injectable()
export class MinioClientService {
constructor(
private readonly toolsService: ToolsService,
private readonly minioService: MinioService,
private readonly configService: ConfigService,
) {}
private get client() {
return this.minioService.client;
}
getDefaultBucket(): string {
return this.configService.get('MINIO_BUCKET') || 'kt-template-online';
}
getBucketName(bucketName?: string): string {
return bucketName || this.getDefaultBucket();
}
async checkConnection(bucketName?: string) {
const targetBucket = this.getBucketName(bucketName);
const exists = await this.client.bucketExists(targetBucket);
return {
bucketName: targetBucket,
exists,
};
}
async ensureBucket(bucketName?: string): Promise<string> {
const targetBucket = this.getBucketName(bucketName);
const exists = await this.client.bucketExists(targetBucket);
if (!exists) {
await this.client.makeBucket(targetBucket, 'us-east-1');
}
return targetBucket;
}
async uploadObject({ bucketName, objectName, file }: UploadObjectOptions) {
if (!file) {
throw new BadRequestException('请选择要上传的文件');
}
const targetBucket = await this.ensureBucket(bucketName);
const targetObjectName = objectName || this.createObjectName(file.originalname);
const result = await this.client.putObject(
targetBucket,
targetObjectName,
file.buffer,
file.size,
{
'Content-Type': file.mimetype,
},
);
return {
bucketName: targetBucket,
objectName: targetObjectName,
etag: result.etag,
size: file.size,
mimeType: file.mimetype,
url: await this.getPresignedUrl(targetObjectName, targetBucket),
};
}
async listObjects({
bucketName,
prefix = '',
recursive = true,
}: ListObjectOptions) {
const targetBucket = this.getBucketName(bucketName);
const exists = await this.client.bucketExists(targetBucket);
if (!exists) {
return [];
}
return new Promise((resolve, reject) => {
const objects = [];
const stream = this.client.listObjectsV2(targetBucket, prefix, recursive);
stream.on('data', (object) => objects.push(object));
stream.on('error', reject);
stream.on('end', () => resolve(objects));
});
}
async getObject(
objectName: string,
bucketName?: string,
): Promise<MinioObjectResult> {
if (!objectName) {
throw new BadRequestException('objectName不能为空');
}
const targetBucket = this.getBucketName(bucketName);
const objectStat = await this.client.statObject(targetBucket, objectName);
const stream = await this.client.getObject(targetBucket, objectName);
return {
stream,
stat: objectStat,
bucketName: targetBucket,
objectName,
};
}
async getPresignedUrl(
objectName: string,
bucketName?: string,
expiry = 24 * 60 * 60,
): Promise<string> {
if (!objectName) {
throw new BadRequestException('objectName不能为空');
}
return this.client.presignedGetObject(
this.getBucketName(bucketName),
objectName,
expiry,
);
}
async removeObject(objectName: string, bucketName?: string): Promise<boolean> {
if (!objectName) {
throw new BadRequestException('objectName不能为空');
}
await this.client.removeObject(this.getBucketName(bucketName), objectName);
return true;
}
private createObjectName(originalName: string): string {
const safeName = originalName.replace(/[\\/]/g, '_');
const random = Math.random().toString(36).slice(2, 8);
return `uploads/${Date.now()}-${random}-${safeName}`;
}
}

View File

@ -1,10 +1,3 @@
import { ApiProperty } from '@nestjs/swagger';
export class PaginatedDto {
@ApiProperty()
total: number;
}
export enum ComponentTypeEnum {
CHART = 1,
COMPONENT = 2,