diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 1570873..f4e4804 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -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" diff --git a/apps/backend/package.json b/apps/backend/package.json index ebb4453..6644d68 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", @@ -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", @@ -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", diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 0287335..0b07d08 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -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' @@ -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 {} diff --git a/apps/backend/src/api/dto/import/import-openapi.dto.ts b/apps/backend/src/api/dto/import/import-openapi.dto.ts new file mode 100644 index 0000000..5bd849c --- /dev/null +++ b/apps/backend/src/api/dto/import/import-openapi.dto.ts @@ -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 + 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 +} diff --git a/apps/backend/src/api/dto/import/import-preview.dto.ts b/apps/backend/src/api/dto/import/import-preview.dto.ts new file mode 100644 index 0000000..170199d --- /dev/null +++ b/apps/backend/src/api/dto/import/import-preview.dto.ts @@ -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[] +} diff --git a/apps/backend/src/api/dto/import/index.ts b/apps/backend/src/api/dto/import/index.ts new file mode 100644 index 0000000..ce2006f --- /dev/null +++ b/apps/backend/src/api/dto/import/index.ts @@ -0,0 +1,2 @@ +export * from './import-openapi.dto' +export * from './import-preview.dto' diff --git a/apps/backend/src/api/dto/index.ts b/apps/backend/src/api/dto/index.ts index 2e45058..2325956 100644 --- a/apps/backend/src/api/dto/index.ts +++ b/apps/backend/src/api/dto/index.ts @@ -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' diff --git a/apps/backend/src/api/import.controller.ts b/apps/backend/src/api/import.controller.ts new file mode 100644 index 0000000..cc598af --- /dev/null +++ b/apps/backend/src/api/import.controller.ts @@ -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) + } +} diff --git a/apps/backend/src/api/import.service.ts b/apps/backend/src/api/import.service.ts new file mode 100644 index 0000000..ee4025f --- /dev/null +++ b/apps/backend/src/api/import.service.ts @@ -0,0 +1,1587 @@ +import type { OpenAPIV3 } from 'openapi-types' +import type { InputJsonValue, TransactionClient } from 'prisma/generated/internal/prismaNamespace' +import { Buffer } from 'node:buffer' +import { SystemConfigKey } from '@apiplayer/shared' +import { HttpService } from '@nestjs/axios' +import { Injectable, Logger } from '@nestjs/common' +import * as yaml from 'js-yaml' +import { nanoid } from 'nanoid' +import { APIMethod, APIOperationType, VersionChangeType } from 'prisma/generated/client' +import { firstValueFrom } from 'rxjs' +import { HanaException } from '@/common/exceptions/hana.exception' +import { PrismaService } from '@/infra/prisma/prisma.service' +import { SystemConfigService } from '@/infra/system-config/system-config.service' +import { + ConflictStrategy, + ExecuteImportReqDto, + ImportedApiResult, + ImportPreviewResDto, + ImportResultResDto, + ImportStats, + OpenApiInfo, + ParsedApiPreview, + ParsedGroupPreview, + ParseOpenapiReqDto, +} from './dto' +import { ApiUtilsService } from './utils.service' + +/** 默认分组名称 */ +const DEFAULT_GROUP_NAME = '未分组' + +/** Schema 转换的最大深度,防止循环引用导致栈溢出 */ +const MAX_SCHEMA_DEPTH = 10 + +/** 引用链最大深度 */ +const MAX_REF_CHAIN_DEPTH = 20 + +/** HTTP 请求超时时间 (ms) */ +const HTTP_FETCH_TIMEOUT = 30000 + +/** OpenAPI 文档最大大小 (10MB) */ +const MAX_CONTENT_SIZE = 10 * 1024 * 1024 + +/** HTTP 方法映射 */ +const HTTP_METHOD_MAP: Readonly> = { + get: APIMethod.GET, + post: APIMethod.POST, + put: APIMethod.PUT, + delete: APIMethod.DELETE, + patch: APIMethod.PATCH, + head: APIMethod.HEAD, + options: APIMethod.OPTIONS, +} + +/** 支持的 HTTP 方法集合(用于快速查找) */ +const SUPPORTED_HTTP_METHODS = new Set(Object.keys(HTTP_METHOD_MAP)) + +/** JSON 请求体 Content-Type */ +const CONTENT_TYPE_JSON = 'application/json' + +/** Form-Data Content-Type */ +const CONTENT_TYPE_FORM_DATA = 'multipart/form-data' + +/** URL Encoded Content-Type */ +const CONTENT_TYPE_URL_ENCODED = 'application/x-www-form-urlencoded' + +/** XML Content-Type */ +const CONTENT_TYPE_XML = 'application/xml' + +/** 纯文本 Content-Type */ +const CONTENT_TYPE_TEXT = 'text/plain' + +/** 二进制 Content-Type */ +const CONTENT_TYPE_OCTET_STREAM = 'application/octet-stream' + +// ============================================================================ +// 类型定义 +// ============================================================================ + +/** API 参数结构 */ +interface ApiParamData { + id: string + name: string + type: string + required: boolean + description: string + example?: unknown + defaultValue?: unknown +} + +/** API 响应结构 */ +interface ApiResponseData { + id: string + name: string + httpStatus: number + body?: Record +} + +/** 请求体数据结构 */ +interface RequestBodyData { + type: 'json' | 'form-data' | 'x-www-form-urlencoded' | 'xml' | 'text' | 'binary' | 'none' + jsonSchema?: Record + formFields?: ApiParamData[] + rawContent?: string + description?: string +} + +/** 内部使用的 API 数据结构 */ +interface InternalApiData { + name: string + path: string + method: APIMethod + operationId?: string + description?: string + groupName: string + tags: string[] + requestHeaders: ApiParamData[] + pathParams: ApiParamData[] + queryParams: ApiParamData[] + requestBody?: RequestBodyData + responses: ApiResponseData[] +} + +/** 服务器信息 */ +interface ServerInfo { + url: string + description?: string +} + +/** 解析结果内部结构 */ +interface ParseResult { + info: OpenApiInfo + servers: ServerInfo[] + apis: InternalApiData[] + operationIds: Set +} + +/** 冲突 API 信息 */ +interface ConflictApiInfo { + id: string + path: string + method: APIMethod +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * 生成 API 唯一标识键 + * @param method HTTP 方法 + * @param path 请求路径 + */ +function buildApiKey(method: APIMethod | string, path: string): string { + return `${method}:${path}` +} + +/** + * 将任意值转换为 Prisma JSON 值 + * @param value 待转换的值 + */ +function toJsonValue(value: T): InputJsonValue { + return value as unknown as InputJsonValue +} + +/** + * 检查对象是否为 OpenAPI 引用对象 + * @param obj 待检查的对象 + */ +function isReferenceObject(obj: unknown): obj is OpenAPIV3.ReferenceObject { + return obj !== null && typeof obj === 'object' && '$ref' in obj +} + +/** + * 获取引用路径 + * @param ref 引用对象 + */ +function getRefPath(ref: OpenAPIV3.ReferenceObject): string { + return ref.$ref +} + +// ============================================================================ +// ImportService +// ============================================================================ + +@Injectable() +export class ImportService { + private readonly logger = new Logger(ImportService.name) + + constructor( + private readonly prisma: PrismaService, + private readonly httpService: HttpService, + private readonly apiUtilsService: ApiUtilsService, + private readonly systemConfigService: SystemConfigService, + ) {} + + // ========================================================================== + // 公开 API + // ========================================================================== + + /** + * 解析 OpenAPI 文档并返回预览 + * @param dto 解析请求参数 + * @param projectId 项目 ID + * @param fileContent 上传的文件内容(可选) + */ + async parseOpenapi( + dto: ParseOpenapiReqDto, + projectId: string, + fileContent?: string, + ): Promise { + try { + // 获取文档内容 + const content = await this.resolveOpenapiContent(dto, fileContent) + + // 解析文档 + const doc = this.parseOpenapiDocument(content) + + // 提取 API 信息 + const parseResult = this.extractApisFromDocument(doc) + + // 获取现有 API 并构建查找 Map + const existingApis = await this.getExistingApis(projectId) + const existingApiMap = this.buildExistingApiMap(existingApis) + + // 构建预览数据 + const { parsedApis, groupsMap } = this.buildPreviewData(parseResult.apis, existingApiMap) + + // 构建统计信息 + const stats = this.buildImportStats(parsedApis) + + this.logger.log( + `解析 OpenAPI 文档成功: ${parseResult.info.title}, 共 ${stats.totalApis} 个 API, ${stats.newApis} 个新增, ${stats.conflictApis} 个冲突`, + ) + + return { + info: parseResult.info, + groups: Array.from(groupsMap.values()), + apis: parsedApis, + stats, + content, + } + } + catch (error) { + if (error instanceof HanaException) + throw error + this.logger.error(`解析 OpenAPI 文档失败: ${(error as Error).message}`, (error as Error).stack) + throw new HanaException('OPENAPI_PARSE_FAILED', { message: (error as Error).message }) + } + } + + /** + * 执行导入操作 + * @param dto 导入请求参数 + * @param projectId 项目 ID + * @param userId 用户 ID + */ + async executeImport( + dto: ExecuteImportReqDto, + projectId: string, + userId: string, + ): Promise { + try { + // 解析文档 + const doc = this.parseOpenapiDocument(dto.content) + const parseResult = this.extractApisFromDocument(doc) + + // 获取现有 API 并构建查找 Map + const existingApis = await this.getExistingApis(projectId) + const existingApiMap = this.buildExistingApiMap(existingApis) + + // 检查项目 API 数量限制 + await this.validateApiLimit(projectId, parseResult.apis, existingApiMap, dto.conflictStrategy) + + // 执行导入 + return await this.performImport(dto, projectId, userId, parseResult.apis, existingApiMap) + } + catch (error) { + if (error instanceof HanaException) + throw error + this.logger.error(`执行导入失败: ${(error as Error).message}`, (error as Error).stack) + throw new HanaException('OPENAPI_IMPORT_FAILED', { message: (error as Error).message }) + } + } + + // ========================================================================== + // 内容获取与解析 + // ========================================================================== + + /** + * 解析获取 OpenAPI 文档内容 + * @param dto 请求参数 + * @param fileContent 上传的文件内容 + */ + private async resolveOpenapiContent(dto: ParseOpenapiReqDto, fileContent?: string): Promise { + let content: string + + if (fileContent) { + content = fileContent + } + else if (dto.content) { + content = dto.content + } + else if (dto.url) { + content = await this.fetchOpenapiFromUrl(dto.url) + } + else { + throw new HanaException('OPENAPI_PARSE_FAILED', { + message: '请提供 OpenAPI 文档内容、URL 或上传文件', + }) + } + + // 验证内容大小 + this.validateContentSize(content) + return content + } + + /** + * 验证内容大小 + * @param content 文档内容 + */ + private validateContentSize(content: string): void { + const sizeInBytes = Buffer.byteLength(content, 'utf-8') + if (sizeInBytes > MAX_CONTENT_SIZE) { + const sizeMB = (sizeInBytes / (1024 * 1024)).toFixed(2) + throw new HanaException('OPENAPI_PARSE_FAILED', { + message: `文档大小 (${sizeMB}MB) 超过限制 (10MB)`, + }) + } + } + + /** + * 从 URL 获取 OpenAPI 文档 + * @param url 文档 URL + */ + private async fetchOpenapiFromUrl(url: string): Promise { + try { + const response = await firstValueFrom( + this.httpService.get(url, { + timeout: HTTP_FETCH_TIMEOUT, + responseType: 'text', + maxContentLength: MAX_CONTENT_SIZE, + maxBodyLength: MAX_CONTENT_SIZE, + }), + ) + return response.data + } + catch (error) { + throw new HanaException('OPENAPI_FETCH_FAILED', { + message: `无法从 URL 获取文档: ${(error as Error).message}`, + }) + } + } + + /** + * 解析 OpenAPI 文档内容(支持 JSON/YAML) + * @param content 文档内容字符串 + */ + private parseOpenapiDocument(content: string): OpenAPIV3.Document { + try { + const trimmed = content.trim() + + // JSON 格式检测(以 { 开头) + if (trimmed.startsWith('{')) { + return JSON.parse(content) as OpenAPIV3.Document + } + + // YAML 格式 + return yaml.load(content, { schema: yaml.JSON_SCHEMA }) as OpenAPIV3.Document + } + catch (error) { + throw new HanaException('OPENAPI_INVALID_FORMAT', { + message: `文档格式错误: ${(error as Error).message}`, + }) + } + } + + // ========================================================================== + // API 提取 + // ========================================================================== + + /** + * 从 OpenAPI 文档中提取 API 信息 + * @param doc OpenAPI 文档对象 + */ + private extractApisFromDocument(doc: OpenAPIV3.Document): ParseResult { + const apis: InternalApiData[] = [] + const operationIds = new Set() + const paths = doc.paths || {} + + for (const [path, pathItem] of Object.entries(paths)) { + if (!pathItem) + continue + + // 提取 path 级别的公共参数 + const pathLevelParams = this.resolveParameters( + (pathItem as OpenAPIV3.PathItemObject).parameters, + doc, + ) + + for (const [method, operation] of Object.entries(pathItem)) { + // 跳过非 HTTP 方法字段(如 parameters, summary 等) + if (!SUPPORTED_HTTP_METHODS.has(method)) + continue + + if (!operation || typeof operation !== 'object') + continue + + const op = operation as OpenAPIV3.OperationObject + const apiData = this.extractSingleApi(path, method, op, pathLevelParams, doc, operationIds) + apis.push(apiData) + } + } + + // 提取文档基本信息 + const info: OpenApiInfo = { + title: doc.info?.title || 'Untitled API', + version: doc.info?.version || '1.0.0', + description: doc.info?.description, + } + + // 提取服务器信息 + const servers: ServerInfo[] = (doc.servers || []).map(server => ({ + url: server.url, + description: server.description, + })) + + return { info, servers, apis, operationIds } + } + + /** + * 提取单个 API 信息 + * @param path 请求路径 + * @param method HTTP 方法 + * @param operation 操作对象 + * @param pathLevelParams 路径级别参数 + * @param doc OpenAPI 文档 + * @param operationIds 已使用的 operationId 集合 + */ + private extractSingleApi( + path: string, + method: string, + operation: OpenAPIV3.OperationObject, + pathLevelParams: OpenAPIV3.ParameterObject[], + doc: OpenAPIV3.Document, + operationIds: Set, + ): InternalApiData { + const apiMethod = HTTP_METHOD_MAP[method] + + // 处理 tags,确保非空 + const tags = this.normalizeTags(operation.tags) + const groupName = tags[0] + + // 合并 path 级别和 operation 级别的参数 + const operationParams = this.resolveParameters(operation.parameters, doc) + const mergedParams = this.mergeParameters(pathLevelParams, operationParams) + + // 按参数位置分类 + const pathParams = mergedParams.filter(p => p.in === 'path').map(p => this.convertParameter(p, doc)) + const queryParams = mergedParams.filter(p => p.in === 'query').map(p => this.convertParameter(p, doc)) + const requestHeaders = mergedParams.filter(p => p.in === 'header').map(p => this.convertParameter(p, doc)) + + // 提取请求体 + const requestBody = operation.requestBody + ? this.convertRequestBody(operation.requestBody, doc) + : undefined + + // 提取响应 + const responses = this.convertResponses(operation.responses || {}, doc) + + // 处理 operationId + const operationId = operation.operationId + if (operationId) { + if (operationIds.has(operationId)) { + this.logger.warn(`重复的 operationId: ${operationId},路径: ${method.toUpperCase()} ${path}`) + } + else { + operationIds.add(operationId) + } + } + + return { + name: operation.summary || operation.operationId || `${method.toUpperCase()} ${path}`, + path, + method: apiMethod, + operationId, + description: operation.description, + groupName, + tags, + requestHeaders, + pathParams, + queryParams, + requestBody, + responses, + } + } + + /** + * 规范化 tags 数组 + * @param tags 原始 tags + */ + private normalizeTags(tags?: string[]): string[] { + // 过滤空字符串和 undefined + const validTags = tags?.filter(tag => tag?.trim()) + + if (!validTags || validTags.length === 0) { + return [DEFAULT_GROUP_NAME] + } + + return validTags + } + + /** + * 解析参数数组(处理 $ref 引用) + * @param parameters 参数数组 + * @param doc OpenAPI 文档 + */ + private resolveParameters( + parameters: (OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject)[] | undefined, + doc: OpenAPIV3.Document, + ): OpenAPIV3.ParameterObject[] { + if (!parameters) + return [] + + return parameters + .map(param => this.resolveRef(param, doc)) + .filter((param): param is OpenAPIV3.ParameterObject => param !== null && typeof param === 'object') + } + + /** + * 合并路径级别和操作级别的参数 + * 操作级别参数会覆盖同名的路径级别参数 + * @param pathParams 路径级别参数 + * @param operationParams 操作级别参数 + */ + private mergeParameters( + pathParams: OpenAPIV3.ParameterObject[], + operationParams: OpenAPIV3.ParameterObject[], + ): OpenAPIV3.ParameterObject[] { + const paramMap = new Map() + + // 先添加 path 级别参数 + for (const param of pathParams) { + const key = `${param.in}:${param.name}` + paramMap.set(key, param) + } + + // 操作级别参数覆盖同名参数 + for (const param of operationParams) { + const key = `${param.in}:${param.name}` + paramMap.set(key, param) + } + + return Array.from(paramMap.values()) + } + + // ========================================================================== + // $ref 引用解析 + // ========================================================================== + + /** + * 解析 $ref 引用 + * @param obj 待解析的对象 + * @param doc OpenAPI 文档 + */ + private resolveRef(obj: T | OpenAPIV3.ReferenceObject, doc: OpenAPIV3.Document): T { + if (!obj || typeof obj !== 'object') + return obj as T + + if (!isReferenceObject(obj)) + return obj as T + + let resolved: unknown = this.resolveRefPath(getRefPath(obj), doc) + let depth = 0 + + // 处理引用链 + while (resolved && isReferenceObject(resolved)) { + if (depth++ > MAX_REF_CHAIN_DEPTH) { + this.logger.warn(`引用链过深,停止解析: ${getRefPath(obj)}`) + return {} as T + } + resolved = this.resolveRefPath(getRefPath(resolved), doc) + } + + return resolved as T + } + + /** + * 根据引用路径解析对象 + * @param refPath 引用路径(如 #/components/schemas/Pet) + * @param doc OpenAPI 文档 + */ + private resolveRefPath(refPath: string, doc: OpenAPIV3.Document): unknown { + const parts = refPath.split('/').slice(1) // 移除开头的 '#' + let resolved: unknown = doc + + for (const part of parts) { + resolved = (resolved as Record)?.[part] + if (resolved === undefined) { + this.logger.warn(`无法解析引用: ${refPath}`) + return {} + } + } + + return resolved + } + + // ========================================================================== + // 参数转换 + // ========================================================================== + + /** + * 转换参数为内部格式 + * @param param OpenAPI 参数对象 + * @param doc OpenAPI 文档 + */ + private convertParameter(param: OpenAPIV3.ParameterObject, doc: OpenAPIV3.Document): ApiParamData { + const schema = param.schema ? this.resolveRef(param.schema, doc) : {} + const schemaObj = schema as OpenAPIV3.SchemaObject + + return { + id: nanoid(), + name: param.name, + type: this.getSchemaType(schemaObj), + required: param.required || false, + description: param.description || '', + example: param.example ?? schemaObj?.example, + defaultValue: schemaObj?.default, + } + } + + // ========================================================================== + // 请求体转换 + // ========================================================================== + + /** + * 转换请求体为内部格式 + * @param requestBody 请求体对象(可能是引用) + * @param doc OpenAPI 文档 + */ + private convertRequestBody( + requestBody: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject, + doc: OpenAPIV3.Document, + ): RequestBodyData { + const resolved = this.resolveRef(requestBody, doc) as OpenAPIV3.RequestBodyObject + const content = resolved.content || {} + + // JSON 请求体 + if (content[CONTENT_TYPE_JSON]) { + return this.convertJsonRequestBody(content[CONTENT_TYPE_JSON], resolved.description, doc) + } + + // Form-Data 请求体 + if (content[CONTENT_TYPE_FORM_DATA]) { + return this.convertFormRequestBody(content[CONTENT_TYPE_FORM_DATA], 'form-data', resolved.description, doc) + } + + // URL Encoded 请求体 + if (content[CONTENT_TYPE_URL_ENCODED]) { + return this.convertFormRequestBody(content[CONTENT_TYPE_URL_ENCODED], 'x-www-form-urlencoded', resolved.description, doc) + } + + // XML 请求体 + if (content[CONTENT_TYPE_XML]) { + return this.convertXmlRequestBody(content[CONTENT_TYPE_XML], resolved.description, doc) + } + + // 纯文本请求体 + if (content[CONTENT_TYPE_TEXT]) { + return { + type: 'text', + description: resolved.description, + } + } + + // 二进制请求体 + if (content[CONTENT_TYPE_OCTET_STREAM]) { + return { + type: 'binary', + description: resolved.description, + } + } + + // 尝试匹配其他 JSON 类型(如 application/json; charset=utf-8) + const jsonContentType = Object.keys(content).find(ct => ct.includes('json')) + if (jsonContentType) { + return this.convertJsonRequestBody(content[jsonContentType], resolved.description, doc) + } + + return { + type: 'none', + description: resolved.description, + } + } + + /** + * 转换 JSON 请求体 + */ + private convertJsonRequestBody( + mediaType: OpenAPIV3.MediaTypeObject, + description: string | undefined, + doc: OpenAPIV3.Document, + ): RequestBodyData { + return { + type: 'json', + jsonSchema: mediaType.schema + ? this.convertSchema(mediaType.schema as OpenAPIV3.SchemaObject, doc, 0, new Set()) + : {}, + description, + } + } + + /** + * 转换表单请求体 + */ + private convertFormRequestBody( + mediaType: OpenAPIV3.MediaTypeObject, + type: 'form-data' | 'x-www-form-urlencoded', + description: string | undefined, + doc: OpenAPIV3.Document, + ): RequestBodyData { + const schema = mediaType.schema ? this.resolveRef(mediaType.schema, doc) : {} + return { + type, + formFields: this.extractFormFields(schema as OpenAPIV3.SchemaObject, doc), + description, + } + } + + /** + * 转换 XML 请求体 + */ + private convertXmlRequestBody( + mediaType: OpenAPIV3.MediaTypeObject, + description: string | undefined, + doc: OpenAPIV3.Document, + ): RequestBodyData { + return { + type: 'xml', + jsonSchema: mediaType.schema + ? this.convertSchema(mediaType.schema as OpenAPIV3.SchemaObject, doc, 0, new Set()) + : {}, + description, + } + } + + // ========================================================================== + // 响应转换 + // ========================================================================== + + /** + * 转换响应对象 + * @param responses 响应对象集合 + * @param doc OpenAPI 文档 + */ + private convertResponses( + responses: OpenAPIV3.ResponsesObject, + doc: OpenAPIV3.Document, + ): ApiResponseData[] { + const result: ApiResponseData[] = [] + + for (const [statusCode, response] of Object.entries(responses)) { + if (!response) + continue + + const resolvedResponse = this.resolveRef(response, doc) as OpenAPIV3.ResponseObject + if (!resolvedResponse) + continue + + // 处理状态码:'default' 使用特殊值 0 表示 + const httpStatus = statusCode === 'default' ? 0 : Number.parseInt(statusCode, 10) + + // 提取响应体 schema + let body: Record | undefined + const content = resolvedResponse.content + + if (content) { + // 优先 JSON + const jsonContent = content[CONTENT_TYPE_JSON] + || Object.entries(content).find(([ct]) => ct.includes('json'))?.[1] + + if (jsonContent?.schema) { + body = this.convertSchema(jsonContent.schema as OpenAPIV3.SchemaObject, doc, 0, new Set()) + } + } + + result.push({ + id: nanoid(), + name: resolvedResponse.description || `响应 ${statusCode}`, + httpStatus, + body, + }) + } + + return result + } + + // ========================================================================== + // Schema 转换 + // ========================================================================== + + /** + * 转换 Schema 为内部格式 + * 使用回溯模式检测循环引用,避免频繁创建 Set 对象 + * @param schema Schema 对象 + * @param doc OpenAPI 文档 + * @param depth 当前递归深度 + * @param visitedRefs 已访问的引用路径集合 + */ + private convertSchema( + schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + doc: OpenAPIV3.Document, + depth: number, + visitedRefs: Set, + ): Record { + if (!schema) + return {} + + // 深度限制检查 + if (depth > MAX_SCHEMA_DEPTH) { + this.logger.debug(`Schema 转换达到最大深度 ${MAX_SCHEMA_DEPTH},停止递归`) + return { type: 'object', description: '[深度限制:省略嵌套内容]' } + } + + let resolved: OpenAPIV3.SchemaObject + let currentRef: string | undefined + + // 处理引用类型 + if (isReferenceObject(schema)) { + currentRef = getRefPath(schema) + + // 循环引用检测 + if (visitedRefs.has(currentRef)) { + this.logger.debug(`Schema 转换检测到循环引用: ${currentRef}`) + return { type: 'object', description: `[循环引用: ${currentRef}]` } + } + + // 添加到已访问集合 + visitedRefs.add(currentRef) + resolved = this.resolveRef(schema, doc) as OpenAPIV3.SchemaObject + } + else { + resolved = schema + } + + if (!resolved || typeof resolved !== 'object') { + // 回溯:移除当前引用 + if (currentRef) + visitedRefs.delete(currentRef) + return {} + } + + // 构建结果对象 + const result = this.buildSchemaResult(resolved, doc, depth, visitedRefs) + + // 回溯:移除当前引用 + if (currentRef) { + visitedRefs.delete(currentRef) + } + + return result + } + + /** + * 构建 Schema 结果对象 + */ + private buildSchemaResult( + schema: OpenAPIV3.SchemaObject, + doc: OpenAPIV3.Document, + depth: number, + visitedRefs: Set, + ): Record { + const result: Record = { + type: schema.type || 'object', + } + + // 基本属性 + if (schema.description) + result.description = schema.description + if (schema.default !== undefined) + result.default = schema.default + if (schema.enum) + result.enum = schema.enum + if (schema.format) + result.format = schema.format + if (schema.nullable) + result.nullable = schema.nullable + + // 数值约束 + if (schema.minimum !== undefined) + result.minimum = schema.minimum + if (schema.maximum !== undefined) + result.maximum = schema.maximum + if (schema.exclusiveMinimum !== undefined) + result.exclusiveMinimum = schema.exclusiveMinimum + if (schema.exclusiveMaximum !== undefined) + result.exclusiveMaximum = schema.exclusiveMaximum + + // 字符串约束 + if (schema.minLength !== undefined) + result.minLength = schema.minLength + if (schema.maxLength !== undefined) + result.maxLength = schema.maxLength + if (schema.pattern) + result.pattern = schema.pattern + + // 数组约束 + if (schema.minItems !== undefined) + result.minItems = schema.minItems + if (schema.maxItems !== undefined) + result.maxItems = schema.maxItems + if (schema.uniqueItems !== undefined) + result.uniqueItems = schema.uniqueItems + + // 示例 + if (schema.example !== undefined) + result.examples = [schema.example] + + // 处理 properties + if (schema.properties) { + result.properties = {} + for (const [key, value] of Object.entries(schema.properties)) { + (result.properties as Record)[key] = this.convertSchema( + value as OpenAPIV3.SchemaObject, + doc, + depth + 1, + visitedRefs, + ) + } + if (schema.required) { + result.required = schema.required + } + } + + // 处理 items(数组类型) + if ('items' in schema && schema.items) { + result.items = this.convertSchema( + schema.items as OpenAPIV3.SchemaObject, + doc, + depth + 1, + visitedRefs, + ) + } + + // 处理组合类型 + if (schema.allOf) { + result.allOf = schema.allOf.map(s => + this.convertSchema(s as OpenAPIV3.SchemaObject, doc, depth + 1, visitedRefs), + ) + } + if (schema.oneOf) { + result.oneOf = schema.oneOf.map(s => + this.convertSchema(s as OpenAPIV3.SchemaObject, doc, depth + 1, visitedRefs), + ) + } + if (schema.anyOf) { + result.anyOf = schema.anyOf.map(s => + this.convertSchema(s as OpenAPIV3.SchemaObject, doc, depth + 1, visitedRefs), + ) + } + + // 处理 additionalProperties + if (schema.additionalProperties !== undefined) { + if (typeof schema.additionalProperties === 'object') { + result.additionalProperties = this.convertSchema( + schema.additionalProperties as OpenAPIV3.SchemaObject, + doc, + depth + 1, + visitedRefs, + ) + } + else { + result.additionalProperties = schema.additionalProperties + } + } + + return result + } + + /** + * 从 Schema 提取表单字段 + */ + private extractFormFields(schema: OpenAPIV3.SchemaObject, doc: OpenAPIV3.Document): ApiParamData[] { + const resolved = this.resolveRef(schema, doc) as OpenAPIV3.SchemaObject + const fields: ApiParamData[] = [] + + if (resolved?.properties) { + const required = resolved.required || [] + for (const [name, prop] of Object.entries(resolved.properties)) { + const propSchema = this.resolveRef(prop, doc) as OpenAPIV3.SchemaObject + fields.push({ + id: nanoid(), + name, + type: this.getSchemaType(propSchema), + required: required.includes(name), + description: propSchema?.description || '', + example: propSchema?.example, + defaultValue: propSchema?.default, + }) + } + } + + return fields + } + + /** + * 获取 Schema 类型字符串 + */ + private getSchemaType(schema: OpenAPIV3.SchemaObject): string { + if (!schema) + return 'string' + + // 处理 nullable + const baseType = this.getBaseSchemaType(schema) + return baseType + } + + /** + * 获取基础类型 + */ + private getBaseSchemaType(schema: OpenAPIV3.SchemaObject): string { + if (schema.type === 'integer') + return 'integer' + if (schema.type === 'number') + return 'number' + if (schema.type === 'boolean') + return 'boolean' + if (schema.type === 'array') + return 'array' + if (schema.type === 'object') + return 'object' + if (schema.format === 'binary') + return 'file' + return 'string' + } + + // ========================================================================== + // 冲突检测与预览构建 + // ========================================================================== + + /** + * 获取现有 API 列表 + */ + private async getExistingApis(projectId: string): Promise { + return this.prisma.aPI.findMany({ + where: { projectId, recordStatus: 'ACTIVE' }, + select: { id: true, path: true, method: true }, + }) + } + + /** + * 构建现有 API 的查找 Map + * @param existingApis 现有 API 列表 + */ + private buildExistingApiMap(existingApis: ConflictApiInfo[]): Map { + const map = new Map() + for (const api of existingApis) { + map.set(buildApiKey(api.method, api.path), api) + } + return map + } + + /** + * 构建预览数据 + */ + private buildPreviewData( + apis: InternalApiData[], + existingApiMap: Map, + ): { parsedApis: ParsedApiPreview[], groupsMap: Map } { + const parsedApis: ParsedApiPreview[] = [] + const groupsMap = new Map() + + for (const api of apis) { + const conflictApi = existingApiMap.get(buildApiKey(api.method, api.path)) + + parsedApis.push({ + path: api.path, + method: api.method, + name: api.name, + groupName: api.groupName, + description: api.description, + hasConflict: !!conflictApi, + conflictApiId: conflictApi?.id, + }) + + // 更新分组统计 + const existing = groupsMap.get(api.groupName) + if (existing) { + existing.apiCount++ + } + else { + groupsMap.set(api.groupName, { + name: api.groupName, + apiCount: 1, + }) + } + } + + return { parsedApis, groupsMap } + } + + /** + * 构建导入统计信息 + */ + private buildImportStats(parsedApis: ParsedApiPreview[]): ImportStats { + const conflictApis = parsedApis.filter(a => a.hasConflict).length + return { + totalApis: parsedApis.length, + newApis: parsedApis.length - conflictApis, + conflictApis, + } + } + + // ========================================================================== + // 导入执行 + // ========================================================================== + + /** + * 验证 API 数量限制 + * - skip: 只计算不冲突的新 API + * - overwrite: 只计算不冲突的新 API(覆盖不增加数量) + * - rename: 所有导入的 API 都会新增(冲突的会重命名) + */ + private async validateApiLimit( + projectId: string, + apis: InternalApiData[], + existingApiMap: Map, + conflictStrategy: ConflictStrategy, + ): Promise { + const projectMaxApis = this.systemConfigService.get(SystemConfigKey.PROJECT_MAX_APIS) + const currentApiCount = await this.prisma.aPI.count({ + where: { projectId, recordStatus: 'ACTIVE' }, + }) + + // 根据冲突策略计算实际新增数量 + let newApisCount: number + if (conflictStrategy === ConflictStrategy.RENAME) { + // rename 策略:所有 API 都会创建(冲突的会重命名后创建) + newApisCount = apis.length + } + else { + // skip/overwrite 策略:只有不冲突的 API 会新增 + // overwrite 是更新现有 API,不增加数量 + newApisCount = apis.filter(api => + !existingApiMap.has(buildApiKey(api.method, api.path)), + ).length + } + + if (currentApiCount + newApisCount > projectMaxApis) { + throw new HanaException('PROJECT_API_LIMIT_EXCEEDED') + } + } + + /** + * 导入 API + */ + private async performImport( + dto: ExecuteImportReqDto, + projectId: string, + userId: string, + apis: InternalApiData[], + existingApiMap: Map, + ): Promise { + const results: ImportedApiResult[] = [] + const createdGroups: string[] = [] + let createdCount = 0 + let updatedCount = 0 + let skippedCount = 0 + let failedCount = 0 + + // 获取现有分组 + const groupNameToId = await this.getExistingGroupMap(projectId) + + await this.prisma.$transaction(async (tx) => { + // 创建缺失的分组 + if (dto.createMissingGroups !== false) { + await this.createMissingGroups(tx, apis, groupNameToId, projectId, createdGroups) + } + + // 处理每个 API + for (const api of apis) { + const conflictApi = existingApiMap.get(buildApiKey(api.method, api.path)) + const groupId = this.resolveGroupId(dto.targetGroupId, api.groupName, groupNameToId) + + // 确保有分组 + const finalGroupId = await this.ensureGroupExists( + tx, + groupId, + groupNameToId, + projectId, + createdGroups, + ) + + try { + const result = await this.processApiImport( + tx, + api, + conflictApi, + finalGroupId, + projectId, + userId, + dto.conflictStrategy, + ) + + results.push(result.apiResult) + + switch (result.action) { + case 'created': + createdCount++ + break + case 'updated': + updatedCount++ + break + case 'skipped': + skippedCount++ + break + } + } + catch (error) { + results.push({ + name: api.name, + path: api.path, + method: api.method, + status: 'failed', + error: (error as Error).message, + }) + failedCount++ + } + } + }) + + this.logger.log( + `用户 ${userId} 在项目 ${projectId} 中导入了 OpenAPI 文档: 创建 ${createdCount}, 更新 ${updatedCount}, 跳过 ${skippedCount}, 失败 ${failedCount}`, + ) + + return { + success: failedCount === 0, + results, + createdCount, + updatedCount, + skippedCount, + failedCount, + createdGroups, + } + } + + /** + * 获取现有分组映射 + */ + private async getExistingGroupMap(projectId: string): Promise> { + const groups = await this.prisma.aPIGroup.findMany({ + where: { projectId, status: 'ACTIVE' }, + select: { id: true, name: true }, + }) + + const map = new Map() + for (const group of groups) { + map.set(group.name, group.id) + } + return map + } + + /** + * 创建缺失的分组 + */ + private async createMissingGroups( + tx: TransactionClient, + apis: InternalApiData[], + groupNameToId: Map, + projectId: string, + createdGroups: string[], + ): Promise { + const uniqueGroupNames = [...new Set(apis.map(a => a.groupName))] + + for (const groupName of uniqueGroupNames) { + if (!groupNameToId.has(groupName)) { + const group = await tx.aPIGroup.create({ + data: { + projectId, + name: groupName, + sortOrder: 0, + }, + }) + groupNameToId.set(groupName, group.id) + createdGroups.push(groupName) + } + } + } + + /** + * 解析分组 ID + */ + private resolveGroupId( + targetGroupId: string | undefined, + groupName: string, + groupNameToId: Map, + ): string | undefined { + return targetGroupId || groupNameToId.get(groupName) + } + + /** + * 确保分组存在 + */ + private async ensureGroupExists( + tx: TransactionClient, + groupId: string | undefined, + groupNameToId: Map, + projectId: string, + createdGroups: string[], + ): Promise { + if (groupId) { + return groupId + } + + // 创建或获取默认分组 + if (!groupNameToId.has(DEFAULT_GROUP_NAME)) { + const defaultGroup = await tx.aPIGroup.create({ + data: { + projectId, + name: DEFAULT_GROUP_NAME, + sortOrder: 999, + }, + }) + groupNameToId.set(DEFAULT_GROUP_NAME, defaultGroup.id) + createdGroups.push(DEFAULT_GROUP_NAME) + } + + return groupNameToId.get(DEFAULT_GROUP_NAME)! + } + + /** + * 处理单个 API 导入 + */ + private async processApiImport( + tx: TransactionClient, + api: InternalApiData, + conflictApi: ConflictApiInfo | undefined, + groupId: string, + projectId: string, + userId: string, + conflictStrategy: ConflictStrategy, + ): Promise<{ apiResult: ImportedApiResult, action: 'created' | 'updated' | 'skipped' }> { + if (conflictApi) { + return this.handleConflict(tx, api, conflictApi, groupId, projectId, userId, conflictStrategy) + } + + // 创建新 API + const newApi = await this.createNewApi(tx, api, groupId, projectId, userId) + return { + apiResult: { + name: api.name, + path: api.path, + method: api.method, + status: 'created', + apiId: newApi.id, + }, + action: 'created', + } + } + + /** + * 处理冲突 + */ + private async handleConflict( + tx: TransactionClient, + api: InternalApiData, + conflictApi: ConflictApiInfo, + groupId: string, + projectId: string, + userId: string, + strategy: ConflictStrategy, + ): Promise<{ apiResult: ImportedApiResult, action: 'created' | 'updated' | 'skipped' }> { + switch (strategy) { + case ConflictStrategy.SKIP: + return { + apiResult: { + name: api.name, + path: api.path, + method: api.method, + status: 'skipped', + }, + action: 'skipped', + } + + case ConflictStrategy.OVERWRITE: + await this.updateExistingApi(tx, conflictApi.id, api, userId) + return { + apiResult: { + name: api.name, + path: api.path, + method: api.method, + status: 'updated', + apiId: conflictApi.id, + }, + action: 'updated', + } + + case ConflictStrategy.RENAME: { + const renamedPath = `${api.path}_imported` + const renamedApi = await this.createNewApi( + tx, + { ...api, path: renamedPath }, + groupId, + projectId, + userId, + ) + return { + apiResult: { + name: api.name, + path: renamedPath, + method: api.method, + status: 'created', + apiId: renamedApi.id, + }, + action: 'created', + } + } + } + } + + // ========================================================================== + // 数据库操作 + // ========================================================================== + + /** + * 创建新 API + */ + private async createNewApi( + tx: TransactionClient, + api: InternalApiData, + groupId: string, + projectId: string, + userId: string, + ) { + const newApi = await tx.aPI.create({ + data: { + projectId, + groupId, + name: api.name, + method: api.method, + path: api.path, + tags: api.tags, + sortOrder: 0, + editorId: userId, + creatorId: userId, + }, + }) + + const version = await tx.aPIVersion.create({ + data: { + apiId: newApi.id, + projectId, + revision: 1, + status: 'DRAFT', + summary: '从 OpenAPI 文档导入', + editorId: userId, + changes: [VersionChangeType.CREATE], + }, + }) + + await tx.aPISnapshot.create({ + data: { + versionId: version.id, + name: api.name, + method: api.method, + path: api.path, + description: api.description, + tags: api.tags, + status: 'DRAFT', + sortOrder: 0, + requestHeaders: toJsonValue(api.requestHeaders), + pathParams: toJsonValue(api.pathParams), + queryParams: toJsonValue(api.queryParams), + requestBody: toJsonValue(api.requestBody), + responses: toJsonValue(api.responses), + }, + }) + + await tx.aPI.update({ + where: { id: newApi.id }, + data: { currentVersionId: version.id }, + }) + + await this.apiUtilsService.createOperationLog( + { + apiId: newApi.id, + userId, + operation: APIOperationType.CREATE, + versionId: version.id, + changes: [VersionChangeType.CREATE], + description: '从 OpenAPI 文档导入', + metadata: { source: 'openapi-import' }, + }, + tx, + ) + + return newApi + } + + /** + * 更新现有 API + */ + private async updateExistingApi( + tx: TransactionClient, + apiId: string, + api: InternalApiData, + userId: string, + ): Promise { + const existingApi = await tx.aPI.findUnique({ + where: { id: apiId }, + include: { currentVersion: true }, + }) + + if (!existingApi) + return + + // 获取当前最大 revision + const maxRevision = await tx.aPIVersion.aggregate({ + where: { apiId }, + _max: { revision: true }, + }) + const nextRevision = (maxRevision._max.revision ?? 0) + 1 + + // 创建新版本 + const version = await tx.aPIVersion.create({ + data: { + apiId, + projectId: existingApi.projectId, + revision: nextRevision, + status: 'DRAFT', + summary: '从 OpenAPI 文档更新', + editorId: userId, + changes: [ + VersionChangeType.BASIC_INFO, + VersionChangeType.REQUEST_PARAM, + VersionChangeType.REQUEST_BODY, + VersionChangeType.RESPONSE, + ], + }, + }) + + await tx.aPISnapshot.create({ + data: { + versionId: version.id, + name: api.name, + method: api.method, + path: api.path, + description: api.description, + tags: api.tags, + status: 'DRAFT', + sortOrder: existingApi.sortOrder, + requestHeaders: toJsonValue(api.requestHeaders), + pathParams: toJsonValue(api.pathParams), + queryParams: toJsonValue(api.queryParams), + requestBody: toJsonValue(api.requestBody), + responses: toJsonValue(api.responses), + }, + }) + + await tx.aPI.update({ + where: { id: apiId }, + data: { + name: api.name, + tags: api.tags, + editorId: userId, + currentVersionId: version.id, + }, + }) + + await this.apiUtilsService.createOperationLog( + { + apiId, + userId, + operation: APIOperationType.UPDATE, + versionId: version.id, + changes: [ + VersionChangeType.BASIC_INFO, + VersionChangeType.REQUEST_PARAM, + VersionChangeType.REQUEST_BODY, + VersionChangeType.RESPONSE, + ], + description: '从 OpenAPI 文档更新', + metadata: { source: 'openapi-import' }, + }, + tx, + ) + } +} diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 06a44a9..6fb1c2f 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -8,7 +8,6 @@ import { ResMsg } from '@/common/decorators/res-msg.decorator' import { UserDetailInfoDto, UserSessionDto } from '@/common/dto/user.dto' import { AuthGuard } from '@/common/guards/auth.guard' import { PasswordConfirmationPipe } from '@/common/pipes/password-confirmation.pipe' -import { REMEMBER_ME_COOKIE_MAX_AGE } from '@/constants/cookie' import { CookieService } from '@/cookie/cookie.service' import { AuthService } from './auth.service' import { @@ -40,15 +39,16 @@ export class AuthController { const userAgent = request.headers['user-agent'] const ip = request.ip - const { user, sessionId, idleTimeout } = await this.authService.login(loginDto, { userAgent, ip }) + const { user, sessionId, idleTimeout } = await this.authService.login( + loginDto, + { userAgent, ip }, + ) // 成功登录后立即重新生成 Session ID const newSessionId = await this.authService.regenerateSessionId(sessionId) const finalSessionId = newSessionId || sessionId - // 设置 Cookie,过期时间与 Session 一致 - const cookieMaxAge = loginDto.rememberMe ? REMEMBER_ME_COOKIE_MAX_AGE : idleTimeout - this.cookieService.setSecureSessionCookie(response, finalSessionId, { maxAge: cookieMaxAge }) + this.cookieService.setSecureSessionCookie(response, finalSessionId, { maxAge: idleTimeout }) return plainToInstance(LoginResDto, { user }) } diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 904e1bd..6f7fc1e 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,8 +1,8 @@ import { ROLE_NAME, SystemConfigKey } from '@apiplayer/shared' import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' import { compare, hash } from 'bcrypt' import { HanaException } from '@/common/exceptions/hana.exception' -import { DEFAULT_COOKIE_MAX_AGE, REMEMBER_ME_COOKIE_MAX_AGE } from '@/constants/cookie' import { EmailCodeService } from '@/email-code/email-code.service' import { PrismaService } from '@/infra/prisma/prisma.service' import { SystemConfigService } from '@/infra/system-config/system-config.service' @@ -19,6 +19,7 @@ export class AuthService { private readonly sessionService: SessionService, private readonly systemConfigService: SystemConfigService, private readonly emailCodeService: EmailCodeService, + private readonly configService: ConfigService, ) {} /** 对密码进行哈希处理 */ @@ -57,7 +58,9 @@ export class AuthService { } // 创建Session,根据 rememberMe 设置不同的过期时间 - const idleTimeout = rememberMe ? REMEMBER_ME_COOKIE_MAX_AGE : DEFAULT_COOKIE_MAX_AGE + const idleTimeout = rememberMe + ? (this.configService.get('COOKIE_REMEMBER_ME_MAX_AGE') || 30 * 24 * 60 * 60) + : (this.configService.get('COOKIE_MAX_AGE') || 7 * 24 * 60 * 60) const sessionOptions = { idleTimeout } const sessionId = await this.sessionService.createSession( diff --git a/apps/backend/src/constants/cookie.ts b/apps/backend/src/constants/cookie.ts deleted file mode 100644 index e8bf40d..0000000 --- a/apps/backend/src/constants/cookie.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** 默认 Cookie 过期时间:7 天 */ -export const DEFAULT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 - -/** "记住我" Cookie 过期时间:30 天 */ -export const REMEMBER_ME_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 diff --git a/apps/backend/src/cookie/cookie.service.ts b/apps/backend/src/cookie/cookie.service.ts index 911a6a1..41926ca 100644 --- a/apps/backend/src/cookie/cookie.service.ts +++ b/apps/backend/src/cookie/cookie.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { FastifyReply } from 'fastify' -import { DEFAULT_COOKIE_MAX_AGE } from '@/constants/cookie' /** Cookie 配置选项 */ export interface CookieOptions { @@ -13,10 +12,12 @@ export interface CookieOptions { export class CookieService { private isProduction: boolean private cookieDomain: string | undefined + private cookieMaxAge: number constructor(private readonly configService: ConfigService) { this.isProduction = this.configService.get('NODE_ENV') === 'production' this.cookieDomain = this.configService.get('COOKIE_DOMAIN') || undefined + this.cookieMaxAge = this.configService.get('COOKIE_MAX_AGE') || 7 * 24 * 60 * 60 } /** 设置安全的 Session Cookie */ @@ -25,7 +26,7 @@ export class CookieService { sessionId: string, options: CookieOptions = {}, ): void { - const maxAge = options.maxAge ?? DEFAULT_COOKIE_MAX_AGE + const maxAge = options.maxAge ?? this.cookieMaxAge response.cookie('sid', sessionId, { httpOnly: true, @@ -43,7 +44,7 @@ export class CookieService { sessionId: string, ttlSeconds?: number, ): void { - const maxAge = ttlSeconds ?? DEFAULT_COOKIE_MAX_AGE + const maxAge = ttlSeconds ?? this.cookieMaxAge response.cookie('sid', sessionId, { httpOnly: true, diff --git a/apps/backend/tsdown.config.ts b/apps/backend/tsdown.config.ts index 9eb186b..dc32864 100644 --- a/apps/backend/tsdown.config.ts +++ b/apps/backend/tsdown.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ target: 'node20', platform: 'node', dts: false, - inlineOnly: ['@faker-js/faker'], + inlineOnly: ['@faker-js/faker', 'nanoid'], external: [ '@nestjs/common', '@nestjs/core', diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6116b9b..bed89cf 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -25,12 +25,12 @@ "lodash-es": "catalog:frontend", "lucide-vue-next": "catalog:frontend", "monaco-editor": "catalog:frontend", - "nanoid": "catalog:frontend", "pinia": "catalog:frontend", "pinia-plugin-persistedstate": "catalog:frontend", "pinyin-pro": "catalog:frontend", "reka-ui": "catalog:frontend", "tailwind-merge": "catalog:frontend", + "tailwind-scrollbar-hide": "catalog:frontend", "tailwindcss": "catalog:frontend", "uuid": "catalog:frontend", "vee-validate": "catalog:frontend", diff --git a/apps/frontend/src/api/import.ts b/apps/frontend/src/api/import.ts new file mode 100644 index 0000000..38e4191 --- /dev/null +++ b/apps/frontend/src/api/import.ts @@ -0,0 +1,19 @@ +import type { ExecuteImportReq, ImportPreview, ImportResult, ParseOpenapiReq } from '@/types/import' +import http from '@/service' + +export const importApi = { + /** 解析 OpenAPI 文档(JSON 方式) */ + parseOpenapi: (projectId: string, data: ParseOpenapiReq) => + http.post(`api/${projectId}/import/openapi/parse`, { json: data }).json(), + + /** 解析 OpenAPI 文档(文件上传方式) */ + parseOpenapiFile: (projectId: string, file: File) => { + const formData = new FormData() + formData.append('file', file) + return http.post(`api/${projectId}/import/openapi/parse`, { body: formData }).json() + }, + + /** 执行导入 */ + executeImport: (projectId: string, data: ExecuteImportReq) => + http.post(`api/${projectId}/import/openapi/execute`, { json: data }).json(), +} diff --git a/apps/frontend/src/components/ui/scroll-area/ScrollArea.vue b/apps/frontend/src/components/ui/scroll-area/ScrollArea.vue index 99349c0..7d07172 100644 --- a/apps/frontend/src/components/ui/scroll-area/ScrollArea.vue +++ b/apps/frontend/src/components/ui/scroll-area/ScrollArea.vue @@ -10,9 +10,18 @@ import { import { cn } from '@/lib/utils' import ScrollBar from './ScrollBar.vue' -const props = defineProps() +interface Props extends ScrollAreaRootProps { + class?: HTMLAttributes['class'] + orientation?: 'vertical' | 'horizontal' | 'both' + barWidth?: number +} -const delegatedProps = reactiveOmit(props, 'class') +const props = withDefaults(defineProps(), { + orientation: 'vertical', + barWidth: 10, +}) + +const delegatedProps = reactiveOmit(props, 'class', 'orientation') diff --git a/apps/frontend/src/components/ui/scroll-area/ScrollBar.vue b/apps/frontend/src/components/ui/scroll-area/ScrollBar.vue index e390300..3758df7 100644 --- a/apps/frontend/src/components/ui/scroll-area/ScrollBar.vue +++ b/apps/frontend/src/components/ui/scroll-area/ScrollBar.vue @@ -5,9 +5,16 @@ import { reactiveOmit } from '@vueuse/core' import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui' import { cn } from '@/lib/utils' -const props = withDefaults(defineProps(), { - orientation: 'vertical', -}) +const props = withDefaults( + defineProps(), + { + orientation: 'vertical', + barWidth: 10, + }, +) const delegatedProps = reactiveOmit(props, 'class') @@ -19,10 +26,11 @@ const delegatedProps = reactiveOmit(props, 'class') :class=" cn('flex touch-none p-px transition-colors select-none', orientation === 'vertical' - && 'h-full w-2.5 border-l border-l-transparent', + && 'h-full border-l border-l-transparent', orientation === 'horizontal' - && 'h-2.5 flex-col border-t border-t-transparent', + && 'flex-col border-t border-t-transparent', props.class)" + :style="orientation === 'vertical' ? { width: `${barWidth}px` } : { height: `${barWidth}px` }" > (null) const isCloneApiDialogOpen = ref(false) const apiToClone = ref(null) +const isImportDialogOpen = ref(false) + +function handleImportOpenapi() { + isImportDialogOpen.value = true +} + function handleCreateGroup(parentId?: string) { groupDialogMode.value = 'create' groupDialogParentId.value = parentId @@ -96,6 +103,7 @@ function handleDeleteApi(api: ApiBrief) { @select-api="handleSelectApi" @clone-api="handleCloneApi" @delete-api="handleDeleteApi" + @import-openapi="handleImportOpenapi" />
+ + diff --git a/apps/frontend/src/components/workbench/api-tree/ApiTree.vue b/apps/frontend/src/components/workbench/api-tree/ApiTree.vue index 2c6f4b7..ced9f42 100644 --- a/apps/frontend/src/components/workbench/api-tree/ApiTree.vue +++ b/apps/frontend/src/components/workbench/api-tree/ApiTree.vue @@ -27,6 +27,7 @@ const emits = defineEmits<{ (e: 'deleteGroup', group: GroupNodeWithApis): void (e: 'cloneApi', api: ApiBrief): void (e: 'deleteApi', api: ApiBrief): void + (e: 'importOpenapi'): void }>() const apiTreeStore = useApiTreeStore() @@ -108,9 +109,10 @@ onMounted(() => { - +
() const apiTreeStore = useApiTreeStore() @@ -141,6 +143,24 @@ watch(searchInput, (val) => { + + + + + + + + 导入 OpenAPI + + +
diff --git a/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiDialog.vue b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiDialog.vue new file mode 100644 index 0000000..b62bf2d --- /dev/null +++ b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiDialog.vue @@ -0,0 +1,190 @@ + + + diff --git a/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiPreview.vue b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiPreview.vue new file mode 100644 index 0000000..1707d54 --- /dev/null +++ b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiPreview.vue @@ -0,0 +1,295 @@ + + + diff --git a/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiResult.vue b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiResult.vue new file mode 100644 index 0000000..75f904a --- /dev/null +++ b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiResult.vue @@ -0,0 +1,184 @@ + + + diff --git a/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiUpload.vue b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiUpload.vue new file mode 100644 index 0000000..955d201 --- /dev/null +++ b/apps/frontend/src/components/workbench/dialogs/import/ImportOpenapiUpload.vue @@ -0,0 +1,203 @@ + + +