Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ REDIS_PORT="6379"
REDIS_DB="0"
# REDIS_PASSWORD="xxx"o # 若 Redis 设置了密码,请取消注释并填写密码

COOKIE_MAX_AGE="604800" # 7 * 24 * 60 * 60
COOKIE_REMEMBER_ME_MAX_AGE="2592000" # 30 * 24 * 60 * 60
# COOKIE_DOMAIN=".xxx.com" # 若需跨子域名使用 Cookie,请取消注释并设置为你的主域名

R2_ACCESS_KEY_ID="xxx"
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"dev": "nest start --watch",
"build": "tsdown",
"typecheck": "tsc --noEmit --incremental",
"start": "node dist/main.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
Expand Down Expand Up @@ -45,7 +46,9 @@
"fastify": "catalog:backend",
"ioredis": "catalog:backend",
"joi": "catalog:backend",
"js-yaml": "catalog:backend",
"json-schema-faker": "catalog:backend",
"openapi-types": "catalog:backend",
"pg": "catalog:backend",
"reflect-metadata": "catalog:backend",
"resend": "catalog:backend",
Expand All @@ -62,6 +65,7 @@
"@swc/core": "catalog:backend",
"@types/bcrypt": "catalog:backend",
"@types/express": "catalog:backend",
"@types/js-yaml": "catalog:backend",
"@types/pg": "catalog:backend",
"prisma": "catalog:backend",
"source-map-support": "catalog:backend",
Expand Down
11 changes: 7 additions & 4 deletions apps/backend/src/api/api.module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HttpModule } from '@nestjs/axios'
import { Module } from '@nestjs/common'
import { AuthModule } from '@/auth/auth.module'
import { PrismaModule } from '@/infra/prisma/prisma.module'
Expand All @@ -9,14 +10,16 @@ import { ApiController } from './api.controller'
import { ApiService } from './api.service'
import { GroupController } from './group.controller'
import { GroupService } from './group.service'
import { ImportController } from './import.controller'
import { ImportService } from './import.service'
import { ApiUtilsService } from './utils.service'
import { VersionController } from './version.controller'
import { VersionService } from './version.service'

@Module({
imports: [PrismaModule, AuthModule, PermissionModule, ProjectModule, UtilModule, SystemConfigModule],
controllers: [ApiController, GroupController, VersionController],
providers: [ApiService, ApiUtilsService, GroupService, VersionService],
exports: [ApiService, ApiUtilsService, GroupService, VersionService],
imports: [PrismaModule, AuthModule, PermissionModule, ProjectModule, UtilModule, SystemConfigModule, HttpModule],
controllers: [ApiController, GroupController, VersionController, ImportController],
providers: [ApiService, ApiUtilsService, GroupService, VersionService, ImportService],
exports: [ApiService, ApiUtilsService, GroupService, VersionService, ImportService],
})
export class ApiModule {}
81 changes: 81 additions & 0 deletions apps/backend/src/api/dto/import/import-openapi.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
IsUrl,
registerDecorator,
ValidateIf,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator'

/** 冲突处理策略 */
export enum ConflictStrategy {
/** 跳过冲突的 API */
SKIP = 'skip',
/** 覆盖现有 API */
OVERWRITE = 'overwrite',
/** 重命名新 API */
RENAME = 'rename',
}

/** 自定义验证器,确保 content 或 url 至少提供一个 */
@ValidatorConstraint({ name: 'atLeastOneField', async: false })
class AtLeastOneFieldConstraint implements ValidatorConstraintInterface {
validate(_value: unknown, args: ValidationArguments) {
const object = args.object as Record<string, unknown>
const fields = args.constraints as string[]
return fields.some(field => object[field] !== undefined && object[field] !== null && object[field] !== '')
}

defaultMessage(args: ValidationArguments) {
const fields = args.constraints as string[]
return `至少需要提供以下字段之一: ${fields.join(', ')}`
}
}

function AtLeastOneOf(fields: string[], validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: fields,
validator: AtLeastOneFieldConstraint,
})
}
}

/** 解析 OpenAPI 文档请求 DTO(URL 或内容方式) */
export class ParseOpenapiReqDto {
@IsOptional()
@IsString({ message: 'OpenAPI 文档内容必须是字符串' })
@AtLeastOneOf(['content', 'url'], { message: '请提供 OpenAPI 文档内容或 URL(也可上传文件)' })
content?: string

@IsOptional()
@IsString({ message: 'OpenAPI 文档 URL 必须是字符串' })
@ValidateIf(o => o.url !== undefined && o.url !== '')
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] }, { message: 'URL 格式不正确,必须以 http:// 或 https:// 开头' })
url?: string
}

/** 执行导入请求 DTO */
export class ExecuteImportReqDto {
@IsString({ message: 'OpenAPI 文档内容必须是字符串' })
content: string

@IsEnum(ConflictStrategy, { message: '冲突处理策略必须是有效的枚举值' })
conflictStrategy: ConflictStrategy

@IsOptional()
@IsString({ message: '目标分组ID必须是字符串' })
targetGroupId?: string

@IsOptional()
@IsBoolean({ message: '是否自动创建分组必须是布尔值' })
createMissingGroups?: boolean
}
97 changes: 97 additions & 0 deletions apps/backend/src/api/dto/import/import-preview.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { APIMethod } from 'prisma/generated/client'

/** 解析后的 API 信息预览 */
export interface ParsedApiPreview {
/** 请求路径 */
path: string
/** 请求方法 */
method: APIMethod
/** API 名称 */
name: string
/** 所属分组名称(基于 OpenAPI tags) */
groupName: string
/** 描述 */
description?: string
/** 是否与现有 API 冲突 */
hasConflict: boolean
/** 冲突的现有 API ID */
conflictApiId?: string
}

/** 解析后的分组信息预览 */
export interface ParsedGroupPreview {
/** 分组名称 */
name: string
/** 分组描述 */
description?: string
/** 该分组下的 API 数量 */
apiCount: number
}

/** 统计信息 */
export interface ImportStats {
/** 总 API 数量 */
totalApis: number
/** 新 API 数量 */
newApis: number
/** 冲突 API 数量 */
conflictApis: number
}

/** OpenAPI 文档基本信息 */
export interface OpenApiInfo {
/** 文档标题 */
title: string
/** 文档版本 */
version: string
/** 文档描述 */
description?: string
}

/** 解析预览响应 DTO */
export class ImportPreviewResDto {
/** OpenAPI 文档基本信息 */
info: OpenApiInfo
/** 分组列表 */
groups: ParsedGroupPreview[]
/** API 列表 */
apis: ParsedApiPreview[]
/** 统计信息 */
stats: ImportStats
/** 原始 OpenAPI 文档内容(用于后续导入) */
content: string
}

/** 导入结果中的单个 API 信息 */
export interface ImportedApiResult {
/** API 名称 */
name: string
/** 请求路径 */
path: string
/** 请求方法 */
method: APIMethod
/** 导入状态 */
status: 'created' | 'updated' | 'skipped' | 'failed'
/** 失败原因 */
error?: string
/** 创建的 API ID */
apiId?: string
}

/** 导入执行结果响应 DTO */
export class ImportResultResDto {
/** 是否成功 */
success: boolean
/** 导入的 API 结果列表 */
results: ImportedApiResult[]
/** 成功创建数量 */
createdCount: number
/** 成功更新数量 */
updatedCount: number
/** 跳过数量 */
skippedCount: number
/** 失败数量 */
failedCount: number
/** 创建的分组名称列表 */
createdGroups: string[]
}
2 changes: 2 additions & 0 deletions apps/backend/src/api/dto/import/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './import-openapi.dto'
export * from './import-preview.dto'
1 change: 1 addition & 0 deletions apps/backend/src/api/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './clone-api.dto'
export * from './create-api.dto'
export * from './get-apis.dto'
export * from './group'
export * from './import'
export * from './operation-log.dto'
export * from './sort-items.dto'
export * from './update-api.dto'
Expand Down
80 changes: 80 additions & 0 deletions apps/backend/src/api/import.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { FastifyRequest } from 'fastify'
import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common'
import { ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'
import { ProjectPermissions, RequireProjectMember } from '@/common/decorators/permissions.decorator'
import { ReqUser } from '@/common/decorators/req-user.decorator'
import { ResMsg } from '@/common/decorators/res-msg.decorator'
import { HanaException } from '@/common/exceptions/hana.exception'
import { AuthGuard } from '@/common/guards/auth.guard'
import { PermissionsGuard } from '@/common/guards/permissions.guard'
import { ExecuteImportReqDto, ParseOpenapiReqDto } from './dto'
import { ImportService } from './import.service'

/** 允许的文件扩展名 */
const ALLOWED_EXTENSIONS = new Set(['.json', '.yaml', '.yml'])

@ApiTags('OpenAPI 导入')
@Controller('api/:projectId/import')
@UseGuards(AuthGuard, PermissionsGuard)
export class ImportController {
constructor(private readonly importService: ImportService) {}

@Post('openapi/parse')
@RequireProjectMember()
@ProjectPermissions(['api:create'], 'projectId')
@ResMsg('OpenAPI 文档解析成功')
@ApiOperation({ summary: '解析 OpenAPI 文档' })
@ApiParam({ name: 'projectId', description: '项目ID' })
@ApiConsumes('multipart/form-data', 'application/json')
@ApiBody({
description: '上传 OpenAPI 文件或提供内容/URL',
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary', description: 'OpenAPI 文件 (JSON/YAML)' },
content: { type: 'string', description: 'OpenAPI 文档内容' },
url: { type: 'string', description: 'OpenAPI 文档 URL' },
},
},
})
async parseOpenapi(
@Param('projectId') projectId: string,
@Req() request: FastifyRequest,
@Body() dto: ParseOpenapiReqDto,
) {
let fileContent: string | undefined

// 如果是上传文件,验证并读取内容
if (request.isMultipart()) {
const file = await request.file()
if (file) {
// 验证文件类型
const ext = file.filename.toLowerCase().slice(file.filename.lastIndexOf('.'))
if (!ALLOWED_EXTENSIONS.has(ext)) {
throw new HanaException('OPENAPI_INVALID_FORMAT', {
message: `不支持的文件类型: ${ext},请上传 .json, .yaml 或 .yml 文件`,
})
}

const buffer = await file.toBuffer()
fileContent = buffer.toString('utf-8')
}
}

return this.importService.parseOpenapi(dto, projectId, fileContent)
}

@Post('openapi/execute')
@RequireProjectMember()
@ProjectPermissions(['api:create'], 'projectId')
@ResMsg('OpenAPI 文档导入成功')
@ApiOperation({ summary: '执行 OpenAPI 导入' })
@ApiParam({ name: 'projectId', description: '项目ID' })
async executeImport(
@Param('projectId') projectId: string,
@Body() dto: ExecuteImportReqDto,
@ReqUser('id') userId: string,
) {
return this.importService.executeImport(dto, projectId, userId)
}
}
Loading