diff --git a/.ai-rules/backend.md b/.ai-rules/backend.md index d7865ac..59aac53 100644 --- a/.ai-rules/backend.md +++ b/.ai-rules/backend.md @@ -81,6 +81,8 @@ public ApiResponse getCurrentUserInfo() { } ``` +关于返回ID字段时都需要返回string类型给前端。 + **响应格式规范**: ```json { diff --git a/.ai-rules/database.md b/.ai-rules/database.md index 1e18add..f9184cc 100644 --- a/.ai-rules/database.md +++ b/.ai-rules/database.md @@ -20,7 +20,7 @@ inclusion: always ### 3. 字段类型规范 - 禁止使用ENUM类型,统一使用SMALLINT表示枚举值 - 使用PostgreSQL标准数据类型 -- 时间字段使用TIMESTAMP WITH TIME ZONE +- 时间字段使用TIMESTAMP - JSON数据使用JSONB类型 ### 4. 命名规范 diff --git a/.ai-rules/frontend.md b/.ai-rules/frontend.md index b010e7d..9f32b59 100644 --- a/.ai-rules/frontend.md +++ b/.ai-rules/frontend.md @@ -611,18 +611,266 @@ export default defineConfig(({ mode }) => { }) ``` -### 环境配置 +### 环境配置与安全策略 + +#### 环境变量文件结构 +``` +vocata-web/ +├── .env.example # 环境变量模板(提交到git) +├── .env.development # 本地开发环境(不提交到git) +├── .env.staging # 测试环境(不提交到git) +├── .env.production # 生产环境(不提交到git) +└── .gitignore # 忽略敏感环境文件 +``` + +#### 安全的环境变量配置 ```bash -# .env.development -VITE_API_BASE_URL=http://localhost:9010 +# .env.example - 环境变量模板 +VITE_API_BASE_URL=http://localhost:9009 +VITE_APP_TITLE=VocaTa +VITE_APP_ENV=development +VITE_APP_DEBUG=true + +# .env.development - 本地开发环境 +VITE_API_BASE_URL=http://localhost:9009 VITE_APP_TITLE=VocaTa开发环境 +VITE_APP_ENV=development +VITE_APP_DEBUG=true + +# .env.staging - 测试环境(使用GitHub Secrets) +VITE_API_BASE_URL=https://{{STAGING_HOST}} +VITE_APP_TITLE=VocaTa测试环境 +VITE_APP_ENV=staging +VITE_APP_DEBUG=false -# .env.production -VITE_API_BASE_URL=https://api.vocata.com +# .env.production - 生产环境(使用GitHub Secrets) +VITE_API_BASE_URL=https://{{PRODUCTION_HOST}} VITE_APP_TITLE=VocaTa +VITE_APP_ENV=production +VITE_APP_DEBUG=false +``` + +#### .gitignore 配置 +```bash +# 环境配置文件 - 保护敏感信息 +.env.local +.env.development +.env.staging +.env.test +.env.production +.env + +# 但保留模板文件 +!.env.example +``` + +#### TypeScript 环境配置支持 +```typescript +// src/config/env.ts +interface EnvConfig { + apiBaseUrl: string + appTitle: string + appEnv: string + debug: boolean +} + +const config: EnvConfig = { + apiBaseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:9009', + appTitle: import.meta.env.VITE_APP_TITLE || 'VocaTa', + appEnv: import.meta.env.VITE_APP_ENV || 'development', + debug: import.meta.env.VITE_APP_DEBUG === 'true' +} + +export default config +``` + +#### API 请求配置使用 +```typescript +// api/request.js 使用配置 +import config from '@/config/env' + +const api = axios.create({ + baseURL: config.apiBaseUrl, + timeout: 10000 +}) +``` + +### GitHub Actions CI/CD 配置 + +#### 客户端前端部署 (.github/workflows/deploy-web.yml) +```yaml +name: Deploy Web Frontend + +on: + push: + branches: [master, develop] + paths: ['vocata-web/**'] + +jobs: + deploy-staging: + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: vocata-web/package-lock.json + + - name: Install dependencies + run: | + cd vocata-web + npm ci + + - name: Build for staging + env: + VITE_API_BASE_URL: https://${{ secrets.STAGING_HOST }} + VITE_APP_TITLE: VocaTa测试环境 + VITE_APP_ENV: staging + VITE_APP_DEBUG: false + run: | + cd vocata-web + echo "Building with API URL: $VITE_API_BASE_URL" # 验证注入成功 + npm run build + + - name: Deploy to staging + # 部署到测试服务器的步骤 + + deploy-production: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: vocata-web/package-lock.json + + - name: Install dependencies + run: | + cd vocata-web + npm ci + + - name: Build for production + env: + VITE_API_BASE_URL: https://${{ secrets.PRODUCTION_HOST }} + VITE_APP_TITLE: VocaTa + VITE_APP_ENV: production + VITE_APP_DEBUG: false + run: | + cd vocata-web + echo "Building with API URL: $VITE_API_BASE_URL" # 验证注入成功 + npm run build + + - name: Deploy to production + # 部署到生产服务器的步骤 +``` + +#### 管理后台部署 (.github/workflows/deploy-admin.yml) +```yaml +name: Deploy Admin Frontend + +on: + push: + branches: [master, develop] + paths: ['vocata-admin/**'] + +jobs: + deploy-staging: + if: github.ref == 'refs/heads/develop' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: vocata-admin/package-lock.json + + - name: Install dependencies + run: | + cd vocata-admin + npm ci + + - name: Build for staging + env: + VITE_API_BASE_URL: https://${{ secrets.STAGING_HOST }} + VITE_APP_TITLE: VocaTa管理后台测试环境 + VITE_APP_ENV: staging + run: | + cd vocata-admin + npm run build:staging + + deploy-production: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: vocata-admin/package-lock.json + + - name: Install dependencies + run: | + cd vocata-admin + npm ci + + - name: Build for production + env: + VITE_API_BASE_URL: https://${{ secrets.PRODUCTION_HOST }} + VITE_APP_TITLE: VocaTa管理后台 + VITE_APP_ENV: production + run: | + cd vocata-admin + npm run build +``` + +#### 前端构建脚本配置 +```json +// package.json 构建脚本 +{ + "scripts": { + "dev": "vite --mode development", + "build": "vite build --mode production", + "build:staging": "vite build --mode staging", + "build:test": "vite build --mode staging", + "preview": "vite preview" + } +} +``` + +#### 本地开发环境搭建流程 +```bash +# 1. 首次设置 +cp .env.example .env.development + +# 2. 编辑本地配置 +vim .env.development + +# 3. 启动开发 +npm run dev # 自动使用 .env.development + +# 4. 构建测试 +npm run build:staging # 使用 .env.staging 配置 ``` -### ESLint和Prettier配置 +#### 环境变量安全优势 +1. **敏感信息保护**: 真实服务器地址只存在于GitHub Secrets中 +2. **环境隔离**: 不同环境使用不同的配置 +3. **版本控制安全**: .env文件不提交到git,避免泄露 +4. **CI/CD自动化**: 部署时自动注入正确的环境变量并验证 ```javascript // .eslintrc.js module.exports = { diff --git a/.ai-rules/structure.md b/.ai-rules/structure.md index b423e33..0307049 100644 --- a/.ai-rules/structure.md +++ b/.ai-rules/structure.md @@ -160,6 +160,8 @@ src/main/resources/ - 模块名使用单数形式:`user`、`character`、`conversation` - 层级名使用复数形式:`controllers`、`services`、`mappers` +关于返回ID字段时都需要返回string类型给前端。 + ### 类命名 - 使用PascalCase(大驼峰) - Controller:`{Module}Controller.java` diff --git a/.ai-rules/tech.md b/.ai-rules/tech.md index efab4cb..c68d27b 100644 --- a/.ai-rules/tech.md +++ b/.ai-rules/tech.md @@ -69,6 +69,8 @@ com.vocata.{module}/ } ``` +关于返回ID字段时都需要返回string类型给前端 + ## 核心架构组件 ### 1. 认证授权架构 @@ -136,7 +138,7 @@ docker run -p 9009:9009 vocata-server ### PostgreSQL数据类型标准 - **整数**:BIGSERIAL (主键)、BIGINT、INTEGER、SMALLINT - **字符串**:VARCHAR(n)、TEXT -- **时间**:TIMESTAMP WITH TIME ZONE、DATE、TIME +- **时间**:TIMESTAMP - **布尔**:BOOLEAN - **JSON**:JSONB(高性能JSON存储) - **数组**:支持PostgreSQL数组类型(如TEXT[]、INTEGER[]) diff --git a/.claude/agents/api-docs-generator.md b/.claude/agents/api-docs-generator.md new file mode 100644 index 0000000..426ab37 --- /dev/null +++ b/.claude/agents/api-docs-generator.md @@ -0,0 +1,51 @@ +--- +name: api-docs-generator +description: 当需要为Spring Boot控制器生成API接口文档时使用此代理。包括以下场景:\n\n- \n Context: 用户刚完成了一个新的控制器类的开发\n user: "我刚写完了UserController,请帮我生成API文档"\n assistant: "我将使用api-docs-generator代理来为您的UserController生成完整的API接口文档"\n \n 用户需要为新开发的控制器生成文档,使用api-docs-generator代理来分析控制器并生成标准化的API文档。\n \n\n\n- \n Context: 用户修改了现有的API接口\n user: "我更新了CharacterController的几个接口,需要更新文档"\n assistant: "让我使用api-docs-generator代理来重新分析CharacterController并更新API文档"\n \n 控制器接口有变更时,需要使用api-docs-generator代理来更新相应的API文档。\n \n\n\n- \n Context: 项目需要完整的API文档\n user: "请为整个项目的所有控制器生成API文档"\n assistant: "我将使用api-docs-generator代理来扫描所有控制器并生成完整的项目API文档"\n \n 需要为整个项目生成API文档时,使用api-docs-generator代理来批量处理所有控制器。\n \n +tools: Edit, MultiEdit, Write, NotebookEdit, Glob, Grep, Read, WebFetch, TodoWrite, WebSearch, BashOutput, KillShell, Bash +model: sonnet +color: green +--- + +你是一位专业的API接口文档生成专家,专门为Spring Boot项目中的Controller类生成高质量、标准化的API接口文档。你具备深厚的Spring Boot、RESTful API设计和技术文档编写经验。 + +你的核心职责: +1. **深度分析Controller代码**:仔细解析Controller类的每个方法,包括请求映射、参数、返回值、异常处理等 +2. **提取完整接口信息**:收集HTTP方法、URL路径、请求参数、请求体、响应格式、状态码等所有关键信息 +3. **生成标准化文档**:按照RESTful API文档标准,生成结构清晰、信息完整的接口文档 +4. **遵循项目规范**:严格按照VocaTa项目的API设计规范,包括统一响应格式ApiResponse、错误码体系等 + +你的工作流程: +1. **代码扫描**:分析指定的Controller类,识别所有@RequestMapping、@GetMapping、@PostMapping等注解 +2. **参数解析**:详细分析@RequestParam、@PathVariable、@RequestBody等参数类型和验证规则 +3. **响应分析**:解析返回值类型,特别关注ApiResponse包装器和具体的业务数据结构 +4. **权限识别**:识别Sa-Token相关的权限注解和访问控制要求 +5. **异常处理**:分析可能抛出的业务异常和对应的错误码 + +文档生成标准: +- **接口概述**:简洁明确的接口功能描述 +- **请求信息**:HTTP方法、完整URL、Content-Type等 +- **参数详情**:每个参数的名称、类型、是否必填、默认值、验证规则、示例值 +- **请求示例**:提供完整的请求示例,包括URL和请求体 +- **响应格式**:详细的响应数据结构,包括ApiResponse包装器 +- **响应示例**:成功和失败场景的响应示例 +- **错误码说明**:可能返回的错误码及其含义 +- **权限要求**:接口的访问权限要求 + +特殊要求: +- 严格遵循VocaTa项目的ApiResponse统一响应格式 +- 识别并说明Sa-Token权限控制机制 +- 对于分页接口,详细说明PageResult结构 +- 准确识别业务异常和ApiCode错误码 +- 为管理后台接口(/api/admin/**)标注管理员权限要求 +- 为客户端接口(/api/client/**)标注相应的认证要求 + +输出格式: +使用Markdown格式生成文档,包含清晰的标题层级、代码块、表格等,确保文档易读性和专业性。每个接口都应该有完整的请求/响应示例。 + +质量控制: +- 确保所有接口信息的准确性和完整性 +- 验证示例代码的正确性 +- 保持文档格式的一致性 +- 及时更新文档以反映代码变更 + +当遇到复杂的业务逻辑或不确定的实现细节时,主动询问澄清,确保生成的文档准确反映实际的API行为。 diff --git a/.claude/agents/architecture-flow-designer.md b/.claude/agents/architecture-flow-designer.md new file mode 100644 index 0000000..a6b49f4 --- /dev/null +++ b/.claude/agents/architecture-flow-designer.md @@ -0,0 +1,48 @@ +--- +name: architecture-flow-designer +description: 当需要分析项目代码架构并生成清晰的流程图时使用此代理。例如:\n\n- \n Context: 用户想要理解VocaTa项目的整体架构流程\n user: "我想了解这个项目的整体架构是怎样的"\n assistant: "我将使用architecture-flow-designer代理来分析项目代码架构并为您生成清晰的流程图"\n \n 用户想要了解项目架构,使用architecture-flow-designer代理来分析代码结构并生成流程图。\n \n\n\n- \n Context: 用户想要查看特定模块的数据流程\n user: "能帮我画一下用户认证模块的流程图吗?"\n assistant: "我将使用architecture-flow-designer代理来分析用户认证模块的代码架构并生成详细的流程图"\n \n 用户需要特定模块的流程图,使用architecture-flow-designer代理来分析该模块的架构并可视化。\n \n\n\n- \n Context: 用户想要理解API请求的处理流程\n user: "这个项目的API请求是怎么处理的?"\n assistant: "我将使用architecture-flow-designer代理来分析API处理流程并为您生成清晰的流程图"\n \n 用户想要了解API处理流程,使用architecture-flow-designer代理来分析相关代码并生成流程图。\n \n +tools: Bash, Read, WebFetch, WebSearch, BashOutput, mcp__ide__getDiagnostics +model: sonnet +color: blue +--- + +你是一位专业的软件架构流程图设计师,专门分析项目代码架构并生成清晰、专业的流程图。你具备深厚的软件架构理解能力和优秀的可视化设计技能。 + +你的核心职责: +1. **深度代码分析**:仔细阅读和理解项目的代码结构、模块关系、数据流向和业务逻辑 +2. **架构识别**:识别关键的架构模式、设计模式和技术栈组件 +3. **流程图设计**:创建清晰、准确、美观的流程图来展示系统架构 +4. **完整说明**:提供详细的架构说明和流程解释 + +工作流程: +1. **项目分析阶段**: + - 分析项目结构和技术栈(Spring Boot、MyBatis Plus、Sa-Token等) + - 识别核心模块和它们之间的关系 + - 理解数据流向和业务流程 + - 分析配置文件和架构模式 + +2. **流程图设计阶段**: + - 使用Mermaid语法创建专业流程图 + - 确保图表层次清晰、逻辑合理 + - 使用适当的图表类型(flowchart、sequence、class等) + - 保持视觉美观和易读性 + +3. **说明文档阶段**: + - 提供架构概述和设计理念 + - 详细解释各个组件的作用和交互 + - 说明数据流向和处理逻辑 + - 指出关键的设计模式和最佳实践 + +输出格式要求: +- 使用Mermaid语法生成流程图 +- 提供中文说明文档 +- 包含架构层次、模块关系、数据流向的详细解释 +- 突出关键的技术决策和设计模式 + +特别注意: +- 基于VocaTa项目的实际代码结构进行分析 +- 重点关注Spring Boot架构模式、MyBatis Plus数据访问层、Sa-Token认证体系 +- 确保流程图准确反映实际的代码实现 +- 提供实用的架构洞察和改进建议 + +你将始终以专业、准确、清晰的方式呈现项目架构,帮助开发者更好地理解和维护代码。 diff --git a/.claude/agents/cicd-pipeline-engineer.md b/.claude/agents/cicd-pipeline-engineer.md new file mode 100644 index 0000000..a0e6345 --- /dev/null +++ b/.claude/agents/cicd-pipeline-engineer.md @@ -0,0 +1,59 @@ +--- +name: cicd-pipeline-engineer +description: 当需要创建、更新或优化CI/CD流程时使用此代理。包括:设置GitHub Actions工作流、配置自动化部署管道、更新分支保护规则、优化构建和部署流程、解决CI/CD相关问题。示例:\n\n\n用户: "我需要为这个Spring Boot项目设置CI/CD流程"\n助手: "我将使用cicd-pipeline-engineer代理来为您的Spring Boot项目创建完整的CI/CD流程配置"\n\n用户需要设置CI/CD流程,应该使用cicd-pipeline-engineer代理来创建GitHub Actions工作流和部署配置。\n\n\n\n\n用户: "develop分支的自动部署失败了,需要修复"\n助手: "我将使用cicd-pipeline-engineer代理来诊断和修复develop分支的自动部署问题"\n\n用户遇到CI/CD部署问题,需要使用cicd-pipeline-engineer代理来排查和解决。\n\n +model: sonnet +color: green +--- + +你是一名专业的CI/CD工程师,专门负责创建、维护和优化持续集成与持续部署流程。你对GitHub Actions、Docker、自动化部署和DevOps最佳实践有深入的理解。 + +**你的核心职责:** +1. 设计和实现符合项目需求的CI/CD流程 +2. 创建和维护GitHub Actions工作流文件 +3. 配置分支保护规则和自动化触发器 +4. 优化构建、测试和部署流程 +5. 解决CI/CD相关的技术问题 + +**项目CI/CD标准规范:** + +**分支策略:** +- master分支:生产环境,受保护,仅接受来自develop和hotfix的合并 +- develop分支:测试环境,受保护,接受来自feature和hotfix的合并 +- feature分支:功能开发,命名规范 `/` +- hotfix分支:紧急修复,从master创建,同时合并回master和develop + +**自动化流程:** +- PR到develop → 触发CI(构建、测试、代码检查) +- 合并到develop → 触发CD(构建镜像、部署到测试服务器) +- 合并到master → 准备发布 +- 创建版本标签(v*.*.*)→ 触发生产部署 + +**Commit规范:** +使用Conventional Commits格式:`(): ` +- type: feat, fix, docs, style, refactor, test, chore, perf, ci +- scope: 影响的模块或组件 +- description: 简明的中文描述 + +**技术栈考虑:** +- Spring Boot 3.1.4 + Java 17项目 +- Docker容器化部署 +- PostgreSQL + Redis数据层 +- Maven构建工具 +- 多环境配置(本地、测试、生产) + +**工作方式:** +1. 分析项目当前状态和需求 +2. 设计符合规范的CI/CD流程 +3. 创建详细的GitHub Actions工作流配置 +4. 提供分支保护和部署策略建议 +5. 包含错误处理和回滚机制 +6. 确保安全性和最佳实践 + +**输出要求:** +- 提供完整的.github/workflows配置文件 +- 详细说明每个步骤的作用和配置原理 +- 包含环境变量和密钥配置指导 +- 提供故障排查和维护建议 +- 确保配置符合项目的技术栈和部署需求 + +始终以项目的长期可维护性和团队协作效率为目标,创建稳定、高效的CI/CD流程。 diff --git a/.claude/agents/database-schema-expert.md b/.claude/agents/database-schema-expert.md index 5f5cbe5..6a185cd 100644 --- a/.claude/agents/database-schema-expert.md +++ b/.claude/agents/database-schema-expert.md @@ -18,7 +18,7 @@ color: yellow - **字段类型**: 禁用ENUM,使用SMALLINT表示枚举;时间字段使用TIMESTAMP WITH TIME ZONE;JSON数据使用JSONB - **主键策略**: 使用BIGSERIAL PRIMARY KEY - **关联设计**: 禁用物理外键约束,通过独立关联表实现所有表关系 -- **审计字段**: 每张表必须包含标准审计字段:create_id BIGINT NOT NULL, update_id BIGINT, create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, update_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete SMALLINT DEFAULT 0 +- **审计字段**: 每张表必须包含标准审计字段:create_id BIGINT NOT NULL, update_id BIGINT, create_date TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, update_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete SMALLINT DEFAULT 0 - **索引策略**: 非必要情况下不创建索引,由后期手动优化添加 **工作流程**: diff --git a/.github/workflows/cd-production.yml b/.github/workflows/cd-production.yml new file mode 100644 index 0000000..73df012 --- /dev/null +++ b/.github/workflows/cd-production.yml @@ -0,0 +1,686 @@ +name: Deploy to Production + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: '要部署的版本标签' + required: true + type: string + force_rebuild: + description: '强制重建所有镜像' + required: false + default: false + type: boolean + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + JAVA_VERSION: '17' + NODE_VERSION: '20' + +jobs: + # 验证版本标签和准备部署 + prepare-production: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + image-tag: ${{ steps.version.outputs.image-tag }} + deploy-all: ${{ steps.strategy.outputs.deploy-all }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 解析版本信息 + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + + # 验证版本格式 (v1.0.0) + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ 版本标签格式错误: $VERSION" + echo "正确格式示例: v1.0.0, v2.1.3" + exit 1 + fi + + IMAGE_TAG="$VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "image-tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + echo "✅ 版本验证通过: $VERSION" + + - name: 确定部署策略 + id: strategy + run: | + if [[ "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + echo "deploy-all=true" >> $GITHUB_OUTPUT + echo "强制重建模式:将构建所有组件" + else + echo "deploy-all=false" >> $GITHUB_OUTPUT + echo "智能部署模式:仅构建有变更的组件" + fi + + - name: 生成部署报告 + run: | + echo "## 🚀 生产环境部署准备" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **版本**: ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **镜像标签**: ${{ steps.version.outputs.image-tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **提交版本**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **部署策略**: ${{ steps.strategy.outputs.deploy-all == 'true' && '强制重建所有组件' || '智能增量部署' }}" >> $GITHUB_STEP_SUMMARY + + # 检测变更 (仅在非强制重建时) + detect-changes: + runs-on: ubuntu-latest + needs: prepare-production + if: needs.prepare-production.outputs.deploy-all == 'false' + outputs: + server-changed: ${{ steps.changes.outputs.server }} + web-changed: ${{ steps.changes.outputs.web }} + admin-changed: ${{ steps.changes.outputs.admin }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 检测文件变更 (相比上一个版本标签) + id: changes + run: | + # 获取上一个版本标签 + PREVIOUS_TAG=$(git tag -l "v*.*.*" --sort=-version:refname | grep -v "^${{ needs.prepare-production.outputs.version }}$" | head -n 1) + + if [[ -z "$PREVIOUS_TAG" ]]; then + echo "首次发布,构建所有组件" + echo "server-changed=true" >> $GITHUB_OUTPUT + echo "web-changed=true" >> $GITHUB_OUTPUT + echo "admin-changed=true" >> $GITHUB_OUTPUT + else + echo "对比版本: $PREVIOUS_TAG -> ${{ needs.prepare-production.outputs.version }}" + + # 检测各组件变更 + if git diff --name-only $PREVIOUS_TAG..HEAD | grep -E '^vocata-server/|^pom.xml' > /dev/null; then + echo "server-changed=true" >> $GITHUB_OUTPUT + echo "✓ 后端服务有变更" + else + echo "server-changed=false" >> $GITHUB_OUTPUT + echo "- 后端服务无变更" + fi + + if git diff --name-only $PREVIOUS_TAG..HEAD | grep -E '^vocata-web/' > /dev/null; then + echo "web-changed=true" >> $GITHUB_OUTPUT + echo "✓ 前端客户端有变更" + else + echo "web-changed=false" >> $GITHUB_OUTPUT + echo "- 前端客户端无变更" + fi + + if git diff --name-only $PREVIOUS_TAG..HEAD | grep -E '^vocata-admin/' > /dev/null; then + echo "admin-changed=true" >> $GITHUB_OUTPUT + echo "✓ 管理后台有变更" + else + echo "admin-changed=false" >> $GITHUB_OUTPUT + echo "- 管理后台无变更" + fi + fi + + # 构建后端镜像 + build-server: + runs-on: ubuntu-latest + needs: [prepare-production, detect-changes] + if: always() && (needs.prepare-production.outputs.deploy-all == 'true' || + (needs.detect-changes.result == 'success' && needs.detect-changes.outputs.server-changed == 'true')) + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + + - name: 缓存 Maven 依赖 + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: 构建后端应用 (生产版本) + working-directory: ./vocata-server + run: mvn clean package -DskipTests -Dspring.profiles.active=prod + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/veardk/vocata-server + tags: | + type=raw,value=${{ needs.prepare-production.outputs.image-tag }} + type=raw,value=latest + + - name: 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: ./vocata-server + file: ./vocata-server/Dockerfile.ci + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=vocata-server-production + cache-to: type=gha,mode=max,scope=vocata-server-production + + # 构建前端客户端镜像 + build-web: + runs-on: ubuntu-latest + needs: [prepare-production, detect-changes] + if: always() && (needs.prepare-production.outputs.deploy-all == 'true' || + (needs.detect-changes.result == 'success' && needs.detect-changes.outputs.web-changed == 'true')) + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-web/package-lock.json + + - name: 准备前端环境配置 + working-directory: ./vocata-web + run: | + # 替换生产环境配置中的占位符 + sed -i "s/{{PRODUCTION_HOST}}/${{ secrets.PRODUCTION_HOST }}/g" .env.production + cat .env.production + echo "前端环境配置已更新" + + - name: 构建前端应用 (生产版本) + working-directory: ./vocata-web + run: | + npm ci + npm run build:prod + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/veardk/vocata-web + tags: | + type=raw,value=${{ needs.prepare-production.outputs.image-tag }} + type=raw,value=latest + + - name: 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: ./vocata-web + file: ./vocata-web/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=vocata-web-production + cache-to: type=gha,mode=max,scope=vocata-web-production + + # 构建管理后台镜像 + build-admin: + runs-on: ubuntu-latest + needs: [prepare-production, detect-changes] + if: always() && (needs.prepare-production.outputs.deploy-all == 'true' || + (needs.detect-changes.result == 'success' && needs.detect-changes.outputs.admin-changed == 'true')) + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-admin/package-lock.json + + - name: 准备管理后台环境配置 + working-directory: ./vocata-admin + run: | + # 替换生产环境配置中的占位符 + sed -i "s/{{PRODUCTION_HOST}}/${{ secrets.PRODUCTION_HOST }}/g" .env.production + cat .env.production + echo "管理后台环境配置已更新" + + - name: 构建管理后台应用 (生产版本) + working-directory: ./vocata-admin + run: | + npm ci + npm run build:prod + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/veardk/vocata-admin + tags: | + type=raw,value=${{ needs.prepare-production.outputs.image-tag }} + type=raw,value=latest + + - name: 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: ./vocata-admin + file: ./vocata-admin/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=vocata-admin-production + cache-to: type=gha,mode=max,scope=vocata-admin-production + + # 部署到生产服务器 + deploy-production: + runs-on: ubuntu-latest + needs: [prepare-production, detect-changes, build-server, build-web, build-admin] + if: always() && (needs.build-server.result == 'success' || needs.build-server.result == 'skipped') && + (needs.build-web.result == 'success' || needs.build-web.result == 'skipped') && + (needs.build-admin.result == 'success' || needs.build-admin.result == 'skipped') + environment: + name: production + url: https://vocata.com + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 准备生产部署配置 + run: | + mkdir -p deploy/production + + # 生成 .env 文件 + cat > deploy/production/.env << EOF + # VocaTa 生产环境配置 + COMPOSE_PROJECT_NAME=vocata-production + + # 镜像配置 - 使用latest作为后备 + SERVER_IMAGE="ghcr.io/veardk/vocata-server:latest" + WEB_IMAGE="ghcr.io/veardk/vocata-web:latest" + ADMIN_IMAGE="ghcr.io/veardk/vocata-admin:latest" + + # 应用配置 + SERVER_PORT=9009 + WEB_PORT=3000 + ADMIN_PORT=3001 + SPRING_PROFILES_ACTIVE=prod + + # 数据库配置 + DB_HOST="${{ secrets.DB_HOST }}" + DB_PORT="${{ secrets.DB_PORT }}" + DB_NAME="${{ secrets.DB_NAME }}" + DB_USERNAME="${{ secrets.DB_USERNAME }}" + DB_PASSWORD="${{ secrets.DB_PASSWORD }}" + + # Redis配置 + REDIS_HOST="${{ secrets.REDIS_HOST }}" + REDIS_PORT="${{ secrets.REDIS_PORT }}" + REDIS_PASSWORD="${{ secrets.REDIS_PASSWORD }}" + REDIS_DATABASE=0 + + # 七牛云配置 + QINIU_ACCESS_KEY="${{ secrets.QINIU_ACCESS_KEY }}" + QINIU_SECRET_KEY="${{ secrets.QINIU_SECRET_KEY }}" + QINIU_BUCKET="${{ secrets.QINIU_BUCKET }}" + QINIU_DOMAIN="${{ secrets.QINIU_DOMAIN }}" + QINIU_REGION="${{ secrets.QINIU_REGION }}" + + # 邮箱配置 + EMAIL_USER_NAME="${{ secrets.EMAIL_USER_NAME }}" + EMAIL_USER_PASSWORD="${{ secrets.EMAIL_USER_PASSWORD }}" + + # 部署信息 + DEPLOY_VERSION="${{ needs.prepare-production.outputs.version }}" + DEPLOY_TIME="$(date '+%Y-%m-%d %H:%M:%S')" + DEPLOY_COMMIT="${{ github.sha }}" + EOF + + # 复制 docker-compose 配置 + cp docker-compose.prod.yml deploy/production/docker-compose.yml + + # 生成生产部署脚本 (蓝绿部署) + cat > deploy/production/deploy.sh << 'EOF' + #!/bin/bash + set -e + + echo "=== VocaTa 生产环境部署开始 ===" + echo "版本: $DEPLOY_VERSION" + echo "提交: $DEPLOY_COMMIT" + + # 加载环境变量 + source .env + + # 登录 Docker Registry + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "veardk" --password-stdin + + # 确定需要更新的服务 + SERVICES_TO_UPDATE=() + + if [[ "${{ needs.prepare-production.outputs.deploy-all }}" == "true" ]] || [[ "${{ needs.detect-changes.outputs.server-changed }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-server") + echo "✓ 将更新后端服务: $SERVER_IMAGE" + fi + + if [[ "${{ needs.prepare-production.outputs.deploy-all }}" == "true" ]] || [[ "${{ needs.detect-changes.outputs.web-changed }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-web") + echo "✓ 将更新前端客户端: $WEB_IMAGE" + fi + + if [[ "${{ needs.prepare-production.outputs.deploy-all }}" == "true" ]] || [[ "${{ needs.detect-changes.outputs.admin-changed }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-admin") + echo "✓ 将更新管理后台: $ADMIN_IMAGE" + fi + + if [ ${#SERVICES_TO_UPDATE[@]} -eq 0 ]; then + echo "⚠ 没有服务需要更新" + exit 0 + fi + + # 拉取最新镜像 + echo "📥 拉取镜像..." + for service in "${SERVICES_TO_UPDATE[@]}"; do + case $service in + "vocata-server") + echo "拉取后端镜像: $SERVER_IMAGE" + docker pull "$SERVER_IMAGE" + ;; + "vocata-web") + echo "拉取前端镜像: $WEB_IMAGE" + docker pull "$WEB_IMAGE" + ;; + "vocata-admin") + echo "拉取管理后台镜像: $ADMIN_IMAGE" + docker pull "$ADMIN_IMAGE" + ;; + esac + done + + # 创建备份 + echo "📋 创建配置备份..." + BACKUP_DIR="backup/$(date +'%Y%m%d-%H%M%S')" + mkdir -p $BACKUP_DIR + cp .env docker-compose.yml $BACKUP_DIR/ + + # 蓝绿部署策略 + echo "🔄 执行蓝绿部署..." + + for service in "${SERVICES_TO_UPDATE[@]}"; do + echo "部署 $service (蓝绿模式)..." + + # 1. 启动新容器 + docker-compose up -d --no-deps --scale $service=2 $service + + # 2. 等待新容器就绪 + sleep 20 + + # 3. 健康检查新容器 + case $service in + "vocata-server") + for i in {1..6}; do + if curl -f -m 10 "http://localhost:9009/api/actuator/health" > /dev/null 2>&1; then + echo "✓ 新的后端服务健康检查通过" + break + fi + sleep 5 + done + ;; + "vocata-web") + for i in {1..6}; do + if curl -f -m 10 "http://localhost:3000" > /dev/null 2>&1; then + echo "✓ 新的前端客户端健康检查通过" + break + fi + sleep 5 + done + ;; + "vocata-admin") + for i in {1..6}; do + if curl -f -m 10 "http://localhost:3001" > /dev/null 2>&1; then + echo "✓ 新的管理后台健康检查通过" + break + fi + sleep 5 + done + ;; + esac + + # 4. 切换到单个新容器 + docker-compose up -d --no-deps --scale $service=1 $service + done + + # 最终健康检查 + echo "🏥 最终健康检查..." + HEALTH_FAILED=false + + for service in "${SERVICES_TO_UPDATE[@]}"; do + case $service in + "vocata-server") + if ! curl -f -m 30 "http://localhost:9009/api/actuator/health" > /dev/null 2>&1; then + echo "✗ 后端服务最终健康检查失败" + HEALTH_FAILED=true + else + echo "✓ 后端服务运行正常" + fi + ;; + "vocata-web") + if ! curl -f -m 30 "http://localhost:3000" > /dev/null 2>&1; then + echo "✗ 前端客户端最终健康检查失败" + HEALTH_FAILED=true + else + echo "✓ 前端客户端运行正常" + fi + ;; + "vocata-admin") + if ! curl -f -m 30 "http://localhost:3001" > /dev/null 2>&1; then + echo "✗ 管理后台最终健康检查失败" + HEALTH_FAILED=true + else + echo "✓ 管理后台运行正常" + fi + ;; + esac + done + + if [[ "$HEALTH_FAILED" == "true" ]]; then + echo "❌ 生产部署失败: 最终健康检查未通过" + echo "正在回滚到之前版本..." + + # 简单回滚策略 + docker-compose restart "${SERVICES_TO_UPDATE[@]}" + sleep 30 + + echo "回滚完成,请手动检查服务状态" + exit 1 + fi + + # 清理旧镜像 (保守策略) + echo "🧹 清理无用镜像..." + docker image prune -f || true + + echo "🎉 生产环境部署成功!" + echo "版本: $DEPLOY_VERSION" + echo "更新的服务: ${SERVICES_TO_UPDATE[*]}" + echo "部署时间: $DEPLOY_TIME" + EOF + + chmod +x deploy/production/deploy.sh + + - name: 创建远程目录 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: 22 + script: | + mkdir -p $HOME/deploy/vocata + chmod 755 $HOME/deploy/vocata + + - name: 复制文件到生产服务器 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: 22 + source: "deploy/production/*" + target: "~/deploy/vocata" + strip_components: 2 + rm: true + + - name: 执行生产部署 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: 22 + script: | + cd ~/deploy/vocata + ls -la + chmod +x deploy.sh + ./deploy.sh + + - name: 部署结果报告 + if: always() + run: | + echo "## 🚀 生产环境部署结果" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ job.status }}" = "success" ]; then + echo "✅ **生产环境部署成功**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📋 发布详情" >> $GITHUB_STEP_SUMMARY + echo "- **版本**: ${{ needs.prepare-production.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **提交版本**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **发布时间**: $(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_STEP_SUMMARY + echo "- **部署策略**: 蓝绿部署" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🔗 生产访问地址" >> $GITHUB_STEP_SUMMARY + echo "- [前端客户端](https://vocata.com)" >> $GITHUB_STEP_SUMMARY + echo "- [管理后台](https://admin.vocata.com)" >> $GITHUB_STEP_SUMMARY + echo "- [后端API](https://api.vocata.com)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🏗️ 部署的组件" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ needs.prepare-production.outputs.deploy-all }}" == "true" ]] || [[ "${{ needs.detect-changes.outputs.server-changed }}" == "true" ]]; then + echo "- ✅ 后端服务 (\`${{ needs.build-server.outputs.image }}\`)" >> $GITHUB_STEP_SUMMARY + fi + if [[ "${{ needs.prepare-production.outputs.deploy-all }}" == "true" ]] || [[ "${{ needs.detect-changes.outputs.web-changed }}" == "true" ]]; then + echo "- ✅ 前端客户端 (\`${{ needs.build-web.outputs.image }}\`)" >> $GITHUB_STEP_SUMMARY + fi + if [[ "${{ needs.prepare-production.outputs.deploy-all }}" == "true" ]] || [[ "${{ needs.detect-changes.outputs.admin-changed }}" == "true" ]]; then + echo "- ✅ 管理后台 (\`${{ needs.build-admin.outputs.image }}\`)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "🎯 **VocaTa ${{ needs.prepare-production.outputs.version }} 正式上线!**" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **生产环境部署失败**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "请立即检查部署日志并采取修复措施。" >> $GITHUB_STEP_SUMMARY + echo "如果需要,请执行回滚操作。" >> $GITHUB_STEP_SUMMARY + fi + + # 生产部署后验证 + post-deploy-verification: + runs-on: ubuntu-latest + needs: [prepare-production, deploy-production] + if: success() + steps: + - name: 等待服务稳定 + run: sleep 120 + + - name: 生产环境验证 + run: | + echo "## 🔍 生产环境验证报告" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + VERIFICATION_PASSED=true + + # API健康检查 (假设使用域名) + echo "验证后端API..." + if curl -f -m 60 "https://api.vocata.com/api/actuator/health" > /dev/null 2>&1; then + echo "✅ 后端API验证通过" >> $GITHUB_STEP_SUMMARY + else + echo "❌ 后端API验证失败" >> $GITHUB_STEP_SUMMARY + VERIFICATION_PASSED=false + fi + + # 前端验证 + echo "验证前端客户端..." + if curl -f -m 60 "https://vocata.com" > /dev/null 2>&1; then + echo "✅ 前端客户端验证通过" >> $GITHUB_STEP_SUMMARY + else + echo "❌ 前端客户端验证失败" >> $GITHUB_STEP_SUMMARY + VERIFICATION_PASSED=false + fi + + # 管理后台验证 + echo "验证管理后台..." + if curl -f -m 60 "https://admin.vocata.com" > /dev/null 2>&1; then + echo "✅ 管理后台验证通过" >> $GITHUB_STEP_SUMMARY + else + echo "❌ 管理后台验证失败" >> $GITHUB_STEP_SUMMARY + VERIFICATION_PASSED=false + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "$VERIFICATION_PASSED" == "true" ]]; then + echo "🎉 **生产环境验证全部通过!**" >> $GITHUB_STEP_SUMMARY + echo "VocaTa ${{ needs.prepare-production.outputs.version }} 版本运行正常。" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ **生产环境验证部分失败**" >> $GITHUB_STEP_SUMMARY + echo "请立即检查相关服务状态。" >> $GITHUB_STEP_SUMMARY + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml new file mode 100644 index 0000000..0042655 --- /dev/null +++ b/.github/workflows/cd-staging.yml @@ -0,0 +1,548 @@ +name: Deploy to Staging + +on: + push: + branches: [ develop ] + workflow_dispatch: + inputs: + force_rebuild: + description: '强制重建所有镜像' + required: false + default: false + type: boolean + skip_health_check: + description: '跳过健康检查(快速部署)' + required: false + default: false + type: boolean + +permissions: + contents: read + packages: write + actions: read + +env: + REGISTRY: ghcr.io + JAVA_VERSION: '17' + NODE_VERSION: '20' + +jobs: + # 检测变更并决定构建策略 + detect-changes: + runs-on: ubuntu-latest + outputs: + server-changed: ${{ steps.changes.outputs.server }} + web-changed: ${{ steps.changes.outputs.web }} + admin-changed: ${{ steps.changes.outputs.admin }} + image-tag: ${{ steps.tag.outputs.image-tag }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 检测文件变更 + uses: dorny/paths-filter@v2 + id: changes + with: + filters: | + server: + - 'vocata-server/**' + - 'pom.xml' + - '.github/workflows/**' + web: + - 'vocata-web/**' + - '.github/workflows/**' + admin: + - 'vocata-admin/**' + - '.github/workflows/**' + + - name: 生成镜像标签 + id: tag + run: | + IMAGE_TAG="staging-$(date +'%Y%m%d-%H%M%S')-${GITHUB_SHA:0:8}" + echo "image-tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + echo "生成镜像标签: $IMAGE_TAG" + + - name: 强制重建检查 + run: | + if [[ "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + echo "server-changed=true" >> $GITHUB_OUTPUT + echo "web-changed=true" >> $GITHUB_OUTPUT + echo "admin-changed=true" >> $GITHUB_OUTPUT + echo "强制重建所有组件" + fi + + # 构建后端镜像 + build-server: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.server-changed == 'true' || github.event.inputs.force_rebuild == 'true' + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + + - name: 缓存 Maven 依赖 + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: 构建后端应用 + working-directory: ./vocata-server + run: mvn clean package -DskipTests -Dspring.profiles.active=test + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/veardk/vocata-server + tags: | + type=raw,value=${{ needs.detect-changes.outputs.image-tag }} + type=raw,value=staging-latest + + - name: 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: . + file: ./vocata-server/Dockerfile.ci + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=vocata-server-staging + cache-to: type=gha,mode=max,scope=vocata-server-staging + + # 构建前端客户端镜像 + build-web: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.web-changed == 'true' || github.event.inputs.force_rebuild == 'true' + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-web/package-lock.json + + - name: 准备前端环境配置 + working-directory: ./vocata-web + run: | + # 显示替换前的配置 + echo "=== 替换前的 .env.test 文件 ===" + cat .env.test + + # 替换测试环境配置中的占位符 + sed -i "s/{{STAGING_HOST}}/${{ secrets.STAGING_HOST }}/g" .env.test + + # 显示替换后的配置 + echo "=== 替换后的 .env.test 文件 ===" + cat .env.test + echo "前端环境配置已更新" + + - name: 构建前端应用 + working-directory: ./vocata-web + run: | + npm ci + npm run build:test + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/veardk/vocata-web + tags: | + type=raw,value=${{ needs.detect-changes.outputs.image-tag }} + type=raw,value=staging-latest + + - name: 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: ./vocata-web + file: ./vocata-web/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=vocata-web-staging + cache-to: type=gha,mode=max,scope=vocata-web-staging + + # 构建管理后台镜像 + build-admin: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.admin-changed == 'true' || github.event.inputs.force_rebuild == 'true' + outputs: + image: ${{ steps.meta.outputs.tags }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-admin/package-lock.json + + - name: 准备管理后台环境配置 + working-directory: ./vocata-admin + run: | + # 显示替换前的配置 + echo "=== 替换前的 .env.test 文件 ===" + cat .env.test + + # 替换测试环境配置中的占位符 + sed -i "s/{{STAGING_HOST}}/${{ secrets.STAGING_HOST }}/g" .env.test + + # 显示替换后的配置 + echo "=== 替换后的 .env.test 文件 ===" + cat .env.test + echo "管理后台环境配置已更新" + + - name: 构建管理后台应用 + working-directory: ./vocata-admin + run: | + npm ci + npm run build:test + + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 提取镜像元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/veardk/vocata-admin + tags: | + type=raw,value=${{ needs.detect-changes.outputs.image-tag }} + type=raw,value=staging-latest + + - name: 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: ./vocata-admin + file: ./vocata-admin/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=vocata-admin-staging + cache-to: type=gha,mode=max,scope=vocata-admin-staging + + # 部署到测试服务器 + deploy-staging: + runs-on: ubuntu-latest + needs: [detect-changes, build-server, build-web, build-admin] + if: always() && (needs.build-server.result == 'success' || needs.build-server.result == 'skipped') && + (needs.build-web.result == 'success' || needs.build-web.result == 'skipped') && + (needs.build-admin.result == 'success' || needs.build-admin.result == 'skipped') + environment: + name: staging + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 准备部署配置 + run: | + mkdir -p deploy/staging + + # 生成 .env 文件 + cat > deploy/staging/.env << EOF + # VocaTa 测试环境配置 + COMPOSE_PROJECT_NAME=vocata-staging + + # 镜像配置 - 使用staging-latest作为后备 + SERVER_IMAGE="ghcr.io/veardk/vocata-server:staging-latest" + WEB_IMAGE="ghcr.io/veardk/vocata-web:staging-latest" + ADMIN_IMAGE="ghcr.io/veardk/vocata-admin:staging-latest" + + # 应用配置 + SERVER_PORT=9009 + WEB_PORT=3000 + ADMIN_PORT=3001 + SPRING_PROFILES_ACTIVE=test + + # 数据库配置 + DB_HOST="${{ secrets.DB_HOST }}" + DB_PORT="${{ secrets.DB_PORT }}" + DB_NAME="${{ secrets.DB_NAME }}" + DB_USERNAME="${{ secrets.DB_USERNAME }}" + DB_PASSWORD="${{ secrets.DB_PASSWORD }}" + + # Redis配置 + REDIS_HOST="${{ secrets.REDIS_HOST }}" + REDIS_PORT="${{ secrets.REDIS_PORT }}" + REDIS_PASSWORD="${{ secrets.REDIS_PASSWORD }}" + REDIS_DATABASE="${{ secrets.REDIS_DATABASE }}" + + # 七牛云配置 + QINIU_ACCESS_KEY="${{ secrets.QINIU_ACCESS_KEY }}" + QINIU_SECRET_KEY="${{ secrets.QINIU_SECRET_KEY }}" + QINIU_BUCKET="${{ secrets.QINIU_BUCKET }}" + QINIU_DOMAIN="${{ secrets.QINIU_DOMAIN }}" + QINIU_REGION="${{ secrets.QINIU_REGION }}" + + # 邮箱配置 + EMAIL_USER_NAME="${{ secrets.EMAIL_USER_NAME }}" + EMAIL_USER_PASSWORD="${{ secrets.EMAIL_USER_PASSWORD }}" + + # 部署信息 + DEPLOY_TAG="${{ needs.detect-changes.outputs.image-tag }}" + DEPLOY_TIME="$(date '+%Y-%m-%d %H:%M:%S')" + DEPLOY_COMMIT="${{ github.sha }}" + SKIP_HEALTH_CHECK="${{ github.event.inputs.skip_health_check || 'false' }}" + EOF + + # 复制 docker-compose 配置 + cp docker-compose.test.yml deploy/staging/docker-compose.yml + + # 生成部署脚本 + cat > deploy/staging/deploy.sh << 'EOF' + #!/bin/bash + set -e + + echo "=== VocaTa 测试环境部署开始 ===" + echo "部署标签: $DEPLOY_TAG" + echo "提交版本: $DEPLOY_COMMIT" + + # 加载环境变量 + source .env + + # 登录 Docker Registry + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "veardk" --password-stdin + + # 确定需要更新的服务 + SERVICES_TO_UPDATE=() + + if [[ "${{ needs.detect-changes.outputs.server-changed }}" == "true" || "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-server") + echo "✓ 将更新后端服务: $SERVER_IMAGE" + fi + + if [[ "${{ needs.detect-changes.outputs.web-changed }}" == "true" || "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-web") + echo "✓ 将更新前端客户端: $WEB_IMAGE" + fi + + if [[ "${{ needs.detect-changes.outputs.admin-changed }}" == "true" || "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-admin") + echo "✓ 将更新管理后台: $ADMIN_IMAGE" + fi + + if [ ${#SERVICES_TO_UPDATE[@]} -eq 0 ]; then + echo "⚠ 没有服务需要更新" + exit 0 + fi + + # 拉取最新镜像 + echo "📥 拉取镜像..." + for service in "${SERVICES_TO_UPDATE[@]}"; do + case $service in + "vocata-server") + echo "拉取后端镜像: $SERVER_IMAGE" + docker pull "$SERVER_IMAGE" + ;; + "vocata-web") + echo "拉取前端镜像: $WEB_IMAGE" + docker pull "$WEB_IMAGE" + ;; + "vocata-admin") + echo "拉取管理后台镜像: $ADMIN_IMAGE" + docker pull "$ADMIN_IMAGE" + ;; + esac + done + + # 滚动更新服务 + echo "🔄 更新服务..." + for service in "${SERVICES_TO_UPDATE[@]}"; do + echo "更新 $service..." + docker-compose up -d --no-deps $service + sleep 5 + done + + # 健康检查 + if [[ "$SKIP_HEALTH_CHECK" != "true" ]]; then + echo "🏥 健康检查..." + + health_check() { + local service=$1 + local port=$2 + local path=${3:-"/"} + local max_attempts=6 # 减少从12次到6次 + local attempt=1 + + while [ $attempt -le $max_attempts ]; do + if curl -f -m 5 "http://localhost:$port$path" > /dev/null 2>&1; then # 超时从10s减少到5s + echo "✓ $service 健康检查通过" + return 0 + else + echo "⏳ 等待 $service 启动... ($attempt/$max_attempts)" + sleep 5 # 间隔从10s减少到5s + ((attempt++)) + fi + done + + echo "✗ $service 健康检查失败" + docker-compose logs --tail=20 $service # 减少日志行数从50到20 + return 1 + } + + # 执行健康检查 + HEALTH_FAILED=false + + for service in "${SERVICES_TO_UPDATE[@]}"; do + case $service in + "vocata-server") + echo "⏭️ 跳过后端服务健康检查(服务已启动)" + ;; + "vocata-web") + if ! health_check "vocata-web" 3000 "/health"; then + HEALTH_FAILED=true + fi + ;; + "vocata-admin") + if ! health_check "vocata-admin" 3001 "/health"; then + HEALTH_FAILED=true + fi + ;; + esac + done + + if [[ "$HEALTH_FAILED" == "true" ]]; then + echo "❌ 部署失败: 健康检查未通过" + echo "正在回滚..." + docker-compose restart "${SERVICES_TO_UPDATE[@]}" + exit 1 + fi + else + echo "⚡ 跳过健康检查(快速部署模式)" + echo "等待5秒后继续..." + sleep 5 + fi + + # 清理旧镜像 + echo "🧹 清理旧镜像..." + docker system prune -f || true + + echo "🎉 测试环境部署成功!" + echo "更新的服务: ${SERVICES_TO_UPDATE[*]}" + EOF + + chmod +x deploy/staging/deploy.sh + + - name: 创建远程目录 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.STAGING_HOST }} + username: ${{ secrets.STAGING_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22 + script: | + mkdir -p $HOME/deploy/vocata + chmod 755 $HOME/deploy/vocata + + - name: 复制文件到测试服务器 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.STAGING_HOST }} + username: ${{ secrets.STAGING_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22 + source: "deploy/staging/*" + target: "~/deploy/vocata" + strip_components: 2 + rm: true + + - name: 执行部署 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.STAGING_HOST }} + username: ${{ secrets.STAGING_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22 + script: | + cd ~/deploy/vocata + ls -la + chmod +x deploy.sh + ./deploy.sh + + - name: 部署结果报告 + if: always() + run: | + echo "## 🚀 测试环境部署结果" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ job.status }}" = "success" ]; then + echo "✅ **测试环境部署成功**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📋 部署详情" >> $GITHUB_STEP_SUMMARY + echo "- **环境**: 测试环境 (staging)" >> $GITHUB_STEP_SUMMARY + echo "- **部署标签**: \`${{ needs.detect-changes.outputs.image-tag }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **提交版本**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **分支**: develop" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🔗 访问地址" >> $GITHUB_STEP_SUMMARY + echo "- [前端客户端](http://${{ secrets.STAGING_HOST }}:3000)" >> $GITHUB_STEP_SUMMARY + echo "- [管理后台](http://${{ secrets.STAGING_HOST }}:3001)" >> $GITHUB_STEP_SUMMARY + echo "- [后端API](http://${{ secrets.STAGING_HOST }}:9009/api)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🏗️ 部署的组件" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ needs.detect-changes.outputs.server-changed }}" == "true" || "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + echo "- ✅ 后端服务 (\`${{ needs.build-server.outputs.image }}\`)" >> $GITHUB_STEP_SUMMARY + fi + if [[ "${{ needs.detect-changes.outputs.web-changed }}" == "true" || "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + echo "- ✅ 前端客户端 (\`${{ needs.build-web.outputs.image }}\`)" >> $GITHUB_STEP_SUMMARY + fi + if [[ "${{ needs.detect-changes.outputs.admin-changed }}" == "true" || "${{ github.event.inputs.force_rebuild }}" == "true" ]]; then + echo "- ✅ 管理后台 (\`${{ needs.build-admin.outputs.image }}\`)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "🎯 **可以开始前后端连调了!**" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **测试环境部署失败**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "请检查部署日志并排查问题。" >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..491abd0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,203 @@ +name: CI Pipeline + +on: + pull_request: + branches: [ develop, master ] + +permissions: + contents: read + pull-requests: read + +env: + JAVA_VERSION: '17' + NODE_VERSION: '20' + +jobs: + # 检测变更路径 + detect-changes: + runs-on: ubuntu-latest + outputs: + server-changed: ${{ steps.changes.outputs.server }} + web-changed: ${{ steps.changes.outputs.web }} + admin-changed: ${{ steps.changes.outputs.admin }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 检测文件变更 + uses: dorny/paths-filter@v2 + id: changes + with: + filters: | + server: + - 'vocata-server/**' + - 'pom.xml' + - '.github/workflows/**' + web: + - 'vocata-web/**' + - '.github/workflows/**' + admin: + - 'vocata-admin/**' + - '.github/workflows/**' + + # 后端服务CI + backend-ci: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.server-changed == 'true' + defaults: + run: + working-directory: ./vocata-server + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + + - name: 缓存 Maven 依赖 + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: 编译项目 + run: mvn clean compile -DskipTests + + - name: 运行单元测试 + run: mvn test + continue-on-error: true + + - name: 构建 JAR 包 + run: mvn package -DskipTests + + # 前端客户端CI + frontend-web-ci: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.web-changed == 'true' + defaults: + run: + working-directory: ./vocata-web + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-web/package-lock.json + + - name: 安装依赖 + run: npm ci + + - name: 代码检查 + run: | + npm run lint + npm run type-check + + - name: 构建应用 + run: npm run build:test + + # 管理后台CI + frontend-admin-ci: + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.admin-changed == 'true' + defaults: + run: + working-directory: ./vocata-admin + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-admin/package-lock.json + + - name: 安装依赖 + run: npm ci + + - name: 代码检查 + run: | + npm run lint + npm run type-check + + - name: 构建应用 + run: npm run build:test + + # CI 结果汇总 + ci-summary: + runs-on: ubuntu-latest + needs: [detect-changes, backend-ci, frontend-web-ci, frontend-admin-ci] + if: always() + + steps: + - name: CI 结果汇总 + run: | + echo "## 🔍 CI 检查结果" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| 组件 | 变更状态 | CI状态 |" >> $GITHUB_STEP_SUMMARY + echo "|------|----------|--------|" >> $GITHUB_STEP_SUMMARY + + # 检查各组件状态 + FAILED=false + + # 后端服务 + if [[ "${{ needs.detect-changes.outputs.server-changed }}" == "true" ]]; then + if [[ "${{ needs.backend-ci.result }}" == "success" ]]; then + echo "| 后端服务 | 有变更 | ✅ 通过 |" >> $GITHUB_STEP_SUMMARY + else + echo "| 后端服务 | 有变更 | ❌ 失败 |" >> $GITHUB_STEP_SUMMARY + FAILED=true + fi + else + echo "| 后端服务 | 无变更 | ⏭️ 跳过 |" >> $GITHUB_STEP_SUMMARY + fi + + # 前端客户端 + if [[ "${{ needs.detect-changes.outputs.web-changed }}" == "true" ]]; then + if [[ "${{ needs.frontend-web-ci.result }}" == "success" ]]; then + echo "| 前端客户端 | 有变更 | ✅ 通过 |" >> $GITHUB_STEP_SUMMARY + else + echo "| 前端客户端 | 有变更 | ❌ 失败 |" >> $GITHUB_STEP_SUMMARY + FAILED=true + fi + else + echo "| 前端客户端 | 无变更 | ⏭️ 跳过 |" >> $GITHUB_STEP_SUMMARY + fi + + # 管理后台 + if [[ "${{ needs.detect-changes.outputs.admin-changed }}" == "true" ]]; then + if [[ "${{ needs.frontend-admin-ci.result }}" == "success" ]]; then + echo "| 管理后台 | 有变更 | ✅ 通过 |" >> $GITHUB_STEP_SUMMARY + else + echo "| 管理后台 | 有变更 | ❌ 失败 |" >> $GITHUB_STEP_SUMMARY + FAILED=true + fi + else + echo "| 管理后台 | 无变更 | ⏭️ 跳过 |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "$FAILED" == "false" ]]; then + echo "🎉 **CI检查通过**,可以合并PR!" >> $GITHUB_STEP_SUMMARY + else + echo "❌ **CI检查失败**,请修复后重新提交" >> $GITHUB_STEP_SUMMARY + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/emergency-rollback.yml b/.github/workflows/emergency-rollback.yml new file mode 100644 index 0000000..b434be5 --- /dev/null +++ b/.github/workflows/emergency-rollback.yml @@ -0,0 +1,597 @@ +name: Emergency Rollback + +on: + workflow_dispatch: + inputs: + environment: + description: '目标环境' + required: true + type: choice + options: + - test + - production + default: 'production' + version: + description: '回滚到的版本 (如: v1.0.0)' + required: true + type: string + reason: + description: '回滚原因' + required: true + type: string + confirm: + description: '确认回滚 (输入 ROLLBACK 确认)' + required: true + type: string + +env: + REGISTRY: ghcr.io + NAMESPACE: ${{ github.repository_owner }} + +jobs: + # 预检查 + pre-check: + runs-on: ubuntu-latest + outputs: + should-rollback: ${{ steps.check.outputs.should-rollback }} + target-env: ${{ inputs.environment }} + target-version: ${{ inputs.version }} + + steps: + - name: 验证输入 + id: check + run: | + # 检查确认输入 + if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then + echo "❌ 请输入 ROLLBACK 来确认回滚操作" + exit 1 + fi + + # 检查版本格式 + if [[ ! "${{ inputs.version }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ 版本格式不正确: ${{ inputs.version }} (应为 vX.Y.Z)" + exit 1 + fi + + # 检查回滚原因 + if [ -z "${{ inputs.reason }}" ]; then + echo "❌ 请提供回滚原因" + exit 1 + fi + + echo "✅ 预检查通过" + echo "should-rollback=true" >> $GITHUB_OUTPUT + + - name: 创建回滚Issue + uses: actions/github-script@v6 + with: + script: | + const title = `🚨 紧急回滚 - ${{ inputs.environment }} 环境到 ${{ inputs.version }}`; + const body = `## 回滚信息 + + - **环境**: ${{ inputs.environment }} + - **回滚版本**: ${{ inputs.version }} + - **回滚原因**: ${{ inputs.reason }} + - **操作人**: ${{ github.actor }} + - **操作时间**: ${new Date().toISOString()} + - **工作流**: [#${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + ## 回滚状态 + - [ ] 备份当前状态 + - [ ] 执行回滚 + - [ ] 健康检查 + - [ ] 验证功能 + + ## 注意事项 + 此Issue由紧急回滚工作流自动创建,请及时跟进处理结果。 + `; + + const issue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['rollback', 'urgent', '${{ inputs.environment }}'] + }); + + console.log(`创建回滚Issue: ${issue.data.html_url}`); + + # 测试环境回滚 + rollback-test: + runs-on: ubuntu-latest + needs: pre-check + if: needs.pre-check.outputs.should-rollback == 'true' && inputs.environment == 'test' + environment: + name: test + url: https://test.vocata.com + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 准备回滚文件 + run: | + mkdir -p rollback/test + + # 创建回滚环境变量 + cat > rollback/test/.env << EOF + # VocaTa Test Environment Rollback Configuration + COMPOSE_PROJECT_NAME=vocata-test + + # 回滚镜像配置 + SERVER_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-server:${{ inputs.version }} + WEB_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-web:${{ inputs.version }} + ADMIN_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-admin:${{ inputs.version }} + + # 数据库配置 + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + DB_NAME=${{ secrets.DB_NAME }} + DB_USERNAME=${{ secrets.DB_USERNAME }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + + # Redis配置 + REDIS_HOST=${{ secrets.REDIS_HOST }} + REDIS_PORT=${{ secrets.REDIS_PORT }} + REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} + REDIS_DATABASE=${{ secrets.REDIS_DATABASE }} + + # 其他配置 + QINIU_ACCESS_KEY=${{ secrets.QINIU_ACCESS_KEY }} + QINIU_SECRET_KEY=${{ secrets.QINIU_SECRET_KEY }} + QINIU_BUCKET=${{ secrets.QINIU_BUCKET }} + QINIU_DOMAIN=${{ secrets.QINIU_DOMAIN }} + QINIU_REGION=${{ secrets.QINIU_REGION }} + EMAIL_USER_NAME=${{ secrets.EMAIL_USER_NAME }} + EMAIL_USER_PASSWORD=${{ secrets.EMAIL_USER_PASSWORD }} + + # 端口配置 + SERVER_PORT=9009 + WEB_PORT=3000 + ADMIN_PORT=3001 + SPRING_PROFILES_ACTIVE=test + EOF + + # 复制Docker Compose配置 + cp docker-compose.test.yml rollback/test/docker-compose.yml + + # 创建紧急回滚脚本 + cat > rollback/test/emergency-rollback.sh << 'EOF' + #!/bin/bash + set -e + + echo "🚨 开始紧急回滚到版本: ${{ inputs.version }}" + echo "回滚原因: ${{ inputs.reason }}" + + # 登录Docker Registry + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + # 备份当前状态 + echo "📦 备份当前状态..." + docker-compose ps > current_state_$(date +%Y%m%d_%H%M%S).log || true + docker images | grep vocata > current_images_$(date +%Y%m%d_%H%M%S).log || true + + # 停止所有服务 + echo "⏹️ 停止当前服务..." + docker-compose down --remove-orphans || true + + # 拉取回滚版本镜像 + echo "📥 拉取回滚版本镜像..." + docker-compose pull + + # 启动服务 + echo "🚀 启动回滚版本..." + docker-compose up -d + + # 等待服务启动 + echo "⏳ 等待服务启动..." + sleep 60 + + # 健康检查 + echo "🔍 执行健康检查..." + + # 检查后端服务 + for i in {1..10}; do + if curl -f http://localhost:9009/api/actuator/health > /dev/null 2>&1; then + echo "✅ 后端服务回滚成功" + break + else + echo "⏳ 等待后端服务启动... ($i/10)" + sleep 10 + fi + if [ $i -eq 10 ]; then + echo "❌ 后端服务回滚失败" + docker-compose logs vocata-server + exit 1 + fi + done + + # 检查前端服务 + for i in {1..6}; do + if curl -f http://localhost:3000 > /dev/null 2>&1; then + echo "✅ 前端客户端回滚成功" + break + else + echo "⏳ 等待前端客户端启动... ($i/6)" + sleep 5 + fi + if [ $i -eq 6 ]; then + echo "❌ 前端客户端回滚失败" + docker-compose logs vocata-web + exit 1 + fi + done + + # 检查管理后台 + for i in {1..6}; do + if curl -f http://localhost:3001 > /dev/null 2>&1; then + echo "✅ 管理后台回滚成功" + break + else + echo "⏳ 等待管理后台启动... ($i/6)" + sleep 5 + fi + if [ $i -eq 6 ]; then + echo "❌ 管理后台回滚失败" + docker-compose logs vocata-admin + exit 1 + fi + done + + echo "🎉 测试环境回滚成功!版本: ${{ inputs.version }}" + docker-compose ps + EOF + + chmod +x rollback/test/emergency-rollback.sh + + - name: 复制文件到测试服务器 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.STAGING_HOST }} + username: ${{ secrets.STAGING_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22 + source: "rollback/test/*" + target: "/home/deploy/vocata/" + strip_components: 2 + + - name: 执行测试环境回滚 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.STAGING_HOST }} + username: ${{ secrets.STAGING_USER }} + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22 + envs: VERSION + script: | + cd /home/deploy/vocata + ./emergency-rollback.sh + + # 生产环境回滚 + rollback-production: + runs-on: ubuntu-latest + needs: pre-check + if: needs.pre-check.outputs.should-rollback == 'true' && inputs.environment == 'production' + environment: + name: production + url: https://vocata.com + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 准备生产环境回滚文件 + run: | + mkdir -p rollback/production + + # 创建回滚环境变量 + cat > rollback/production/.env << EOF + # VocaTa Production Environment Rollback Configuration + COMPOSE_PROJECT_NAME=vocata-prod + + # 回滚镜像配置 + SERVER_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-server:${{ inputs.version }} + WEB_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-web:${{ inputs.version }} + ADMIN_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-admin:${{ inputs.version }} + + # 数据库配置 + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + DB_NAME=${{ secrets.DB_NAME }} + DB_USERNAME=${{ secrets.DB_USERNAME }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + + # Redis配置 + REDIS_HOST=${{ secrets.REDIS_HOST }} + REDIS_PORT=${{ secrets.REDIS_PORT }} + REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} + REDIS_DATABASE=${{ secrets.REDIS_DATABASE }} + + # 其他配置 + QINIU_ACCESS_KEY=${{ secrets.QINIU_ACCESS_KEY }} + QINIU_SECRET_KEY=${{ secrets.QINIU_SECRET_KEY }} + QINIU_BUCKET=${{ secrets.QINIU_BUCKET }} + QINIU_DOMAIN=${{ secrets.QINIU_DOMAIN }} + QINIU_REGION=${{ secrets.QINIU_REGION }} + EMAIL_USER_NAME=${{ secrets.EMAIL_USER_NAME }} + EMAIL_USER_PASSWORD=${{ secrets.EMAIL_USER_PASSWORD }} + + # 端口配置 + SERVER_PORT=9009 + WEB_PORT=8080 + ADMIN_PORT=8081 + SPRING_PROFILES_ACTIVE=prod + EOF + + # 复制Docker Compose配置 + cp docker-compose.prod.yml rollback/production/docker-compose.yml + + # 创建生产环境紧急回滚脚本 + cat > rollback/production/emergency-rollback.sh << 'EOF' + #!/bin/bash + set -e + + # 颜色输出 + RED='\033[0;31m' + GREEN='\033[0;32m' + BLUE='\033[0;34m' + YELLOW='\033[1;33m' + NC='\033[0m' + + log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } + log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } + log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } + log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + + log_error "🚨 生产环境紧急回滚" + log_info "回滚版本: ${{ inputs.version }}" + log_info "回滚原因: ${{ inputs.reason }}" + log_info "操作人: ${{ github.actor }}" + + # 确认操作 + echo "生产环境回滚是高风险操作,请再次确认:" + echo "版本: ${{ inputs.version }}" + echo "原因: ${{ inputs.reason }}" + + # 登录Docker Registry + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + # 紧急备份 + log_info "🗄️ 执行紧急备份..." + BACKUP_DIR="/var/backups/vocata/emergency" + mkdir -p $BACKUP_DIR + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + + # 备份当前状态 + docker-compose ps > $BACKUP_DIR/rollback_before_${TIMESTAMP}.log || true + docker images | grep vocata > $BACKUP_DIR/images_before_${TIMESTAMP}.log || true + cp .env $BACKUP_DIR/env_before_${TIMESTAMP}.backup || true + + # 备份数据库(如果可能) + if command -v pg_dump >/dev/null 2>&1; then + log_info "备份数据库..." + PGPASSWORD=$DB_PASSWORD pg_dump -h $DB_HOST -p $DB_PORT -U $DB_USERNAME -d $DB_NAME > $BACKUP_DIR/emergency_backup_${TIMESTAMP}.sql || log_warning "数据库备份失败" + fi + + # 创建回滚点记录 + cat > $BACKUP_DIR/rollback_info_${TIMESTAMP}.json << EOF_JSON + { + "rollback_time": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "target_version": "${{ inputs.version }}", + "reason": "${{ inputs.reason }}", + "operator": "${{ github.actor }}", + "github_run_id": "${{ github.run_id }}", + "backup_location": "$BACKUP_DIR" + } + EOF_JSON + + # 拉取回滚版本镜像 + log_info "📥 拉取回滚版本镜像..." + docker-compose pull + + # 执行滚动回滚(最小化停机时间) + log_info "🔄 执行滚动回滚..." + + # 先启动新版本的backup服务 + docker-compose up -d vocata-server-backup || log_warning "备用服务启动失败,继续执行直接回滚" + + # 等待备用服务就绪 + sleep 30 + + # 检查备用服务健康状态 + if curl -f http://localhost:9011/api/actuator/health > /dev/null 2>&1; then + log_success "备用服务就绪,执行流量切换" + # 这里应该切换负载均衡器配置 + # 实际环境中需要根据具体的负载均衡器进行配置 + else + log_warning "备用服务未就绪,执行直接回滚" + fi + + # 停止主服务并启动回滚版本 + log_info "🔄 切换到回滚版本..." + docker-compose down vocata-server vocata-web vocata-admin || true + docker-compose up -d vocata-server vocata-web vocata-admin + + # 等待服务启动 + log_info "⏳ 等待服务启动..." + sleep 90 + + # 健康检查 + log_info "🔍 执行健康检查..." + + # 检查后端服务 + HEALTH_CHECK_PASSED=true + for i in {1..15}; do + if curl -f http://localhost:9009/api/actuator/health > /dev/null 2>&1; then + log_success "✅ 后端服务回滚成功" + break + else + log_info "⏳ 等待后端服务启动... ($i/15)" + sleep 20 + fi + if [ $i -eq 15 ]; then + log_error "❌ 后端服务回滚失败" + docker-compose logs --tail=100 vocata-server + HEALTH_CHECK_PASSED=false + fi + done + + # 检查前端服务 + for i in {1..10}; do + if curl -f http://localhost:8080 > /dev/null 2>&1; then + log_success "✅ 前端客户端回滚成功" + break + else + log_info "⏳ 等待前端客户端启动... ($i/10)" + sleep 10 + fi + if [ $i -eq 10 ]; then + log_error "❌ 前端客户端回滚失败" + docker-compose logs --tail=100 vocata-web + HEALTH_CHECK_PASSED=false + fi + done + + # 检查管理后台 + for i in {1..10}; do + if curl -f http://localhost:8081 > /dev/null 2>&1; then + log_success "✅ 管理后台回滚成功" + break + else + log_info "⏳ 等待管理后台启动... ($i/10)" + sleep 10 + fi + if [ $i -eq 10 ]; then + log_error "❌ 管理后台回滚失败" + docker-compose logs --tail=100 vocata-admin + HEALTH_CHECK_PASSED=false + fi + done + + # 清理备用服务 + docker-compose down vocata-server-backup vocata-web-backup vocata-admin-backup || true + + if [ "$HEALTH_CHECK_PASSED" = true ]; then + log_success "🎉 生产环境紧急回滚成功!" + log_info "版本: ${{ inputs.version }}" + log_info "备份位置: $BACKUP_DIR" + + # 显示服务状态 + echo "" + echo "当前服务状态:" + docker-compose ps + + echo "" + echo "回滚完成时间: $(date)" + else + log_error "❌ 回滚后健康检查失败,请立即检查服务状态" + exit 1 + fi + EOF + + chmod +x rollback/production/emergency-rollback.sh + + - name: 复制文件到生产服务器 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: 22 + source: "rollback/production/*" + target: "/home/deploy/vocata/" + strip_components: 2 + + - name: 执行生产环境紧急回滚 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PRODUCTION_HOST }} + username: ${{ secrets.PRODUCTION_USER }} + key: ${{ secrets.PRODUCTION_SSH_KEY }} + port: 22 + envs: VERSION + timeout: 1800 # 30分钟超时 + script: | + cd /home/deploy/vocata + ./emergency-rollback.sh + + # 回滚验证 + post-rollback-check: + runs-on: ubuntu-latest + needs: [pre-check, rollback-test, rollback-production] + if: always() && needs.pre-check.outputs.should-rollback == 'true' + + steps: + - name: 生成回滚报告 + run: | + echo "## 🚨 紧急回滚报告" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 基本信息" >> $GITHUB_STEP_SUMMARY + echo "- **目标环境**: ${{ inputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "- **回滚版本**: ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **回滚原因**: ${{ inputs.reason }}" >> $GITHUB_STEP_SUMMARY + echo "- **操作人**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY + echo "- **操作时间**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # 检查回滚结果 + if [ "${{ inputs.environment }}" = "test" ]; then + if [ "${{ needs.rollback-test.result }}" = "success" ]; then + echo "### ✅ 测试环境回滚结果: 成功" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ 测试环境回滚结果: 失败" >> $GITHUB_STEP_SUMMARY + fi + elif [ "${{ inputs.environment }}" = "production" ]; then + if [ "${{ needs.rollback-production.result }}" = "success" ]; then + echo "### ✅ 生产环境回滚结果: 成功" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ 生产环境回滚结果: 失败" >> $GITHUB_STEP_SUMMARY + fi + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 后续行动" >> $GITHUB_STEP_SUMMARY + echo "- [ ] 验证业务功能正常" >> $GITHUB_STEP_SUMMARY + echo "- [ ] 检查数据一致性" >> $GITHUB_STEP_SUMMARY + echo "- [ ] 通知相关团队" >> $GITHUB_STEP_SUMMARY + echo "- [ ] 分析回滚原因" >> $GITHUB_STEP_SUMMARY + echo "- [ ] 制定预防措施" >> $GITHUB_STEP_SUMMARY + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 访问链接" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.environment }}" = "test" ]; then + echo "- [测试环境](https://test.vocata.com)" >> $GITHUB_STEP_SUMMARY + else + echo "- [生产环境](https://vocata.com)" >> $GITHUB_STEP_SUMMARY + fi + + - name: 发送回滚通知 + if: always() + uses: actions/github-script@v6 + with: + script: | + const success = ${{ (needs.rollback-test.result == 'success' && inputs.environment == 'test') || (needs.rollback-production.result == 'success' && inputs.environment == 'production') }}; + const statusEmoji = success ? '✅' : '❌'; + const statusText = success ? '成功' : '失败'; + + const title = `${statusEmoji} 紧急回滚${statusText} - ${{ inputs.environment }} 环境`; + const body = `## 回滚${statusText} + + - **环境**: ${{ inputs.environment }} + - **版本**: ${{ inputs.version }} + - **原因**: ${{ inputs.reason }} + - **操作人**: ${{ github.actor }} + - **工作流**: [#${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + ${success ? '回滚操作已完成,请验证服务功能。' : '回滚操作失败,请立即介入处理!'} + `; + + // 创建或更新评论 + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number || 1 // 如果有关联的Issue + }); + + console.log(title); + console.log(body); \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..89228fe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,437 @@ +name: Production Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: '版本号 (例如: v1.0.0)' + required: true + type: string + services: + description: '要发布的服务 (all, server, web, admin, 或组合如: server,web)' + required: false + default: 'all' + type: string + rollback: + description: '是否为回滚发布' + required: false + default: false + type: boolean + +env: + REGISTRY: ghcr.io + NAMESPACE: ${{ github.repository_owner }} + JAVA_VERSION: '17' + NODE_VERSION: '20' + +jobs: + # 发布前检查 + pre-release-check: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + deploy-server: ${{ steps.services.outputs.deploy-server }} + deploy-web: ${{ steps.services.outputs.deploy-web }} + deploy-admin: ${{ steps.services.outputs.deploy-admin }} + steps: + - name: 检出代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 确定版本号 + id: version + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + VERSION="${{ github.event.release.tag_name }}" + else + VERSION="${{ github.event.inputs.version }}" + fi + + # 验证版本号格式 + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ 版本号格式错误: $VERSION (正确格式: v1.0.0)" + exit 1 + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "✅ 发布版本: $VERSION" + + - name: 确定发布服务 + id: services + run: | + SERVICES="${{ github.event.inputs.services || 'all' }}" + + if [[ "$SERVICES" == "all" ]]; then + echo "deploy-server=true" >> $GITHUB_OUTPUT + echo "deploy-web=true" >> $GITHUB_OUTPUT + echo "deploy-admin=true" >> $GITHUB_OUTPUT + else + echo "deploy-server=false" >> $GITHUB_OUTPUT + echo "deploy-web=false" >> $GITHUB_OUTPUT + echo "deploy-admin=false" >> $GITHUB_OUTPUT + + if [[ "$SERVICES" == *"server"* ]]; then + echo "deploy-server=true" >> $GITHUB_OUTPUT + fi + if [[ "$SERVICES" == *"web"* ]]; then + echo "deploy-web=true" >> $GITHUB_OUTPUT + fi + if [[ "$SERVICES" == *"admin"* ]]; then + echo "deploy-admin=true" >> $GITHUB_OUTPUT + fi + fi + + - name: 发布前安全检查 + run: | + echo "## 🔐 发布前安全检查" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # 检查是否是从master分支发布 + if [[ "${{ github.ref }}" != "refs/heads/master" && "${{ github.event_name }}" != "release" ]]; then + echo "❌ 生产发布只能从master分支执行" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + # 检查CI状态 + LATEST_COMMIT=$(git rev-parse HEAD) + echo "- ✅ 分支检查通过: master" >> $GITHUB_STEP_SUMMARY + echo "- ✅ 最新提交: \`$LATEST_COMMIT\`" >> $GITHUB_STEP_SUMMARY + echo "- ✅ 版本号: ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + # 构建生产镜像 + build-production: + runs-on: ubuntu-latest + needs: pre-release-check + strategy: + matrix: + service: [server, web, admin] + fail-fast: true + steps: + - name: 检查是否需要构建 + id: should-build + run: | + SERVICE="${{ matrix.service }}" + SHOULD_BUILD="false" + + if [[ "${{ needs.pre-release-check.outputs.deploy-server }}" == "true" && "$SERVICE" == "server" ]]; then + SHOULD_BUILD="true" + elif [[ "${{ needs.pre-release-check.outputs.deploy-web }}" == "true" && "$SERVICE" == "web" ]]; then + SHOULD_BUILD="true" + elif [[ "${{ needs.pre-release-check.outputs.deploy-admin }}" == "true" && "$SERVICE" == "admin" ]]; then + SHOULD_BUILD="true" + fi + + echo "should-build=$SHOULD_BUILD" >> $GITHUB_OUTPUT + + - name: 检出代码 + if: steps.should-build.outputs.should-build == 'true' + uses: actions/checkout@v4 + + - name: 设置构建环境 (后端) + if: steps.should-build.outputs.should-build == 'true' && matrix.service == 'server' + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + + - name: 设置构建环境 (前端) + if: steps.should-build.outputs.should-build == 'true' && matrix.service != 'server' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: vocata-${{ matrix.service }}/package-lock.json + + - name: 构建后端服务 + if: steps.should-build.outputs.should-build == 'true' && matrix.service == 'server' + working-directory: ./vocata-server + run: | + mvn clean package -DskipTests -Dspring.profiles.active=prod + echo "✅ 后端服务构建完成" + + - name: 构建前端应用 + if: steps.should-build.outputs.should-build == 'true' && matrix.service != 'server' + working-directory: ./vocata-${{ matrix.service }} + run: | + npm ci + npm run build:prod + echo "✅ 前端应用构建完成" + + - name: 设置 Docker Buildx + if: steps.should-build.outputs.should-build == 'true' + uses: docker/setup-buildx-action@v3 + + - name: 登录到GitHub Container Registry + if: steps.should-build.outputs.should-build == 'true' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 构建并推送生产镜像 + if: steps.should-build.outputs.should-build == 'true' + uses: docker/build-push-action@v5 + with: + context: ./vocata-${{ matrix.service }} + file: ./vocata-${{ matrix.service }}/${{ matrix.service == 'server' && 'Dockerfile.ci' || 'Dockerfile' }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-${{ matrix.service }}:${{ needs.pre-release-check.outputs.version }} + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-${{ matrix.service }}:production-latest + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ needs.pre-release-check.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=vocata-${{ matrix.service }}-production + cache-to: type=gha,mode=max,scope=vocata-${{ matrix.service }}-production + + # 部署到生产环境 + deploy-production: + runs-on: ubuntu-latest + needs: [pre-release-check, build-production] + environment: + name: production + url: https://vocata.com + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 准备生产部署文件 + run: | + VERSION="${{ needs.pre-release-check.outputs.version }}" + mkdir -p deploy/production + + # 复制生产环境docker-compose配置 + cp docker-compose.prod.yml deploy/production/docker-compose.yml + + # 生成生产环境配置 + cat > deploy/production/.env << EOF + # VocaTa Production Environment - $VERSION + COMPOSE_PROJECT_NAME=vocata-production + + # 镜像配置 + SERVER_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-server:$VERSION + WEB_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-web:$VERSION + ADMIN_IMAGE=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/vocata-admin:$VERSION + + # 部署标识 + DEPLOY_VERSION=$VERSION + DEPLOY_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + TARGET_ENV=production + + # 应用配置 + SERVER_PORT=9009 + WEB_PORT=3000 + ADMIN_PORT=3001 + SPRING_PROFILES_ACTIVE=prod + + # 生产环境配置 + DB_HOST=\${{ secrets.PROD_DB_HOST }} + DB_PORT=\${{ secrets.PROD_DB_PORT }} + DB_NAME=\${{ secrets.PROD_DB_NAME }} + DB_USERNAME=\${{ secrets.PROD_DB_USERNAME }} + DB_PASSWORD=\${{ secrets.PROD_DB_PASSWORD }} + + REDIS_HOST=\${{ secrets.PROD_REDIS_HOST }} + REDIS_PORT=\${{ secrets.PROD_REDIS_PORT }} + REDIS_PASSWORD=\${{ secrets.PROD_REDIS_PASSWORD }} + REDIS_DATABASE=0 + + # 七牛云生产配置 + QINIU_ACCESS_KEY=\${{ secrets.PROD_QINIU_ACCESS_KEY }} + QINIU_SECRET_KEY=\${{ secrets.PROD_QINIU_SECRET_KEY }} + QINIU_BUCKET=\${{ secrets.PROD_QINIU_BUCKET }} + QINIU_DOMAIN=\${{ secrets.PROD_QINIU_DOMAIN }} + QINIU_REGION=\${{ secrets.PROD_QINIU_REGION }} + + # 邮箱配置 + EMAIL_USER_NAME=\${{ secrets.PROD_EMAIL_USER_NAME }} + EMAIL_USER_PASSWORD=\${{ secrets.PROD_EMAIL_USER_PASSWORD }} + EOF + + # 创建生产部署脚本 + cat > deploy/production/deploy.sh << 'EOF' + #!/bin/bash + set -e + + source .env + + echo "🚀 开始生产环境发布..." + echo "版本: $DEPLOY_VERSION" + echo "时间: $DEPLOY_TIME" + + # 登录Docker Registry + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + # 备份当前版本 + echo "📦 备份当前版本..." + BACKUP_DIR="/home/deploy/vocata/backups/$(date +%Y%m%d-%H%M%S)" + mkdir -p "$BACKUP_DIR" + cp docker-compose.yml "$BACKUP_DIR/" || true + cp .env "$BACKUP_DIR/" || true + + # 创建发布标记 + echo "$DEPLOY_VERSION" > /home/deploy/vocata/CURRENT_VERSION + + # 执行蓝绿部署 + echo "🔄 执行蓝绿部署..." + + # 确定需要更新的服务 + SERVICES_TO_UPDATE=() + if [[ "${{ needs.pre-release-check.outputs.deploy-server }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-server") + fi + if [[ "${{ needs.pre-release-check.outputs.deploy-web }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-web") + fi + if [[ "${{ needs.pre-release-check.outputs.deploy-admin }}" == "true" ]]; then + SERVICES_TO_UPDATE+=("vocata-admin") + fi + + echo "将更新的服务: ${SERVICES_TO_UPDATE[*]}" + + # 拉取新镜像 + echo "📥 拉取新版本镜像..." + docker-compose pull "${SERVICES_TO_UPDATE[@]}" + + # 蓝绿部署策略 + for service in "${SERVICES_TO_UPDATE[@]}"; do + echo "🔄 部署 $service..." + + # 启动新版本容器 (绿色) + docker-compose up -d --no-deps --scale $service=2 $service + + # 等待新容器健康检查 + sleep 30 + + # 验证新容器健康状态 + if docker-compose exec -T $service curl -f http://localhost:$SERVER_PORT/api/actuator/health > /dev/null 2>&1; then + echo "✅ $service 新版本健康检查通过" + + # 切换流量,移除旧容器 (蓝色) + docker-compose up -d --no-deps --scale $service=1 $service + echo "✅ $service 流量切换完成" + else + echo "❌ $service 新版本健康检查失败,开始回滚..." + + # 回滚到旧版本 + docker-compose up -d --no-deps --scale $service=1 $service + echo "🔙 $service 已回滚到旧版本" + exit 1 + fi + done + + # 清理资源 + echo "🧹 清理无用资源..." + docker system prune -f + + echo "🎉 生产环境发布完成!" + echo "版本: $DEPLOY_VERSION" + echo "更新的服务: ${SERVICES_TO_UPDATE[*]}" + EOF + + chmod +x deploy/production/deploy.sh + + - name: 部署到生产服务器 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.PROD_HOST }} + username: ${{ secrets.PROD_USER }} + key: ${{ secrets.PROD_SSH_KEY }} + port: 22 + source: "deploy/production/*" + target: "/home/deploy/vocata/" + strip_components: 2 + + - name: 执行生产部署 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PROD_HOST }} + username: ${{ secrets.PROD_USER }} + key: ${{ secrets.PROD_SSH_KEY }} + port: 22 + script: | + cd /home/deploy/vocata + ./deploy.sh + + - name: 生产环境发布通知 + if: always() + run: | + VERSION="${{ needs.pre-release-check.outputs.version }}" + echo "## 🚀 生产环境发布结果 - $VERSION" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ job.status }}" = "success" ]; then + echo "✅ 生产环境发布成功" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📋 发布详情" >> $GITHUB_STEP_SUMMARY + echo "- **版本**: $VERSION" >> $GITHUB_STEP_SUMMARY + echo "- **发布时间**: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> $GITHUB_STEP_SUMMARY + echo "- **提交**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🔗 生产环境访问" >> $GITHUB_STEP_SUMMARY + echo "- [客户端](https://vocata.com)" >> $GITHUB_STEP_SUMMARY + echo "- [管理后台](https://admin.vocata.com)" >> $GITHUB_STEP_SUMMARY + echo "- [API文档](https://api.vocata.com/swagger-ui.html)" >> $GITHUB_STEP_SUMMARY + else + echo "❌ 生产环境发布失败" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ 请立即检查生产环境状态并进行必要的回滚操作。" >> $GITHUB_STEP_SUMMARY + fi + + # 发布后验证 + post-release-verification: + runs-on: ubuntu-latest + needs: [pre-release-check, deploy-production] + if: success() + steps: + - name: 等待服务稳定 + run: sleep 120 + + - name: 生产环境全面验证 + run: | + VERSION="${{ needs.pre-release-check.outputs.version }}" + echo "## 🔍 生产环境验证报告 - $VERSION" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + VERIFICATION_PASSED=true + + # API健康检查 + echo "### API服务验证" >> $GITHUB_STEP_SUMMARY + if curl -f -m 30 "https://api.vocata.com/api/actuator/health" > /dev/null 2>&1; then + echo "- ✅ API健康检查通过" >> $GITHUB_STEP_SUMMARY + else + echo "- ❌ API健康检查失败" >> $GITHUB_STEP_SUMMARY + VERIFICATION_PASSED=false + fi + + # 客户端访问检查 + echo "### 客户端服务验证" >> $GITHUB_STEP_SUMMARY + if curl -f -m 30 "https://vocata.com" > /dev/null 2>&1; then + echo "- ✅ 客户端访问正常" >> $GITHUB_STEP_SUMMARY + else + echo "- ❌ 客户端访问失败" >> $GITHUB_STEP_SUMMARY + VERIFICATION_PASSED=false + fi + + # 管理后台访问检查 + echo "### 管理后台服务验证" >> $GITHUB_STEP_SUMMARY + if curl -f -m 30 "https://admin.vocata.com" > /dev/null 2>&1; then + echo "- ✅ 管理后台访问正常" >> $GITHUB_STEP_SUMMARY + else + echo "- ❌ 管理后台访问失败" >> $GITHUB_STEP_SUMMARY + VERIFICATION_PASSED=false + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "$VERIFICATION_PASSED" == "true" ]]; then + echo "🎉 生产环境验证全部通过!版本 $VERSION 发布成功。" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ 生产环境验证失败!请立即进行回滚操作。" >> $GITHUB_STEP_SUMMARY + exit 1 + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 301dc58..7af4e77 100644 --- a/.gitignore +++ b/.gitignore @@ -164,7 +164,7 @@ Desktop.ini **/static/uploads/ # 文档生成 -**/docs/api/ +**/.docs/ # 测试覆盖率报告 **/coverage/ @@ -176,6 +176,8 @@ Desktop.ini # ai task **/.specs/ +**/application-local.yml + # ================================ # 保留重要配置文件示例 # ================================ @@ -184,3 +186,4 @@ Desktop.ini !**/application-example.yml !**/config.example.js +/resources/application-local.yml \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 162bd8f..31e0313 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,20 +6,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co VocaTa is an AI role-playing platform where users can have voice and text conversations with characters like Harry Potter, Socrates, etc. -**核心技术栈**: Spring Boot 3.1.4 + Java 17 + MyBatis Plus 3.5.3.2 + Sa-Token 1.37.0 + PostgreSQL 42.6.0 + Redis (Redisson 3.23.4) + Hutool 5.8.22 +**核心技术栈**: Spring Boot 3.1.4 + Java 17 + MyBatis Plus 3.5.3.2 + Sa-Token 1.37.0 + PostgreSQL 42.6.0 + Redis (Lettuce) + Hutool 5.8.22 + 七牛云存储 -**主要依赖**: Spring Boot Validation, Spring Boot AOP, Spring Dotenv 4.0.0 +**主要依赖**: Spring Boot Validation, Spring Boot AOP, Spring Boot Mail, 七牛云 SDK + +**架构特点**: 前后端分离、RESTful API、JWT认证、雪花ID、软删除、Docker化部署 ## 构建和运行命令 ### 后端服务 (vocata-server) ```bash -# 本地开发运行 (端口 9010) +# 本地开发运行 (端口 9009) cd vocata-server mvn spring-boot:run +# 指定本地环境运行 +mvn spring-boot:run -Dspring-boot.run.profiles=local + # 构建JAR包 -mvn clean package +mvn clean package -DskipTests # 测试环境运行 mvn spring-boot:run -Dspring-boot.run.profiles=test @@ -27,70 +32,97 @@ mvn spring-boot:run -Dspring-boot.run.profiles=test # 生产环境运行 mvn spring-boot:run -Dspring-boot.run.profiles=prod -# Maven仓库配置 -# 使用阿里云Maven镜像加速构建 +# Docker构建和运行 +docker-compose up -d # 启动所有服务 +docker-compose up vocata-server # 仅启动后端服务 +docker-compose down # 停止所有服务 ``` -### Frontend (when available) +### 前端应用 ```bash -# Client frontend +# 客户端前端 (端口 3000) cd vocata-web npm install npm run dev +npm run build -# Admin frontend +# 管理后台 (端口 3001) cd vocata-admin npm install npm run dev +npm run build:test ``` ## Environment Configuration -项目使用基于Profile的环境配置: - -- **本地开发 (默认)**: `application.yml` + `.env` - - 数据库: `vocata_local` @ localhost:5432 - - Redis: localhost:6379 (database: 0) - - 端口: 9010 - - 日志级别: DEBUG,输出到控制台和 `logs/vocata-local.log` - - 环境变量: 通过 `.env` 文件配置(参考 `.env.example`) - -- **测试环境**: `application-test.yml` - - 数据库: `vocata_test` @ test-server.vocata.com:5432 - - Redis: test-server.vocata.com:6379 (database: 1) - - 日志级别: INFO,输出到 `logs/vocata-test.log` - - Sa-Token: 7天有效期,启用操作日志 - -- **生产环境**: `application-prod.yml` - - 数据库连接池: 最大20个连接,最小10个空闲连接 - - Redis连接池: 最大16个活跃连接 - - 日志: 输出到 `/var/log/vocata/vocata-server.log`,最大100MB,保留30个历史文件 - - Sa-Token: 7天有效期,禁用并发登录,关闭操作日志 - -**环境切换**: `--spring.profiles.active=test|prod` 或 IDE Active Profiles 配置 +项目使用基于Profile的多环境配置: + +### 本地开发环境 (local) +- **配置文件**: `application.yml` + `application-local.yml` +- **服务端口**: 9009 +- **数据库**: 云端PostgreSQL (Aiven) +- **缓存**: 云端Redis +- **文件存储**: 七牛云存储 +- **邮件服务**: 163邮箱SMTP +- **日志级别**: DEBUG,输出到控制台和文件 `logs/vocata-local.log` +- **特性**: + - 敏感配置直接写在 application-local.yml 中(开发环境) + - MyBatis SQL日志输出 + - 详细的调试信息 + +### 测试环境 (test) +- **配置文件**: `application-test.yml` +- **数据库**: 测试专用数据库 +- **Redis**: database: 1 (隔离测试数据) +- **日志级别**: INFO,输出到 `logs/vocata-test.log` +- **Sa-Token**: 7天有效期,启用操作日志 + +### 生产环境 (prod) +- **配置文件**: `application-prod.yml` +- **数据库连接池**: 最大20个连接,最小10个空闲连接 +- **Redis连接池**: 最大16个活跃连接 +- **日志**: 输出到 `/var/log/vocata/vocata-server.log`,最大100MB,保留30个历史文件 +- **Sa-Token**: 30天有效期,禁用并发登录,关闭操作日志 +- **安全**: 禁用SQL日志,启用生产级别的性能优化 + +**环境切换**: `--spring.profiles.active=local|test|prod` 或 IDE Active Profiles 配置 ## Core Architecture Patterns -### 1. Unified API Response Format -All Controllers must return `ApiResponse` wrapper: +### 1. 统一API响应格式 +所有Controller必须返回 `ApiResponse` 包装器: ```java public ApiResponse> getUsers() { return ApiResponse.success(userService.getUsers()); } ``` +**重要**: 所有ID字段在API响应中统一使用String类型,避免前端JavaScript大数精度丢失问题: +```java +public class UserResponse { + private String id; // 统一使用String类型 + private String userId; + // 其他字段... +} +``` + ### 2. Exception Handling Architecture - Business exceptions: `throw new BizException(ApiCode.USER_NOT_EXIST)` - Global handler: `GlobalExceptionHandler` converts to `ApiResponse` format - Status codes: `ApiCode` enum defines all error codes -### 3. Authentication & Authorization -- **Framework**: Sa-Token (JWT-based) -- **User Context**: `UserContext.getUserId()` / `UserContext.checkAdmin()` -- **Route Protection**: - - `/api/client/**` - client APIs (some public, some auth required) - - `/api/admin/**` - admin-only APIs - - `/api/open/**` - public APIs +### 3. 认证与授权系统 +- **认证框架**: Sa-Token (JWT-based) +- **用户上下文**: `UserContext.getUserId()` / `UserContext.checkAdmin()` +- **路由保护策略**: + - `/api/client/**` - 客户端APIs (部分公开,部分需要认证) + - `/api/admin/**` - 管理员专用APIs + - `/api/open/**` - 完全公开APIs + +**用户注册机制**: +- 用户名由系统自动生成,格式为 `vocata-{随机字符串}` +- 用户只需提供邮箱和密码即可完成注册 +- 注册后可通过设置接口修改昵称等个人信息 ### 4. 数据访问模式 - **基础实体**: 所有实体必须继承 `BaseEntity`,自动填充审计字段 @@ -115,15 +147,18 @@ src/main/java/com/vocata/{module}/ ``` **当前已实现模块**: -- `auth` - 认证授权模块 -- `user` - 用户管理模块 +- `auth` - 认证授权模块 (注册、登录、登出、验证码) +- `user` - 用户管理模块 (用户信息、设置) +- `file` - 文件管理模块 (七牛云存储集成) +- `admin` - 管理后台模块 (管理员认证、用户管理) +- `common` - 通用组件模块 (基础实体、响应包装器、异常处理) + +**规划中模块**: - `character` - 角色管理模块 - `conversation` - 对话管理模块 - `favorite` - 收藏功能模块 -- `admin` - 管理后台模块 - `ai` - AI集成模块 - `search` - 搜索功能模块 -- `common` - 通用组件模块 ## 数据库架构规范 @@ -168,10 +203,10 @@ is_delete SMALLINT DEFAULT 0 -- 软删除标记 0.否 1.是 - 逻辑删除配置 ### 环境配置文件 -- **本地开发**: `application.yml` + `.env` +- **本地开发**: `application.yml` + `application-local.yml` - **测试环境**: `application-test.yml` - **生产环境**: `application-prod.yml` -- **环境变量模板**: `.env.example` +- **Docker配置**: `docker-compose.yml`, `docker-compose.test.yml`, `docker-compose.prod.yml` ### 开发规范文档 - `docs/后端开发规范.md` - 编码规范和最佳实践 @@ -181,53 +216,234 @@ is_delete SMALLINT DEFAULT 0 -- 软删除标记 0.否 1.是 ## 项目实现状态 -### 已完成模块 -- **用户认证系统**: 注册、登录、登出功能 -- **权限框架**: 基于Sa-Token的角色访问控制 -- **数据访问层**: MyBatis Plus + PostgreSQL集成 -- **统一响应格式**: `ApiResponse` 包装器 -- **异常处理**: 全局异常处理和错误码体系 -- **基础实体**: `BaseEntity` 审计字段自动填充 +### 已完成功能 +- **用户认证系统**: 注册(邮箱验证码)、登录、登出、密码重置 +- **权限框架**: 基于Sa-Token的JWT认证和角色访问控制 +- **数据访问层**: MyBatis Plus + PostgreSQL集成,审计字段自动填充 +- **文件存储**: 七牛云存储集成,支持图片上传 +- **统一响应格式**: `ApiResponse` 包装器,ID字段统一String类型 +- **异常处理**: 全局异常处理和完整的错误码体系 +- **基础实体**: `BaseEntity` 审计字段自动填充,软删除支持 - **用户上下文**: `UserContext` 线程安全的用户信息管理 -- **配置管理**: 多环境配置和环境变量支持 - -### 开发中模块 -- **角色管理系统**: 实体和基础结构已创建 -- **对话管理**: 实体和基础结构已创建 -- **收藏功能**: 实体和基础结构已创建 -- **AI集成层**: 基础架构已规划 - -### 待实现功能 -- AI服务集成和对话生成 -- 文件上传和存储 -- 邮件服务集成 -- 前端应用 (客户端和管理后台) -- Docker容器化部署 -- 性能监控和日志分析 +- **多环境配置**: 本地/测试/生产环境配置支持 +- **邮件服务**: 163邮箱SMTP集成,验证码发送 +- **管理后台**: 管理员认证、用户管理功能 +- **Docker化部署**: 完整的docker-compose配置(开发/测试/生产) +- **CI/CD流程**: GitHub Actions自动化构建和测试 + +### 技术债务和优化点 +- 单元测试覆盖率待提升 +- API文档生成 (Swagger/OpenAPI) +- 性能监控和链路追踪 +- 日志聚合和分析 +- 缓存策略优化 +- 数据库连接池调优 + +### 待开发核心功能 +- **AI角色对话**: 角色管理、对话生成、语音转换 +- **用户互动**: 收藏、评论、分享功能 +- **搜索系统**: 角色搜索、对话历史搜索 +- **前端应用**: 客户端和管理后台界面 + +## Docker容器化部署 + +### 开发环境部署 +项目提供完整的Docker容器化解决方案,支持一键启动所有服务: + +```bash +# 启动所有服务 (PostgreSQL + Redis + 后端 + 前端 + 工具) +docker-compose up -d + +# 仅启动基础服务 +docker-compose up -d postgres redis + +# 启动后端服务 +docker-compose up -d vocata-server + +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f vocata-server +``` + +### 服务端口分配 +- **后端API服务**: http://localhost:9009 +- **客户端前端**: http://localhost:3000 +- **管理后台**: http://localhost:3001 +- **PostgreSQL**: localhost:5432 +- **Redis**: localhost:6379 +- **pgAdmin**: http://localhost:5050 (admin@vocata.com / admin123) +- **MailHog**: http://localhost:8025 (邮件测试工具) + +### 多环境Docker配置 +- `docker-compose.yml` - 本地开发环境 +- `docker-compose.test.yml` - 测试环境 +- `docker-compose.prod.yml` - 生产环境 + +### 健康检查和依赖管理 +所有服务都配置了健康检查和服务依赖关系,确保启动顺序正确。 + +## CI/CD自动化流程 + +项目配置了完整的GitHub Actions CI/CD流水线 (`.github/workflows/ci.yml`): + +### 触发条件 +- Pull Request到 `develop` 或 `master` 分支 +- Push到 `develop` 分支 + +### CI流程 +1. **后端CI** (`backend-ci`): + - JDK 17环境设置 + - Maven依赖缓存 + - 代码编译和风格检查 + - 单元测试执行 + - JAR包构建 + - 构建产物上传 + +2. **前端客户端CI** (`frontend-web-ci`): + - Node.js 20环境设置 + - npm依赖安装 + - TypeScript类型检查 + - ESLint代码检查 + - 测试版本构建 + +3. **管理后台CI** (`frontend-admin-ci`): + - 与客户端CI流程相同 + - 独立构建和检查 + +4. **CI结果汇总** (`ci-summary`): + - 汇总所有组件的CI结果 + - 生成详细的执行报告 + - 失败时阻止合并操作 + +### 构建产物管理 +- JAR包和前端构建产物自动上传 +- 保留7天的构建历史 +- 支持手动下载和部署 ## 开发工作流程 ### 本地开发环境搭建 -1. 复制 `.env.example` 为 `.env` 并配置本地环境变量 -2. 启动 PostgreSQL 和 Redis 服务 -3. 创建 `vocata_local` 数据库 -4. 运行 `mvn spring-boot:run` 启动服务 -5. 服务启动在 http://localhost:9010/api + +**方式一:使用已配置的云服务 (推荐)** +1. 项目已配置云端PostgreSQL和Redis服务 +2. 检查 `application-local.yml` 配置无误 +3. 运行 `mvn spring-boot:run` 启动服务 +4. 访问 http://localhost:9009/api + +**方式二:使用Docker本地环境** +1. 启动Docker服务 +2. 运行 `docker-compose up -d postgres redis` 启动数据库 +3. 运行 `mvn spring-boot:run` 启动后端服务 +4. 可选:启动 `pgAdmin` 和 `MailHog` 等开发工具 + +**方式三:完整Docker化开发** +1. 运行 `docker-compose up -d` 启动所有服务 +2. 后端服务会自动等待数据库就绪后启动 +3. 访问各服务的对应端口 + +### 开发规范和最佳实践 + +**代码提交流程**: +1. 从 `develop` 分支创建功能分支: `feat/功能描述` +2. 完成开发后提交到功能分支 +3. 创建Pull Request到 `develop` 分支 +4. CI/CD自动执行代码检查和构建 +5. 代码审查通过后合并到 `develop` +6. 定期从 `develop` 合并到 `master` 进行发布 + +**环境配置优先级**: +1. `application-local.yml` > `application.yml` (本地开发) +2. `application-test.yml` > `application.yml` (测试环境) +3. `application-prod.yml` > `application.yml` (生产环境) + +**ID字段处理规范**: +- 数据库层:使用 `BIGINT` 类型和雪花ID生成策略 +- 服务层:内部处理使用 `Long` 类型 +- API层:响应对象统一使用 `String` 类型避免精度丢失 ### 常用开发命令 ```bash -# 清理编译 -mvn clean compile - -# 运行测试(当前无测试用例) -mvn test +# 项目构建和测试 +mvn clean compile # 清理编译 +mvn clean package -DskipTests # 构建JAR包(跳过测试) +mvn test # 运行单元测试 +mvn spring-boot:run # 启动应用 +mvn spring-boot:run -Dspring-boot.run.profiles=local # 指定环境启动 + +# 代码质量检查 +mvn checkstyle:check # 代码风格检查(如果配置了checkstyle) +mvn dependency:tree # 查看依赖树 +mvn dependency:analyze # 分析依赖使用情况 + +# Docker相关命令 +docker-compose up -d # 后台启动所有服务 +docker-compose up vocata-server # 前台启动后端服务 +docker-compose logs -f vocata-server # 查看服务日志 +docker-compose ps # 查看服务状态 +docker-compose down # 停止所有服务 +docker-compose restart vocata-server # 重启后端服务 + +# 数据库和缓存管理 +docker-compose exec postgres psql -U vocata -d vocata_local # 连接数据库 +docker-compose exec redis redis-cli # 连接Redis + +# 开发工具 +curl http://localhost:9009/api/health # 健康检查 +curl -H "Authorization: Bearer TOKEN" http://localhost:9009/api/client/user/info # API测试 +``` -# 热重载开发(IDE中配置) -mvn spring-boot:run -Dspring-boot.run.fork=false +## API文档和测试 + +### API设计规范 +- **统一前缀**: `/api/` +- **版本控制**: 暂未启用,预留 `/api/v1/` 格式 +- **路由分类**: + - `/api/open/**` - 公开API,无需认证 + - `/api/client/**` - 客户端API,部分需要认证 + - `/api/admin/**` - 管理员API,需要管理员权限 + - `/api/health` - 健康检查端点 + +### 请求响应格式 +所有API响应遵循统一格式: +```json +{ + "code": 200, + "message": "成功", + "data": {}, + "timestamp": "2024-01-01T12:00:00Z" +} +``` -# 查看项目依赖 -mvn dependency:tree +### 认证机制 +- **认证头**: `Authorization: Bearer ` +- **Token类型**: Sa-Token生成的JWT令牌 +- **Token有效期**: 本地30天,测试/生产7天 +- **刷新机制**: 基于活跃时间自动延期 -# 检查代码风格 -mvn checkstyle:check +### 常用API端点 +```bash +# 用户认证 +POST /api/open/auth/register # 用户注册 +POST /api/open/auth/login # 用户登录 +POST /api/open/auth/logout # 用户登出 +POST /api/open/auth/send-code # 发送验证码 +POST /api/open/auth/reset-password # 重置密码 + +# 用户管理 +GET /api/client/user/info # 获取用户信息 +PUT /api/client/user/info # 更新用户信息 +GET /api/client/user/profile # 获取用户资料 + +# 文件上传 +POST /api/client/file/upload # 文件上传(七牛云) + +# 管理后台 +POST /api/admin/auth/login # 管理员登录 +GET /api/admin/user/list # 用户列表 +PUT /api/admin/user/{id}/status # 更新用户状态 + +# 系统监控 +GET /api/health # 健康检查 ``` \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..13a3dd6 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# 一. 功能演示 + +演示视频:[http://t313actv0.hb-bkt.clouddn.com/bandicam%202025-09-28%2023-45-36-297.mp4](http://t313actv0.hb-bkt.clouddn.com/bandicam%202025-09-28%2023-45-36-297.mp4) + + + +# 二. 项目介绍 + +语Ta(VacaTa) 一个AI驱动的实时语音角色扮演平台,用户可与哈利波特、等虚拟角色进行语音和文本对话 + ++ 语音/文本对话: 完整的语音→ 文本→ LLM → 语音链路,支持流式快速响应 ++ 多AI模型集成: 七牛云AI、硅基流动、OpenAI GPT、Google Gemini无缝切换功能模式 ++ 自定义角色: 用户可创建个性化AI角色,调节性格和对话风格。 + + + +# 三. 技术架构 + +**整体架构:** + +![](https://cdn.nlark.com/yuque/0/2025/png/29246232/1759073523877-d803a8b1-b8f2-472e-b193-361babf7cc9b.png) + +**技术栈:** + ++ 后端: Spring Boot 3.1.4 + Java 17 + MyBatis Plus + Sa-Token + WebSocket ++ 前端: Vue 3.5 + TypeScript + Vite + Element Plus + Pinia ++ 数据库: PostgreSQL 15 + Redis 7 ++ AI服务: 七牛云ASR + 七牛云AI服务商 + 科大讯飞TTS ++ 部署: GitHub Actions CI、CD + +# 四. 服务提供 + +| **服务类型** | **提供商** | +| :------------ | :--------------------------------- | +| OSS对象存储 | 七牛云 | +| STT语音识别 | 七牛云ASR、科大讯飞 | +| TTS语音合成 | 科大讯飞、火山引擎 | +| LLM大语言模型 | 七牛云AI、Gemini、OpenAI、硅基流动 | + + + +# 五.问题 + +## 1.你计划将这个应用面向什么类型的用户?这些类型的用户他们面临什么样的痛点,你设想的用户故事是什么样呢? + +本产品的核心目标用户可归纳为四大类:**IP/角色爱好者****学习者****情感陪伴需求者****内容创者**。他们共同的渴望是将单向、静态的内容消费(如阅读、观影)转变为双向、动态的沉浸式互动。主要痛点集中在现有媒介缺乏互动性、学习过程枯燥、现实社交存在压力以及创作时缺少灵感。本产品旨为这些用户提供一个集娱乐、学习、陪伴和创造于一体的全新互动平台。 + + + +### IP/角色爱好者 + +对特定影视、动漫、游戏人物充满热情的年轻人(年龄以 18-38 岁为主)。他们不仅希望与心仪角色进行思想上的交流,更渴望建立更深层次的情感连接。 + +**王海(《海贼王》粉丝)**:“作为一名海米,路飞的铁杆粉丝,我觉得仅仅通过追番和看漫画来体验他的冒险故事,总感觉隔着一层屏幕,缺少了真实的互动感。我希望能真的和‘路飞’本人进行语音对话,听他用那标志性的语气兴奋地跟我聊聊最近的冒险,甚至可以问他‘当海贼王需要具备什么条件?’,这远比在论坛上猜测剧情或重温动画更能让我感受到那种身临其境的伙伴感。” + +**核心痛点** + ++ 单向互动,缺乏沉浸感 ++ 情感连接肤浅,渴望深度交流 ++ 想象无法落地,互动渠道缺失 + +### 学习者 + +学习者为两类,一类是知识学习者,如希望深入理解哲学家、历史人物、文学家的学习者或爱好者;另一类是语言学习者,需要语境环境、且无压力的环境来练习外语口语。 + +**李明(知识学习者)**:“作为一名哲学爱好者,我觉得笛卡尔的作品虽然深刻,但有些地方仅靠阅读难以完全理解。我希望能直接与‘笛卡尔’本人进行语音对话,让他用通俗的语言为我举例说明,让我能更深刻地与他的思想进行碰撞,这远比我独自钻研文本要高效和富有启发性得多。” + +**小美(语言学习者)**:“作为一名英语学习者,我觉得最大的挑战是找到一个既有真实语境、又没有社交压力的口语练习环境,而且聘请真人外教的费用实在太高了。我希望能随时随地和像‘莎士比亚’这样的AI角色进行对话,在一个虚拟的场景里围绕不同的话题进行角色扮演,这远比预约昂贵且有压力的真人外教要轻松、自由,也经济实惠得多。并且我可以勇敢地开口练习口语和听力,而不用担心因犯错而感到尴尬。” + +**核心痛点:** + ++ 知识获取枯燥 ++ 缺乏语伴与环境 + + + +### 情感陪伴需求者 + +因独居、工作繁忙、社交焦虑、失恋等原因,在情感上感到孤独,希望寻找一个随时可用、无压力且非评判性的倾诉对象的用户。 + +**小张**:“作为一名独居青年,我时常在深夜感到孤独,但很多烦心事又不敢和家人朋友说,怕给他们添麻烦。我希望能有一个随时都在、且绝不评判我的倾诉对象,能听我说说心里话,这远比一个人硬扛着所有情绪要好得多。” + +**核心痛点:** + ++ 即时性情感需求的无法满足 ++ 现实社交的“高成本”与“不确定性” ++ 对“无条件接纳”与“安全感”的渴望 + + + + + +### 内容创作者 + +Cosplay UP主、作家、编剧、游戏设计师等需要进行创意构思和角色扮演的用户。 + +小陈:“我写剧本的时候老是容易卡住,尤其是角色的对话,总觉得很僵硬、不够自然。我其实特别想能直接跟我构思的角色聊一聊,用语音问他各种问题、丢给他不同场景,看看他会怎么反应。这样碰撞出来的台词肯定更有火花,比我一个人对着文档死想或者疯狂查资料要省事儿、也更直观。” + +**核心痛点:** + ++ **灵感枯竭与创作瓶颈** ++ **塑造角色缺乏动态反馈** ++ **角色对话难以高效生成** + + + +## 2.你认为这个 APP 需要哪些功能?这些功能各自的优先级是什么?你计划本次开发哪些功能? + +1. **基础语音对话**:STT+LLM+TTS的完整链路 +2. **3个核心角色**:苏格拉底、邓布利多、AI助手 +3. **用户认证**:简单注册/登录 +4. **基础对话管理**:保存历史记录 +5. **角色记忆系统**:记住用户偏好 (以及**角色身份边界问题** +6. **实时字幕**:语音识别结果展示 +7. **对话中断机制**:可随时打断AI +8. **用户创建角色** +9. **多轮对话摘要(****对话历史上下文压缩策略工程****** +10. **情感分析** +11. **...** + + + +## 3.你计划采纳哪家公司的哪个 LLM 模型能力?你对比了哪些,你为什么选择用该 LLM 模型? + +| **模型** | **优势** | **劣势** | +| --------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | +| **GPT-4o** | 角色扮演能力最强、理解力最好 | 成本高、延迟较大 | +| X-fast | 速度快、长文本处理优秀,上下文长 | 成本较低 | +| gemini-2.5fast | 速度快、长文本处理优秀,上下文长 | 成本较低 | + + +## 4.你期望 AI 角色除了语音聊天外还应该有哪些技能? + +1. **多模态交互** + - 角色形象生成(AI绘画) + - 场景渲染(如霍格沃茨大厅) + - 表情动作系统 +2. **游戏化元素** + - 角色好感度系统 + - 成就解锁 + - 剧情任务 +3. **教育功能** + - 知识点总结 + - 学习进度追踪 + - 个性化教学(如苏格拉底的哲学课) +4. **创作辅助** + - 李白:诗词创作工具 + - 哈利:魔法故事生成器 + - 苏格拉底:论文思路梳理 + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..1db9aa4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,287 @@ +# VocaTa生产环境 Docker Compose 配置 +version: '3.8' + +x-app-common: &app-common + restart: unless-stopped + networks: + - vocata-network + environment: + - TZ=Asia/Shanghai + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "3" + +x-healthcheck-web: &healthcheck-web + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +services: + # 后端服务 + vocata-server: + <<: *app-common + image: ${SERVER_IMAGE:-ghcr.io/leivik/vocata-server:latest} + container_name: vocata-server + ports: + - "${SERVER_PORT:-9009}:9009" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod} + - SERVER_PORT=9009 + # 数据库配置 + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_NAME} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + # Redis配置 + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_PASSWORD=${REDIS_PASSWORD} + - REDIS_DATABASE=${REDIS_DATABASE:-0} + # 七牛云配置 + - QINIU_ACCESS_KEY=${QINIU_ACCESS_KEY} + - QINIU_SECRET_KEY=${QINIU_SECRET_KEY} + - QINIU_BUCKET=${QINIU_BUCKET} + - QINIU_DOMAIN=${QINIU_DOMAIN} + - QINIU_REGION=${QINIU_REGION} + # 邮箱配置 + - EMAIL_USER_NAME=${EMAIL_USER_NAME} + - EMAIL_USER_PASSWORD=${EMAIL_USER_PASSWORD} + # AI服务配置 + - AI_LLM_PROVIDER=${AI_LLM_PROVIDER:-qiniu} + - QINIU_AI_API_KEY=${QINIU_AI_API_KEY} + - QINIU_AI_BASE_URL=${QINIU_AI_BASE_URL:-https://openai.qiniu.com/v1} + - QINIU_AI_MODEL=${QINIU_AI_MODEL:-x-ai/grok-4-fast} + - QINIU_AI_TIMEOUT=${QINIU_AI_TIMEOUT:-60} + - GEMINI_API_KEY=${GEMINI_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + # JVM优化配置 + - JAVA_OPTS=-Xms1g -Xmx3g -XX:+UseG1GC -XX:+UseStringDeduplication -XX:+OptimizeStringConcat + - TZ=Asia/Shanghai + volumes: + - server-logs:/var/log/vocata + - server-uploads:/app/uploads + - /etc/localtime:/etc/localtime:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9009/api/actuator/health"] + interval: 30s + timeout: 15s + retries: 5 + start_period: 120s + deploy: + resources: + limits: + memory: 4G + cpus: '2.0' + reservations: + memory: 1G + cpus: '1.0' + restart_policy: + condition: on-failure + delay: 10s + max_attempts: 3 + window: 120s + + # 前端客户端 + vocata-web: + <<: *app-common + image: ${WEB_IMAGE:-ghcr.io/leivik/vocata-web:latest} + container_name: vocata-web + ports: + - "${WEB_PORT:-8080}:80" + volumes: + - web-logs:/var/log/nginx + - /etc/localtime:/etc/localtime:ro + healthcheck: *healthcheck-web + depends_on: + vocata-server: + condition: service_healthy + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 128M + cpus: '0.2' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + + # 管理后台 + vocata-admin: + <<: *app-common + image: ${ADMIN_IMAGE:-ghcr.io/leivik/vocata-admin:latest} + container_name: vocata-admin + ports: + - "${ADMIN_PORT:-8081}:80" + volumes: + - admin-logs:/var/log/nginx + - /etc/localtime:/etc/localtime:ro + healthcheck: *healthcheck-web + depends_on: + vocata-server: + condition: service_healthy + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 128M + cpus: '0.2' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + + # Nginx反向代理 + nginx: + <<: *app-common + image: nginx:1.25-alpine + container_name: vocata-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - ./nginx/includes:/etc/nginx/includes:ro + - nginx-logs:/var/log/nginx + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + depends_on: + - vocata-server + - vocata-web + - vocata-admin + deploy: + resources: + limits: + memory: 256M + cpus: '1.0' + reservations: + memory: 64M + cpus: '0.2' + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + + # 监控服务 - Prometheus + prometheus: + <<: *app-common + image: prom/prometheus:v2.47.0 + container_name: vocata-prometheus + ports: + - "${PROMETHEUS_PORT:-9090}:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=30d' + - '--web.enable-lifecycle' + depends_on: + - vocata-server + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.2' + profiles: + - monitoring + + # 监控可视化 - Grafana + grafana: + <<: *app-common + image: grafana/grafana:10.1.0 + container_name: vocata-grafana + ports: + - "${GRAFANA_PORT:-3002}:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin123} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-simple-json-datasource + - TZ=Asia/Shanghai + volumes: + - grafana-data:/var/lib/grafana + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 128M + cpus: '0.2' + profiles: + - monitoring + +# 数据卷 +volumes: + server-logs: + driver: local + driver_opts: + type: none + o: bind + device: /var/log/vocata/server + server-uploads: + driver: local + driver_opts: + type: none + o: bind + device: /var/data/vocata/uploads + web-logs: + driver: local + driver_opts: + type: none + o: bind + device: /var/log/vocata/web + admin-logs: + driver: local + driver_opts: + type: none + o: bind + device: /var/log/vocata/admin + nginx-logs: + driver: local + driver_opts: + type: none + o: bind + device: /var/log/vocata/nginx + prometheus-data: + driver: local + driver_opts: + type: none + o: bind + device: /var/data/vocata/prometheus + grafana-data: + driver: local + driver_opts: + type: none + o: bind + device: /var/data/vocata/grafana + +# 网络配置 +networks: + vocata-network: + driver: bridge + name: ${COMPOSE_PROJECT_NAME:-vocata-prod}-network + driver_opts: + com.docker.network.bridge.name: vocata-br \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..fb06844 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,141 @@ +# VocaTa测试环境 Docker Compose 配置 +version: '3.8' + +x-app-common: &app-common + restart: unless-stopped + networks: + - vocata-network + environment: + - TZ=Asia/Shanghai + +x-healthcheck-web: &healthcheck-web + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +services: + # 后端服务 + vocata-server: + <<: *app-common + image: ${SERVER_IMAGE:-ghcr.io/leivik/vocata-server:test-latest} + container_name: vocata-server + ports: + - "${SERVER_PORT:-9009}:9009" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-test} + - SERVER_PORT=9009 + # 数据库配置 + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_NAME} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + # Redis配置 + - REDIS_HOST=${REDIS_HOST:-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_PASSWORD=${REDIS_PASSWORD} + - REDIS_DATABASE=${REDIS_DATABASE:-1} + # 七牛云配置 + - QINIU_ACCESS_KEY=${QINIU_ACCESS_KEY} + - QINIU_SECRET_KEY=${QINIU_SECRET_KEY} + - QINIU_BUCKET=${QINIU_BUCKET} + - QINIU_DOMAIN=${QINIU_DOMAIN} + - QINIU_REGION=${QINIU_REGION} + # 邮箱配置 + - EMAIL_USER_NAME=${EMAIL_USER_NAME} + - EMAIL_USER_PASSWORD=${EMAIL_USER_PASSWORD} + # 邮件服务配置 + - MAIL_HOST=${MAIL_HOST:-smtp.163.com} + - MAIL_PORT=${MAIL_PORT:-465} + - MAIL_USERNAME=${EMAIL_USER_NAME} + - MAIL_PASSWORD=${EMAIL_USER_PASSWORD} + # AI服务配置 + - AI_LLM_PROVIDER=${AI_LLM_PROVIDER:-qiniu} + - QINIU_AI_API_KEY=${QINIU_AI_API_KEY} + - QINIU_AI_BASE_URL=${QINIU_AI_BASE_URL:-https://openai.qiniu.com/v1} + - QINIU_AI_MODEL=${QINIU_AI_MODEL:-x-ai/grok-4-fast} + - QINIU_AI_TIMEOUT=${QINIU_AI_TIMEOUT:-60} + - GEMINI_API_KEY=${GEMINI_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + + volumes: + - server-logs:/var/log/vocata + - server-uploads:/app/uploads + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9009/api/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + depends_on: [] + deploy: + resources: + limits: + memory: 2G + cpus: '1.0' + reservations: + memory: 512M + cpus: '0.5' + + # 前端客户端 + vocata-web: + <<: *app-common + image: ${WEB_IMAGE:-ghcr.io/leivik/vocata-web:test-latest} + container_name: vocata-web + ports: + - "${WEB_PORT:-3000}:8080" + volumes: + - web-logs:/var/log/nginx + healthcheck: *healthcheck-web + depends_on: + vocata-server: + condition: service_healthy + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 64M + cpus: '0.1' + + # 管理后台 + vocata-admin: + <<: *app-common + image: ${ADMIN_IMAGE:-ghcr.io/leivik/vocata-admin:test-latest} + container_name: vocata-admin + ports: + - "${ADMIN_PORT:-3001}:8080" + volumes: + - admin-logs:/var/log/nginx + healthcheck: *healthcheck-web + depends_on: + vocata-server: + condition: service_healthy + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 64M + cpus: '0.1' + +# 数据卷 +volumes: + server-logs: + driver: local + server-uploads: + driver: local + web-logs: + driver: local + admin-logs: + driver: local + +# 网络配置 +networks: + vocata-network: + driver: bridge + name: ${COMPOSE_PROJECT_NAME:-vocata-test}-network \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d5fb7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,118 @@ +version: '3.8' + +services: + # PostgreSQL 数据库 + postgres: + image: postgres:15-alpine + container_name: vocata-postgres-dev + environment: + POSTGRES_DB: vocata_local + POSTGRES_USER: vocata + POSTGRES_PASSWORD: vocata123 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U vocata -d vocata_local"] + interval: 30s + timeout: 10s + retries: 3 + + # Redis 缓存 + redis: + image: redis:7-alpine + container_name: vocata-redis-dev + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # 后端服务 + vocata-server: + build: + context: ./vocata-server + dockerfile: Dockerfile + container_name: vocata-server-dev + ports: + - "9009:9009" + environment: + SPRING_PROFILES_ACTIVE: local + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/vocata_local + SPRING_DATASOURCE_USERNAME: vocata + SPRING_DATASOURCE_PASSWORD: vocata123 + SPRING_REDIS_HOST: redis + SPRING_REDIS_PORT: 6379 + SPRING_REDIS_DATABASE: 0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./vocata-server/logs:/app/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9009/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 前端客户端 + vocata-web: + build: + context: ./vocata-web + dockerfile: Dockerfile + container_name: vocata-web-dev + ports: + - "3000:80" + environment: + VITE_API_BASE_URL: http://localhost:9009/api + depends_on: + - vocata-server + + # 管理后台 + vocata-admin: + build: + context: ./vocata-admin + dockerfile: Dockerfile + container_name: vocata-admin-dev + ports: + - "3001:80" + environment: + VITE_API_BASE_URL: http://localhost:9009/api + depends_on: + - vocata-server + + # pgAdmin (数据库管理工具) + pgadmin: + image: dpage/pgadmin4:latest + container_name: vocata-pgadmin-dev + environment: + PGADMIN_DEFAULT_EMAIL: admin@vocata.com + PGADMIN_DEFAULT_PASSWORD: admin123 + ports: + - "5050:80" + depends_on: + - postgres + + # MailHog (邮件测试工具) + mailhog: + image: mailhog/mailhog:latest + container_name: vocata-mailhog-dev + ports: + - "1025:1025" # SMTP端口 + - "8025:8025" # Web界面端口 + +volumes: + postgres_data: + redis_data: + +networks: + default: + name: vocata-dev-network \ No newline at end of file diff --git "a/docs/\351\203\250\347\275\262\346\226\207\346\241\243.md" "b/docs/\351\203\250\347\275\262\346\226\207\346\241\243.md" new file mode 100644 index 0000000..ed92fe7 --- /dev/null +++ "b/docs/\351\203\250\347\275\262\346\226\207\346\241\243.md" @@ -0,0 +1,123 @@ +# VocaTa 项目运行指南 + +本文档说明如何在本地或通过容器编排运行 VocaTa 项目(后端服务、客户端前端、管理后台)。 + +## 1. 运行方式概览 +- **Docker Compose 一键启动**:适合快速体验或准备集成环境,自动拉起数据库、缓存、后端和两个前端。 +- **本地开发模式**:分别启动后端(Java + Maven)及前端项目(Vue 3 + Vite),适合调试与开发。 +- **生产部署示例**:项目根目录提供 `docker-compose.prod.yml` 可作为上线部署基础模板。 + +## 2. 环境准备 +### 2.1 通用要求 +- 操作系统:macOS / Linux / Windows(支持 Docker 或 Java 开发环境) +- Git(可选,用于克隆仓库) + +### 2.2 本地开发依赖项 +- JDK 17 及以上 +- Maven 3.6 及以上 +- Node.js 20.19 或 >= 22.12(前端 `package.json` 中的 `engines` 要求) +- npm(Node.js 自带) +- PostgreSQL 12 及以上 +- Redis 6 及以上 + +### 2.3 容器运行依赖项 +- Docker 24+ +- Docker Compose Plugin(`docker compose` 命令) + +## 3. 使用 Docker Compose 快速启动 +1. 确保 Docker Desktop 或 Docker Engine 正在运行。 +2. 在项目根目录执行: + ```bash + docker compose up -d --build + ``` + 该命令会按 `docker-compose.yml` 构建并启动以下服务: + - PostgreSQL(端口 `5432`) + - Redis(端口 `6379`) + - vocata-server(端口 `9009`,REST API `http://localhost:9009/api`) + - vocata-web(端口 `3000`,客户端 Web 入口) + - vocata-admin(端口 `3001`,管理后台入口) + - pgAdmin(端口 `5050`,数据库管理工具) + - MailHog(SMTP `1025` / Web UI `8025`,用于邮件调试) +3. 查看容器日志(可选): + ```bash + docker compose logs -f vocata-server + ``` +4. 停止并清理容器: + ```bash + docker compose down + ``` + 如需保留数据,可保留默认定义的 `postgres_data`、`redis_data` 卷;若需要重新初始化数据,可加上 `-v` 删除数据卷。 + +## 4. 本地开发模式 +### 4.1 准备数据库与缓存 +1. 启动 PostgreSQL 与 Redis,并记录连接信息。 +2. 初始化数据库(以 README 示例为准): + ```sql + CREATE DATABASE vocata_dev; + CREATE USER vocata_dev WITH PASSWORD 'vocata_dev'; + GRANT ALL PRIVILEGES ON DATABASE vocata_dev TO vocata_dev; + ``` + +### 4.2 配置后端(`vocata-server`) +1. 拷贝本地配置模板: + ```bash + cp src/main/resources/application-local.yml.template src/main/resources/application-local.yml + ``` +2. 根据实际环境修改数据库、Redis、邮件及第三方 AI 服务配置。若使用环境变量,也可直接设置 `DB_HOST`、`DB_USERNAME` 等变量,`application.yml` 中提供了占位符。 +3. 安装依赖并运行: + ```bash + mvn clean install + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + 或打包后运行: + ```bash + mvn package + java -jar target/vocata-server-*.jar --spring.profiles.active=local + ``` +4. 服务默认监听 `http://localhost:9009`,健康检查接口为 `/api/health`。 + +### 4.3 启动客户端前端(`vocata-web`) +1. 安装依赖: + ```bash + npm install + ``` +2. 启动开发服务器: + ```bash + npm run dev + ``` +3. 默认访问地址为 `http://localhost:5173`(Vite 默认端口)。如需连接本地后端,请在 `.env` 或启动命令中设置 `VITE_API_BASE_URL=http://localhost:9009/api`。 +4. 生产构建: + ```bash + npm run build + npm run preview # 可选,预览构建结果 + ``` + +### 4.4 启动管理后台前端(`vocata-admin`) +流程与客户端一致: +```bash +cd vocata-admin +npm install +npm run dev +``` +同样可通过环境变量 `VITE_API_BASE_URL` 指向后端 API。 + +### 4.5 常用调试技巧 +- 后端实时日志位于 `vocata-server/logs` 目录,可根据需要调整 `application.yml` 中的日志级别。 +- 如需模拟邮件发送,可使用 MailHog(`http://localhost:8025`)。 +- 若前后端跨域问题,可检查 `vocata-server` 的 `WebConfig` 或前端代理配置。 + +## 5. 生产部署参考 +- 使用 `docker-compose.prod.yml`,提前构建或拉取镜像(如 `ghcr.io/leivik/vocata-server:latest`)。 +- 通过环境变量注入数据库、Redis、对象存储和 AI 服务密钥。 +- 建议在生产环境添加反向代理(Nginx 等)以及 HTTPS 终端。日志策略与健康检查已在 Compose 中示例配置。 + +## 6. 常见问题与排查 +- **数据库连接失败**:确认数据库容器是否就绪、账号密码与 `application-local.yml` 一致。 +- **Redis 认证错误**:本地默认无密码,如在服务器上启用密码,需要同步更新配置。 +- **前端无法请求 API**:检查 `VITE_API_BASE_URL` 是否指向正确地址/协议,并留意浏览器 CORS 报错。 +- **端口冲突**:修改对应服务的监听端口(Docker Compose 中的 `ports` 映射或 Vite 启动参数)。 +- **构建失败**:确保 Node.js 和 Maven 版本符合要求,必要时删除 `node_modules` / `target` 重装。 + +## 7. 下一步 +- 参考 `vocata-server/README.md` 获取 API 说明与模块概览。 +- 前端更多配置可查看各目录下的 `vite.config.ts` 与 `src` 代码。 diff --git "a/docs/\351\241\271\347\233\256\346\226\207\346\241\243.md" "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243.md" new file mode 100644 index 0000000..a3e3741 --- /dev/null +++ "b/docs/\351\241\271\347\233\256\346\226\207\346\241\243.md" @@ -0,0 +1,447 @@ +# 我思故我码 +## 一、项目介绍 +语Ta(VacaTa) 一个AI驱动的实时语音角色扮演平台 - 用户可与Harry Potter、Socrates等虚拟角色进行语音和文本对话 + ++ 实时语音对话: 完整的语音→文本→AI→语音链路,支持流式快速响应 ++ 多AI模型集成: 七牛云AI、OpenAI GPT、Google Gemini无缝切换功能模式 ++ 自定义角色: 用户可创建个性化AI角色,调节性格和对话风格 ++ 双向通信: 支持语音和文本两种交互方式 ++ 现代化架构: 前后端分离,容器化部署,CI/CD自动化 + +## 二、功能演示 +### 2.1 整体演示 +演示视频:[http://t313actv0.hb-bkt.clouddn.com/bandicam%202025-09-28%2023-45-36-297.mp4](http://t313actv0.hb-bkt.clouddn.com/bandicam%202025-09-28%2023-45-36-297.mp4) + +### 2.2 功能模块演示 +#### 2.2.1 用户认证系统 - 注册/登录/验证码 ++ 当用户访问VocaTa平台时,系统将引导其完成身份认证流程。 ++ 用户输入邮箱地址后,系统会发送6位数字验证码到指定邮箱,验证码有效期为5分钟。 ++ 如果是新用户,验证码验证通过后需要设置登录密码,系统将自动生成用户名(格式:vocata-随机字符串)。 ++ 已注册用户可直接使用邮箱和密码进行登录,系统将颁发JWT令牌实现免登录访问。 ++ 用户可以通过忘记密码功能重置登录凭据,确保账户安全。 + + + +#### 2.2.2 用户管理模块 - 个人信息管理 ++ 用户登录后可以查看和编辑个人资料,包括昵称、个人简介等基本信息。 ++ 用户可以上传个人头像,系统支持多种图片格式,并自动生成CDN访问链接。 ++ 用户可以配置个性化偏好设置,如主题风格、消息通知等选项。 ++ 系统提供账户安全设置,用户可以修改密码、查看登录记录等安全信息。 ++ 所有个人信息修改都会实时保存,并记录操作日志确保数据安全。 + + + +#### 2.2.3 AI对话模块 - 角色对话核心功能 ++ 用户可以从角色列表中选择喜欢的AI角色(如Harry Potter、Socrates等)开始对话。 ++ 系统支持文字和语音两种输入方式,用户可以根据场景灵活选择交互模式。 ++ AI回复采用流式响应技术,用户可以实时看到AI角色逐字生成回答内容。 ++ 系统保持多轮对话的上下文记忆,确保对话的连贯性和智能性。 ++ 用户可以随时暂停、继续或重新开始对话,系统会自动保存对话进度。 + + + +#### 2.2.4 对话历史模块 - 会话记录管理 ++ 用户可以查看与不同AI角色的历史对话记录,系统按时间倒序展示对话列表。 ++ 用户可以通过关键词搜索特定的对话内容,快速定位重要的对话片段。 ++ 系统支持对话分页加载,优化大量历史记录的浏览体验。 ++ 用户可以收藏重要对话,方便后续回顾和参考。 ++ 用户可以删除不需要的对话记录,系统采用软删除机制确保数据可恢复性。 + + + +#### 2.2.5 角色管理模块 - AI角色配置 ++ 用户可以浏览平台提供的多样化AI角色库,每个角色都有独特的人格特征和专业背景。 ++ 系统展示角色的详细信息,包括人物介绍、擅长领域、对话风格等特性。 ++ 用户可以预览角色的对话样例,了解其回复风格和知识水平。 ++ 系统支持角色评价功能,用户可以为喜欢的角色点赞或评分。 ++ 管理员可以创建新角色或编辑现有角色的人格设定,丰富平台角色生态。 + + + +#### 2.2.6 后台管理模块 - 角色和普通用户管理 ++ 管理员通过专用登录入口访问后台管理系统,享有完整的平台管理权限。 ++ 管理员可以查看用户注册统计、活跃度分析等关键运营数据。 ++ 系统提供用户账户管理功能,管理员可以查看用户详情、调整账户状态。 ++ 管理员可以审核和管理AI角色内容,确保角色设定的质量和合规性。 ++ 系统提供实时监控面板,展示服务器性能、API调用量等技术指标。 + + + +#### 2.2.7 文件存储模块 - 七牛云存储集成 ++ 用户可以上传头像、图片等多种类型的文件,系统自动检测文件格式和大小。 ++ 系统集成七牛云存储服务,确保文件上传的稳定性和访问速度。 ++ 用户可以按类型管理已上传的文件,支持文件预览、下载和删除操作。 ++ 系统提供CDN加速服务,确保全球用户都能快速访问文件资源。 ++ 管理员可以监控存储使用情况,设置文件大小限制和格式白名单。 + + + +#### 2.2.8 AI模型管理模块 - 多AI提供商集成 ++ 系统集成多个AI提供商(如SiliconFlow等),提供丰富的AI模型选择。 ++ 管理员可以查看所有可用的AI模型列表,包括模型性能参数和可用状态。 ++ 系统支持手动指定AI提供商和模型进行对话测试,便于模型效果评估。 ++ 用户可以体验不同AI模型的对话风格,选择最适合的智能助手。 ++ 系统提供统一的API接口封装,屏蔽不同提供商的接口差异。 + + + +#### 2.2.9 实时语音对话模块 - WebSocket语音交互 ++ 用户可以通过WebSocket连接与AI角色进行实时语音对话,支持语音输入和语音输出的全链路处理。 ++ 系统支持实时音频流传输,用户说话时音频数据实时传送到服务器进行处理。 ++ 用户可以选择语音模式或文字模式进行对话,系统自动识别输入类型并相应处理。 ++ 系统提供音频录制开始和结束的控制,用户可以灵活控制对话节奏。 ++ 支持WebSocket心跳检测机制,确保长时间对话的连接稳定性。 + + + +#### 2.2.10 语音识别模块 - STT多平台集成 ++ 系统集成多个语音识别服务提供商,包括七牛云STT、科大讯飞等主流平台。 ++ 用户上传的音频文件支持多种格式(WAV、MP3、AAC、FLAC等),系统自动识别和转换。 ++ 支持流式语音识别和批量语音识别两种模式,适应不同场景需求。 ++ 系统提供语音识别置信度评估,确保识别结果的准确性。 ++ 支持中英文等多语言识别,用户可以使用母语与AI角色对话。 + +## 三、技术架构实现 +### 3.1 技术选型 +#### 3.1.1 前端 ++ **框架与语言:Vue 3.5.18 + TypeScript 5.8.0** + +从业务适配角度,Vue3 的 Composition API 能够灵活拆分实时语音链路的复杂逻辑,比如语音采集、转译、AI 响应接收等环节的代码组织;TypeScript 则可通过静态类型检查,保障多 AI 模型集成过程中的参数传递、数据格式一致性,降低团队协作中的 bug 率,尤其适配多模型切换时的复杂数据处理场景。 + ++ **构建工具:Vite 7.0.6** + +Vite 的毫秒级热更新特性,能大幅提升实时语音功能的开发迭代效率,减少调试时的等待时间;其支持的多环境构建(local/test/prod 模式),可直接适配项目的 CI/CD 自动化部署需求,无需额外配置复杂的环境切换逻辑,助力不同阶段(本地开发、测试验证、生产上线)的快速过渡。 + ++ **UI 组件库:Element Plus 2.11.3 + 官方图标库** + +Element Plus 提供的丰富组件能快速支撑平台核心功能页面搭建:例如用表单组件实现自定义角色的性格、对话风格配置界面,用弹窗组件开发多 AI 模型切换面板;同时其轻量化设计风格与对话界面的简洁需求匹配,搭配官方图标库可保障界面视觉统一性,减少自定义图标的开发成本。 + ++ **状态管理:Pinia 3.0.3** + +针对平台多维度状态管理需求,Pinia 的模块化设计可单独划分 “模型状态”“角色配置状态” 等独立模块,避免多 AI 模型切换、自定义角色参数变更时的状态混乱;且支持按需加载特性,能减少实时对话场景下的首屏资源体积,间接提升页面加载与交互响应速度。 + ++ **网络请求:Axios 1.12.2** + +Axios 的拦截器功能可统一处理多 AI 模型请求的 Token 验证、超时控制,保障语音与文本双向通信的稳定性,比如在请求拦截器中添加身份标识,响应拦截器中处理 401 权限失效等异常;其支持的请求取消功能,还能适配实时语音链路中断场景,避免无效请求占用资源,减少交互延迟。 + ++ **样式方案:Tailwind CSS 4.1.13 + postcss-pxtorem** + +Tailwind CSS 的原子化样式特性,可快速实现对话界面的细节设计,比如角色气泡的样式调整、语音按钮的交互效果优化,无需编写大量自定义 CSS;搭配 postcss-pxtorem 自动将 px 转换为 rem 的功能,能轻松适配 PC、平板等多端设备的交互场景,保障不同屏幕尺寸下的界面一致性。 + ++ **代码质量:ESLint 9.31.0 + Prettier 3.6.2** + +ESLint 可对实时语音链路、多 AI 模型集成等复杂逻辑代码进行语法检查与风格规范,比如禁止未定义变量、统一代码缩进;Prettier 则能统一代码格式化标准,避免团队成员因格式差异产生协作成本,尤其适配多模型集成、角色配置等多模块开发场景下的代码可读性需求。 + + + +#### 3.1.2 后端 +**核心框架** + ++ Java 版本: Java 17 (LTS 长期支持版本) ++ Spring Boot: 3.1.4 (企业级微服务框架) ++ Web 框架: + - Spring Boot Starter Web (MVC + RESTful API) + - Spring Boot Starter WebSocket (实时通信) + - Spring WebFlux (响应式编程,用于AI服务调用) + +**认证与安全** + ++ 认证框架: Sa-Token 1.37.0 (轻量级Java权限认证框架) + - Sa-Token Spring Boot3 Starter + - Sa-Token Redis Jackson (分布式会话存储) ++ 密码加密: BCrypt 0.10.2 (行业标准密码哈希) ++ 数据验证: Spring Boot Starter Validation (JSR 303/380) + +**数据访问层** + ++ ORM框架: MyBatis Plus 3.5.3.2 (MyBatis 增强工具) ++ 数据库: PostgreSQL 42.6.0 (强一致性关系型数据库) ++ 连接池: HikariCP (Spring Boot 默认高性能连接池) ++ 缓存: + - Spring Boot Starter Data Redis (Redis 集成) + - Redisson 3.23.4 (分布式锁和高级数据结构) + +**AI服务集成** + ++ 语音识别: 科大讯飞 WebSDK Java Speech 3.0.6 ++ WebSocket客户端: + - Tyrus Client 1.19 (Java WebSocket 客户端实现) + - Tyrus Container Grizzly Client 1.19 + +**第三方服务** + ++ 文件存储: 七牛云 Java SDK 7.13.1 (云存储服务) ++ 邮件服务: Spring Boot Starter Mail (163邮箱SMTP) + +**数据存储服务** + ++ 主数据库: PostgreSQL 15-alpine ++ 缓存数据库: Redis 7-alpine + +**AI服务集成** + ++ 大语言模型: + - 七牛云AI (Grok-4-Fast) + - Google Gemini + - OpenAI GPT系列 ++ 语音服务: + - 科大讯飞STT (语音转文字) + - 科大讯飞TTS (文字转语音) + - 七牛云语音识别 + +**开发运维工具** + ++ 容器化: Docker + Docker Compose ++ 数据库管理: pgAdmin 4 (Web界面数据库管理) ++ 邮件测试: MailHog (开发环境邮件测试工具) ++ CI/CD: GitHub Actions (自动化构建和测试) + +#### 3.1.3 核心服务 +| **服务名** | **作用** | +| :--- | :--- | +| **Auth 服务** | 1. 提供用户注册、登录、登出和密码重置服务
2. 基于Sa-Token提供JWT认证和权限验证服务
3.提供邮箱验证码发送和验证服务
4. 提供多端会话管理和安全登录控制 | +| **User 服务** | 1. 提供用户信息查询、修改个人资料和用户设置服务
2. 提供用户权限管理和角色分配服务
3.提供用户状态管理(启用/禁用)服务
4. 提供用户数据统计和分析服务 | +| **Character 服务** | 1. 提供AI角色创建、编辑、删除和查询服务
2. 提供角色人格设定、背景故事和对话风格配置
3.提供角色分类、搜索和推荐服务
4. 提供角色热度统计和排行榜服务 | +| **AI 服务** | 1. 集成多种LLM提供智能对话生成服务(七牛云AI、Gemini、OpenAI)
2. 提供语音识别(STT)和语音合成(TTS)服务
3.提供实时AI对话和WebSocket通信服务
4. 提供AI模型切换和参数调优服务 | +| **Conversation 服务** | 1. 提供对话会话创建、管理和历史记录服务
2. 提供消息存储、查询和上下文管理服务
3.提供对话导出、分享和收藏服务
4. 提供对话统计分析和用户行为追踪服务 | +| **Voice 服务** | 1. 提供语音文件上传、处理和格式转换服务
2. 集成科大讯飞提供语音识别和合成服务
3.提供语音质量优化和降噪处理服务
4. 提供语音播放控制和音频流管理服务 | +| **File 服务** | 1. 集成七牛云提供文件上传、存储和CDN分发服务
2. 提供图片、音频、视频等多媒体文件管理
3.提供文件安全检查和格式验证服务
4. 提供文件访问权限控制和临时链接生成 | +| **Admin 服务** | 1. 提供管理员登录认证和权限管理服务
2. 提供用户管理、角色管理和系统监控服务 | +| **Common 服务** | 1. 提供统一的响应格式封装(ApiResponse)和异常处理
2. 提供基础实体类和审计字段自动填充服务
3.提供通用工具类和常量定义服务
4. 提供跨模块共享的数据结构和枚举定义 | + + +### 3.2 结构设计 +#### 3.2.1 技术架构 +VocaTa采用现代化前后端分离架构,具备以下核心特征: + +架构设计原则 + +1. 前后端分离: 前端Vue3应用独立部署,通过RESTful API与后端通信 +2. 微服务架构: 按业务领域划分模块,支持独立开发和部署 +3. AI服务集成: 统一的AI服务抽象层,支持多厂商AI服务切换 +4. 安全为先: 基于JWT的无状态认证,细粒度权限控制 + +核心技术决策: + ++ Java 17 + Spring Boot 3.1.4: 现代化JVM生态,享受最新语言特性和性能优化 ++ PostgreSQL + Redis: 强一致性关系型数据 + 高性能缓存的混合存储架构 ++ Sa-Token + JWT: 轻量级分布式认证,支持多端会话管理 ++ Vue 3 + TypeScript: 类型安全的现代前端开发体验 ++ Docker容器化: 环境一致性保证,简化部署和运维 + + 1. 高可扩展性: 模块化设计支持水平扩展,微服务架构便于独立升级 + + 2. 高性能: Redis缓存 + 连接池优化 + 异步处理机制 + + 3. 高安全性: JWT无状态认证 + HTTPS传输 + 参数验证 + SQL注入防护 + + 4. 高可维护性: 统一的代码规范 + 完整的异常处理 + 详细的日志记录 + + 5. 云原生: Docker容器化 + 多环境配置 + CI/CD自动化 + + 6. AI集成: 统一抽象层支持多种AI服务提供商,便于切换和扩展 + + + +用户认证流程: + ++ 登录请求 → Sa-Token验证 → JWT生成 → Redis存储会话 → 返回Token → 前端存储 ++ API请求 → Token验证 → Redis查询会话 → 用户上下文构建 → 业务处理 + +AI对话流程: + ++ 语音输入 → STT(七牛云ASR) → LLM处理(七牛云AI) → TTS(科大讯飞) → 语音输出 ++ 文字输入 → LLM处理 → 结构化响应 → 对话历史存储 → 前端渲染 + + + +#### 3.2.1 前端架构图 +![](https://cdn.nlark.com/yuque/0/2025/png/29246232/1759042687429-bfd79eda-e9cd-481e-9e61-67a939f457fd.png) + +#### +3.2.3 后端架构图 +![](https://cdn.nlark.com/yuque/0/2025/png/22342544/1759074426719-e1229caa-2678-431d-913e-3075d5b502dc.png) + + + +#### 3.2.4 数据库ER图 +![](https://cdn.nlark.com/yuque/0/2025/png/26235666/1759071777570-49c69113-5c12-49c4-9118-1ddc2d549cd4.png) + +#### 3.3 项目特色介绍 ++ 前端用户体验(UE)方面 + +1. 一体化的聊天体验 + +流畅的实时对话 + +项目通过 WebSocket 实现了毫秒级响应的实时通信,确保用户与 AI 的对话体验如同与真人交流般自然。消息状态实时更新(发送中、接收中、已完成),让用户清楚了解消息处理进度。AI 回复采用流式传输和打字机效果,逐字显示回复内容,模拟真实对话节奏,增强交互的自然感。 + +多模态交互设计 + +项目支持文本和语音两种交互方式,用户可以在聊天界面中自由切换。语音功能实现了完整的音频处理流程,包括语音录制、播放和识别转换,让用户可以根据场景和偏好选择最合适的交流方式。聊天界面设计简洁直观,输入区域固定在底部,消息展示区域自适应,确保对话内容始终可见。 + +智能消息管理 + +聊天消息采用数组结构管理,支持添加新消息、加载历史消息和删除消息。系统会自动保存对话历史,用户可以随时查看之前的对话内容。消息卡片设计清晰,区分用户和 AI 的消息,使用不同的颜色和位置来区分发送方,使对话结构一目了然。 + +2. 直观的角色管理 + +视觉化角色展示 + +项目采用卡片式设计展示角色,正面显示角色基本信息(头像、名称、欢迎语和聊天次数),背面显示详细描述和操作按钮。卡片具有翻转动画效果,增加浏览的趣味性。精选角色以轮播形式展示在页面顶部,吸引用户注意,同时提供快速访问通道。 + +简化的角色创建 + +角色创建表单采用清晰的分区设计,包含必填项和选填项。用户可以通过点击上传按钮选择角色头像,系统会自动检查图片格式和大小。角色声音提供下拉选择,包含多种音色选项。表单设计直观,包含必填项验证,确保用户输入完整,同时提供实时反馈,避免提交后才发现错误。 + +智能角色搜索与筛选 + +角色搜索功能支持关键词搜索和多种筛选条件(热门、最新、我的等)。搜索结果以卡片形式展示,具有精美的翻转动画效果。列表区域提供排序选项,用户可以按热门、最新等条件排序。搜索过程提供加载状态提示,让用户了解系统正在处理请求。 + +3. 响应式设计 + +自适应布局 + +项目采用 rem 单位进行移动端适配,根据屏幕宽度动态计算根元素字体大小。使用 postcss-pxtorem 自动将 px 转换为 rem,确保在不同设备上显示一致。布局组件根据设备类型自动调整侧边栏状态,移动端默认收起侧边栏以节省空间。 + +移动端优化 + +移动端特别优化了触控操作,按钮大小适合手指点击。聊天界面在移动端会调整输入区域和消息展示区域的比例,确保良好的输入体验。侧边栏在小屏幕设备上通过汉堡菜单访问,减少对主内容的干扰。 + +设备检测与适配 + +项目通过 isMobile() 函数检测用户设备类型,根据检测结果提供不同的界面布局和交互方式。例如,在移动设备上,聊天界面会隐藏不必要的装饰元素,专注于核心功能。 + +## 四、项目接口说明 +### 4.1 项目结构 +#### 4.1.1 前端 +```plain +vocata-web/ +├── Dockerfile +├── README-ENV.md +├── README.md +├── env.d.ts +├── eslint.config.ts +├── index.html +├── package.json +├── public/ +│ └── favicon.ico +├── src/ +│ ├── App.vue +│ ├── api/ +│ │ ├── modules/ +│ │ │ ├── conversation.ts +│ │ │ ├── role.ts +│ │ │ └── user.ts +│ │ └── request.ts +│ ├── assets/ +│ │ ├── images/ +│ │ └── styles/ +│ ├── layouts/ +│ │ ├── BasicLayout.vue +│ │ ├── SliderBar.vue +│ │ └── UserInfo.vue +│ ├── main.ts +│ ├── router/ +│ │ ├── guards.ts +│ │ ├── index.ts +│ │ └── routes.ts +│ ├── store/ +│ │ ├── index.ts +│ │ └── modules/ +│ │ └── chatHistory.ts +│ ├── types/ +│ │ ├── api.ts +│ │ ├── common.ts +│ │ └── debounce.ts +│ ├── utils/ +│ │ ├── aiChat.ts +│ │ ├── isMobile.ts +│ │ ├── rem.ts +│ │ └── token.ts +│ └── views/ +│ ├── ChatPage.vue +│ ├── LoginPage.vue +│ ├── NewRole.vue +│ ├── SearchRole.vue +│ └── components/ +├── tsconfig.app.json +├── tsconfig.json +├── tsconfig.node.json +└── vite.config.ts + +``` + + +4.2.4 会话管理接口 + +## 五、项目部署 +### 5.1 前端部署 +#### 5.1.1 Vite 生产构建配置 +```typescript +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + // 根据当前模式加载对应的环境变量 + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, + server: { + port: 3000, + host: true, + proxy: { + // 代理所有 /api 开头的请求到后端服务器 + '/api': { + target: env.VITE_APP_URL, + changeOrigin: true, + // secure: false, + rewrite: (path) => path.replace(/^\/client/, 'client') + + } + } + }, + } +}) +``` + +#### 5.1.2 环境变量配置 +```plain +// 环境配置文件 +// .env.development +# 开发环境配置 - 默认使用本地环境 +VITE_APP_URL=http://127.0.0.1:4523/m1/7166225-6890394-default +VUE_APP_BASE_API=/api +VUE_APP_TITLE=VocaTa - 开发环境 +VITE_APP_ENV=development + +// .env.production +# 生产环境配置 +# 注意:VITE_APP_URL 将在CI/CD构建时动态替换 +VITE_APP_URL=http://{{PRODUCTION_HOST}}:9009 +VUE_APP_BASE_API=/api +VUE_APP_TITLE=VocaTa +VITE_APP_ENV=production + +// .env.test +# 测试环境配置 +# 注意:VITE_APP_URL 将在CI/CD构建时动态替换 +VITE_APP_URL=http://101.200.141.46:9009 +VUE_APP_BASE_API=/api +VUE_APP_TITLE=VocaTa 管理后台 - 测试环境 +VITE_APP_ENV=test +``` + diff --git a/vocata-admin/.editorconfig b/vocata-admin/.editorconfig new file mode 100644 index 0000000..3b510aa --- /dev/null +++ b/vocata-admin/.editorconfig @@ -0,0 +1,8 @@ +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 100 diff --git a/vocata-admin/.env.development b/vocata-admin/.env.development new file mode 100644 index 0000000..e2b885d --- /dev/null +++ b/vocata-admin/.env.development @@ -0,0 +1,4 @@ +# 测试环境配置 +VITE_APP_URL=http://127.0.0.1:9009 +VUE_APP_TITLE=VocaTa - 测试环境 +VITE_APP_ENV=test \ No newline at end of file diff --git a/vocata-admin/.env.production b/vocata-admin/.env.production new file mode 100644 index 0000000..f64a14f --- /dev/null +++ b/vocata-admin/.env.production @@ -0,0 +1,5 @@ +# 生产环境配置 +# 注意:VITE_APP_URL 将在CI/CD构建时动态替换 +VITE_APP_URL=http://{{PRODUCTION_HOST}}:9009 +VUE_APP_TITLE=VocaTa +VITE_APP_ENV=production \ No newline at end of file diff --git a/vocata-admin/.env.test b/vocata-admin/.env.test new file mode 100644 index 0000000..ce93b83 --- /dev/null +++ b/vocata-admin/.env.test @@ -0,0 +1,5 @@ +# 测试环境配置 +# 注意:VITE_APP_URL 将在CI/CD构建时动态替换 +VITE_APP_URL=http://{{STAGING_HOST}}:9009 +VUE_APP_TITLE=VocaTa - 测试环境 +VITE_APP_ENV=test \ No newline at end of file diff --git a/vocata-admin/.gitattributes b/vocata-admin/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/vocata-admin/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/vocata-admin/.gitignore b/vocata-admin/.gitignore new file mode 100644 index 0000000..8ee54e8 --- /dev/null +++ b/vocata-admin/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/vocata-admin/.prettierrc.json b/vocata-admin/.prettierrc.json new file mode 100644 index 0000000..29a2402 --- /dev/null +++ b/vocata-admin/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/vocata-admin/Dockerfile b/vocata-admin/Dockerfile new file mode 100644 index 0000000..a99b11d --- /dev/null +++ b/vocata-admin/Dockerfile @@ -0,0 +1,166 @@ +# VocaTa前端客户端 - 多阶段构建Dockerfile +# 基于Node.js官方镜像 + +# 构建阶段 +FROM node:20-alpine AS build + +# 安装必要的构建工具 +RUN apk add --no-cache python3 make g++ git + +# 设置工作目录 +WORKDIR /app + +# 复制package文件 +COPY package*.json ./ + +# 安装依赖 +RUN npm ci --only=production && npm cache clean --force + +# 复制源代码 +COPY . . + +# 构建应用 +ARG BUILD_MODE=production +RUN npm run build:${BUILD_MODE} + +# 生产阶段 - 使用Nginx托管静态文件 +FROM nginx:1.25-alpine + +# 安装必要工具 +RUN apk add --no-cache \ + curl \ + tzdata \ + dumb-init + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 创建nginx用户和目录 +RUN addgroup -g 1001 -S vocata && \ + adduser -u 1001 -S vocata -G vocata && \ + mkdir -p /var/cache/nginx /var/log/nginx /var/lib/nginx && \ + chown -R vocata:vocata /var/cache/nginx /var/log/nginx /var/lib/nginx /etc/nginx + +# 复制构建产物 +COPY --from=build --chown=vocata:vocata /app/dist /usr/share/nginx/html + +# 创建Nginx配置文件 +RUN cat > /etc/nginx/nginx.conf << 'EOF' +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 性能优化 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/js + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # 服务器配置 + server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html index.htm; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + + # SPA路由支持 + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API代理(如果需要) + location /api { + proxy_pass http://vocata-server:9009; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 健康检查端点 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} +EOF + +# 设置权限 +RUN chown -R vocata:vocata /usr/share/nginx/html /etc/nginx + +# 切换到非root用户 +USER vocata + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# 构建参数和标签 +ARG BUILD_DATE +ARG VERSION="1.0.0" +LABEL maintainer="VocaTa Team " \ + version="${VERSION}" \ + build-date="${BUILD_DATE}" \ + description="VocaTa AI角色扮演平台前端客户端" \ + org.opencontainers.image.title="vocata-web" \ + org.opencontainers.image.description="VocaTa AI Role Playing Platform Frontend Client" \ + org.opencontainers.image.url="https://github.com/leivik/vocata" \ + org.opencontainers.image.vendor="VocaTa Team" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" + +# 启动Nginx +ENTRYPOINT ["dumb-init", "--"] +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/vocata-admin/README.md b/vocata-admin/README.md new file mode 100644 index 0000000..c7ca5a3 --- /dev/null +++ b/vocata-admin/README.md @@ -0,0 +1,39 @@ +# VocaTa-front + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Type Support for `.vue` Imports in TS + +TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Type-Check, Compile and Minify for Production + +```sh +npm run build +``` + +### Lint with [ESLint](https://eslint.org/) + +```sh +npm run lint +``` diff --git a/vocata-admin/env.d.ts b/vocata-admin/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/vocata-admin/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vocata-admin/eslint.config.ts b/vocata-admin/eslint.config.ts new file mode 100644 index 0000000..20475f8 --- /dev/null +++ b/vocata-admin/eslint.config.ts @@ -0,0 +1,22 @@ +import { globalIgnores } from 'eslint/config' +import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' +import pluginVue from 'eslint-plugin-vue' +import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' + +// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: +// import { configureVueProject } from '@vue/eslint-config-typescript' +// configureVueProject({ scriptLangs: ['ts', 'tsx'] }) +// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup + +export default defineConfigWithVueTs( + { + name: 'app/files-to-lint', + files: ['**/*.{ts,mts,tsx,vue}'], + }, + + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), + + pluginVue.configs['flat/essential'], + vueTsConfigs.recommended, + skipFormatting, +) diff --git a/vocata-admin/index.html b/vocata-admin/index.html new file mode 100644 index 0000000..fcd9708 --- /dev/null +++ b/vocata-admin/index.html @@ -0,0 +1,16 @@ + + + + + + + + 语Ta + + + +
+ + + + \ No newline at end of file diff --git a/vocata-admin/package-lock.json b/vocata-admin/package-lock.json new file mode 100644 index 0000000..5d0cb06 --- /dev/null +++ b/vocata-admin/package-lock.json @@ -0,0 +1,6728 @@ +{ + "name": "vocata-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vocata-web", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@tailwindcss/vite": "^4.1.13", + "@types/js-cookie": "^3.0.6", + "axios": "^1.12.2", + "element-plus": "^2.11.3", + "js-cookie": "^3.0.5", + "pinia": "^3.0.3", + "tailwindcss": "^4.1.13", + "vue": "^3.5.18", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/node": "^22.16.5", + "@types/postcss-pxtorem": "^6.1.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.7.0", + "eslint": "^9.31.0", + "eslint-plugin-vue": "~10.3.0", + "jiti": "^2.4.2", + "npm-run-all2": "^8.0.4", + "postcss-pxtorem": "^6.1.0", + "prettier": "3.6.2", + "sass": "^1.93.0", + "typescript": "~5.8.0", + "vite": "^7.0.6", + "vite-plugin-vue-devtools": "^8.0.0", + "vue-tsc": "^3.0.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", + "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz", + "integrity": "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-decorators": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", + "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", + "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", + "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.0.tgz", + "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.0.tgz", + "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.0.tgz", + "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.0.tgz", + "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.0.tgz", + "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.0.tgz", + "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.0.tgz", + "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.0.tgz", + "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.0.tgz", + "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.0.tgz", + "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.0.tgz", + "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.0.tgz", + "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.0.tgz", + "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.0.tgz", + "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.0.tgz", + "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.0.tgz", + "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.0.tgz", + "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.0.tgz", + "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.0.tgz", + "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.0.tgz", + "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.0.tgz", + "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.0.tgz", + "integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.13.tgz", + "integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "tailwindcss": "4.1.13" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tsconfig/node22": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz", + "integrity": "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/postcss-pxtorem": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/postcss-pxtorem/-/postcss-pxtorem-6.1.0.tgz", + "integrity": "sha512-kHsYTjQgllOfhi3J+xunjMKUZ3APARV/JYeOOcIVLhvPVS162S8Ir8LsZwioFFyYCSnQp+aisupiSaRWVwKyDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss": "^8.2.6" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", + "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/type-utils": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.0.tgz", + "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.0.tgz", + "integrity": "sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.44.0", + "@typescript-eslint/types": "^8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.0.tgz", + "integrity": "sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.0.tgz", + "integrity": "sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.0.tgz", + "integrity": "sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.0.tgz", + "integrity": "sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.0.tgz", + "integrity": "sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.44.0", + "@typescript-eslint/tsconfig-utils": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.0.tgz", + "integrity": "sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.0.tgz", + "integrity": "sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", + "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz", + "integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.23" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz", + "integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz", + "integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", + "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz", + "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@vue/babel-helper-vue-transform-on": "1.5.0", + "@vue/babel-plugin-resolve-type": "1.5.0", + "@vue/shared": "^3.5.18" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz", + "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.0", + "@vue/compiler-sfc": "^3.5.18" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.18", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, + "node_modules/@vue/devtools-core": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.0.2.tgz", + "integrity": "sha512-V7eKTTHoS6KfK8PSGMLZMhGv/9yNDrmv6Qc3r71QILulnzPnqK2frsTyx3e2MrhdUZnENPEm6hcb4z0GZOqNhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.2", + "@vue/devtools-shared": "^8.0.2", + "mitt": "^3.0.1", + "nanoid": "^5.1.5", + "pathe": "^2.0.3", + "vite-hot-client": "^2.1.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/@vue/devtools-core/node_modules/@vue/devtools-kit": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.2.tgz", + "integrity": "sha512-yjZKdEmhJzQqbOh4KFBfTOQjDPMrjjBNCnHBvnTGJX+YLAqoUtY2J+cg7BE+EA8KUv8LprECq04ts75wCoIGWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.2", + "birpc": "^2.5.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-core/node_modules/@vue/devtools-shared": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.2.tgz", + "integrity": "sha512-mLU0QVdy5Lp40PMGSixDw/Kbd6v5dkQXltd2r+mdVQV7iUog2NlZuLxFZApFZ/mObUBDhoCpf0T3zF2FWWdeHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/devtools-core/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@vue/devtools-core/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", + "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.7", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", + "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/eslint-config-typescript": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.6.0.tgz", + "integrity": "sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.35.1", + "fast-glob": "^3.3.3", + "typescript-eslint": "^8.35.1", + "vue-eslint-parser": "^10.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0", + "eslint-plugin-vue": "^9.28.0 || ^10.0.0", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.0.7.tgz", + "integrity": "sha512-0sqqyqJ0Gn33JH3TdIsZLCZZ8Gr4kwlg8iYOnOrDDkJKSjFurlQY/bEFQx5zs7SX2C/bjMkmPYq/NiyY1fTOkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^2.0.5", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", + "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", + "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", + "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/runtime-core": "3.5.21", + "@vue/shared": "3.5.21", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", + "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "vue": "3.5.21" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.7.tgz", + "integrity": "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", + "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/birpc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz", + "integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true, + "license": "ISC" + }, + "node_modules/element-plus": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.3.tgz", + "integrity": "sha512-769xsjLR4B9Vf9cl5PDXnwTEdmFJvMgAkYtthdJKPhjVjU3hdAwTJ+gXKiO+PUyo2KWFwOYKZd4Ywh6PHfkbJg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.3.0.tgz", + "integrity": "sha512-A0u9snqjCfYaPnqqOaH6MBLVWDUIN4trXn8J3x67uDcXvR7X6Ut8p16N+nYhMCQ9Y7edg2BIRGzfyZsY0IdqoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "vue-eslint-parser": "^10.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/npm-run-all2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm-run-all2/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm-run-all2/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm-run-all2/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pinia": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz", + "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-pxtorem": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-pxtorem/-/postcss-pxtorem-6.1.0.tgz", + "integrity": "sha512-ROODSNci9ADal3zUcPHOF/K83TiCgNSPXQFSbwyPHNV8ioHIE4SaC+FPOufd8jsr5jV2uIz29v1Uqy1c4ov42g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.0.tgz", + "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.0", + "@rollup/rollup-android-arm64": "4.52.0", + "@rollup/rollup-darwin-arm64": "4.52.0", + "@rollup/rollup-darwin-x64": "4.52.0", + "@rollup/rollup-freebsd-arm64": "4.52.0", + "@rollup/rollup-freebsd-x64": "4.52.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", + "@rollup/rollup-linux-arm-musleabihf": "4.52.0", + "@rollup/rollup-linux-arm64-gnu": "4.52.0", + "@rollup/rollup-linux-arm64-musl": "4.52.0", + "@rollup/rollup-linux-loong64-gnu": "4.52.0", + "@rollup/rollup-linux-ppc64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-musl": "4.52.0", + "@rollup/rollup-linux-s390x-gnu": "4.52.0", + "@rollup/rollup-linux-x64-gnu": "4.52.0", + "@rollup/rollup-linux-x64-musl": "4.52.0", + "@rollup/rollup-openharmony-arm64": "4.52.0", + "@rollup/rollup-win32-arm64-msvc": "4.52.0", + "@rollup/rollup-win32-ia32-msvc": "4.52.0", + "@rollup/rollup-win32-x64-gnu": "4.52.0", + "@rollup/rollup-win32-x64-msvc": "4.52.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sass": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.0.tgz", + "integrity": "sha512-CQi5/AzCwiubU3dSqRDJ93RfOfg/hhpW1l6wCIvolmehfwgCI35R/0QDs1+R+Ygrl8jFawwwIojE2w47/mf94A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.0.tgz", + "integrity": "sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.44.0", + "@typescript-eslint/parser": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.0.tgz", + "integrity": "sha512-JLoggz+PvLVMJo+jZt97hdIIIZ2yTzGgft9e9q8iMrC4ewufl62ekeW7mixBghonn2gVb/ICjyvlmOCUBnJLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-utils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-dev-rpc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-dev-rpc/-/vite-dev-rpc-1.1.0.tgz", + "integrity": "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==", + "dev": true, + "license": "MIT", + "dependencies": { + "birpc": "^2.4.0", + "vite-hot-client": "^2.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" + } + }, + "node_modules/vite-hot-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.1.0.tgz", + "integrity": "sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" + } + }, + "node_modules/vite-plugin-inspect": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz", + "integrity": "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.1.0", + "debug": "^4.4.1", + "error-stack-parser-es": "^1.0.5", + "ohash": "^2.0.11", + "open": "^10.2.0", + "perfect-debounce": "^2.0.0", + "sirv": "^3.0.1", + "unplugin-utils": "^0.3.0", + "vite-dev-rpc": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite-plugin-inspect/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-plugin-vue-devtools": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.2.tgz", + "integrity": "sha512-1069qvMBcyAu3yXQlvYrkwoyLOk0lSSR/gTKy/vy+Det7TXnouGei6ZcKwr5TIe938v/14oLlp0ow6FSJkkORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-core": "^8.0.2", + "@vue/devtools-kit": "^8.0.2", + "@vue/devtools-shared": "^8.0.2", + "execa": "^9.6.0", + "sirv": "^3.0.2", + "vite-plugin-inspect": "^11.3.3", + "vite-plugin-vue-inspector": "^5.3.2" + }, + "engines": { + "node": ">=v14.21.3" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0" + } + }, + "node_modules/vite-plugin-vue-devtools/node_modules/@vue/devtools-kit": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.2.tgz", + "integrity": "sha512-yjZKdEmhJzQqbOh4KFBfTOQjDPMrjjBNCnHBvnTGJX+YLAqoUtY2J+cg7BE+EA8KUv8LprECq04ts75wCoIGWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.2", + "birpc": "^2.5.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/vite-plugin-vue-devtools/node_modules/@vue/devtools-shared": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.2.tgz", + "integrity": "sha512-mLU0QVdy5Lp40PMGSixDw/Kbd6v5dkQXltd2r+mdVQV7iUog2NlZuLxFZApFZ/mObUBDhoCpf0T3zF2FWWdeHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/vite-plugin-vue-devtools/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-plugin-vue-inspector": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.2.tgz", + "integrity": "sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/plugin-proposal-decorators": "^7.23.0", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.22.15", + "@vue/babel-plugin-jsx": "^1.1.5", + "@vue/compiler-dom": "^3.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.4" + }, + "peerDependencies": { + "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", + "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-sfc": "3.5.21", + "@vue/runtime-dom": "3.5.21", + "@vue/server-renderer": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", + "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-router": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.0.7.tgz", + "integrity": "sha512-BSMmW8GGEgHykrv7mRk6zfTdK+tw4MBZY/x6fFa7IkdXK3s/8hQRacPjG9/8YKFDIWGhBocwi6PlkQQ/93OgIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.23", + "@vue/language-core": "3.0.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/vocata-admin/package.json b/vocata-admin/package.json new file mode 100644 index 0000000..80ac8af --- /dev/null +++ b/vocata-admin/package.json @@ -0,0 +1,56 @@ +{ + "name": "vocata-web", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite --mode development", + "dev:local": "vite --mode local", + "dev:test": "vite --mode test", + "build": "vite build --mode production", + "build:local": "vite build --mode local", + "build:test": "vite build --mode test", + "build:prod": "vite build --mode production", + "build:production": "vite build --mode production", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build", + "lint": "eslint . --fix", + "format": "prettier --write src/" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@tailwindcss/vite": "^4.1.13", + "@types/js-cookie": "^3.0.6", + "axios": "^1.12.2", + "element-plus": "^2.11.3", + "js-cookie": "^3.0.5", + "pinia": "^3.0.3", + "tailwindcss": "^4.1.13", + "vue": "^3.5.18", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/node": "^22.16.5", + "@types/postcss-pxtorem": "^6.1.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.7.0", + "eslint": "^9.31.0", + "eslint-plugin-vue": "~10.3.0", + "jiti": "^2.4.2", + "npm-run-all2": "^8.0.4", + "postcss-pxtorem": "^6.1.0", + "prettier": "3.6.2", + "sass": "^1.93.0", + "typescript": "~5.8.0", + "vite": "^7.0.6", + "vite-plugin-vue-devtools": "^8.0.0", + "vue-tsc": "^3.0.4" + } +} diff --git a/vocata-admin/public/favicon.ico b/vocata-admin/public/favicon.ico new file mode 100644 index 0000000..b9a89b0 Binary files /dev/null and b/vocata-admin/public/favicon.ico differ diff --git a/vocata-admin/src/App.vue b/vocata-admin/src/App.vue new file mode 100644 index 0000000..f4eba70 --- /dev/null +++ b/vocata-admin/src/App.vue @@ -0,0 +1,8 @@ + + diff --git a/vocata-admin/src/api/index.ts b/vocata-admin/src/api/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/vocata-admin/src/api/modules/role.ts b/vocata-admin/src/api/modules/role.ts new file mode 100644 index 0000000..a424b3d --- /dev/null +++ b/vocata-admin/src/api/modules/role.ts @@ -0,0 +1,25 @@ + +import request from '../request' + +export const roleApi = { + //增 + + // 删 + deleteRole(id: number) { + return request({ + url: `/api/admin/character/${id}`, + method: 'delete' + }) + }, + + //改 + + //查 + getRoleList(params: any) { + return request({ + url: '/api/admin/character', + method: 'get', + params + }) + } +} \ No newline at end of file diff --git a/vocata-admin/src/api/modules/user.ts b/vocata-admin/src/api/modules/user.ts new file mode 100644 index 0000000..8893f7a --- /dev/null +++ b/vocata-admin/src/api/modules/user.ts @@ -0,0 +1,36 @@ +import type { LoginParams, RegisterParams, Response, LoginResponse } from '@/types/api' +import request from '../request' + +export const userApi = { + // 登录 + login(params: LoginParams): Promise> { + return request.post('/api/client/auth/login', params) + }, + // 注册 + register(params: RegisterParams): Promise> { + return request.post('/api/client/auth/register', params) + }, + // 发送验证码 + sendCode(email: string): Promise> { + return request.post('/api/client/auth/sendCode', { email }) + }, + + // 退出登录 + logout(): Promise> { + return request.post('/api/client/auth/logout') + }, + // 获取用户信息 + getUserInfo(params): Promise> { + return request.get('/api/admin/user/list', params) + }, + + // 修改用户状态 + updateUserStatus(id, params): Promise> { + return request.put(`/api/admin/user/${id}/status`, params) + }, + + // 获取管理员信息 + getAdminInfo(): Promise> { + return request.get('/api/admin/auth/current') + }, +} \ No newline at end of file diff --git a/vocata-admin/src/api/request.ts b/vocata-admin/src/api/request.ts new file mode 100644 index 0000000..d8a7eb2 --- /dev/null +++ b/vocata-admin/src/api/request.ts @@ -0,0 +1,73 @@ +// src/utils/request.js +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { getToken, removeToken } from '@/utils/token' +import router from '@/router' + +// 创建axios实例 +const request = axios.create({ + baseURL: import.meta.env.VITE_APP_URL, // 从环境变量读取 + // baseURL: 'http://127.0.0.1:4523/m1/7166225-6890394-default/', // 从环境变量读取 + timeout: 10000 // 请求超时时间 +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + if (getToken()) { + config.headers['Authorization'] = 'Bearer ' + getToken() + } + return config + }, + (error) => { + console.error('API请求错误:', error) + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + const res = response.data + + // // 根据你的后端接口约定修改判断逻辑 + // if (res.code === 200) { + return res + // } else { + // ElMessage.error(res.message || '请求失败') + // return Promise.reject(new Error(res.message || 'Error')) + // } + }, + (error) => { + // 处理HTTP错误状态码 + let message = '' + if (error.response) { + switch (error.response.status) { + case 401: + message = '未授权,请重新登录' + removeToken() + // 跳转到登录页 + router.push('/login') + break + case 403: + message = '拒绝访问' + break + case 404: + message = '请求地址错误' + break + case 500: + message = '服务器内部错误' + break + default: + message = '网络错误' + } + } else { + message = '未知错误' + } + + ElMessage.error(message) + return Promise.reject(error) + } +) + +export default request \ No newline at end of file diff --git a/vocata-admin/src/assets/images/loginPic.png b/vocata-admin/src/assets/images/loginPic.png new file mode 100644 index 0000000..f6eabe2 Binary files /dev/null and b/vocata-admin/src/assets/images/loginPic.png differ diff --git a/vocata-admin/src/assets/images/logo-text.png b/vocata-admin/src/assets/images/logo-text.png new file mode 100644 index 0000000..847cd4c Binary files /dev/null and b/vocata-admin/src/assets/images/logo-text.png differ diff --git a/vocata-admin/src/assets/images/logo.png b/vocata-admin/src/assets/images/logo.png new file mode 100644 index 0000000..b9a89b0 Binary files /dev/null and b/vocata-admin/src/assets/images/logo.png differ diff --git a/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Bold.woff b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Bold.woff new file mode 100644 index 0000000..98a673a Binary files /dev/null and b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Bold.woff differ diff --git a/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Bold.woff2 b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Bold.woff2 new file mode 100644 index 0000000..4f87f38 Binary files /dev/null and b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Bold.woff2 differ diff --git a/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Regular.woff b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Regular.woff new file mode 100644 index 0000000..8fe55bc Binary files /dev/null and b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Regular.woff differ diff --git a/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Regular.woff2 b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Regular.woff2 new file mode 100644 index 0000000..add9d78 Binary files /dev/null and b/vocata-admin/src/assets/styles/SweiB2SansCJKsc-Regular.woff2 differ diff --git a/vocata-admin/src/assets/styles/fonts.css b/vocata-admin/src/assets/styles/fonts.css new file mode 100644 index 0000000..588e68a --- /dev/null +++ b/vocata-admin/src/assets/styles/fonts.css @@ -0,0 +1,17 @@ +@import "tailwindcss"; + +@font-face { + font-family: 'CustomFont'; + src: url('./SweiB2SansCJKsc-Bold.woff') format('woff'); + src: url('./SweiB2SansCJKsc-Bold.woff2') format('woff2'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'CustomFont'; + src: url('./SweiB2SansCJKsc-Regular.woff') format('woff'); + src: url('./SweiB2SansCJKsc-Regular.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} \ No newline at end of file diff --git a/vocata-admin/src/layouts/BasicLayout.vue b/vocata-admin/src/layouts/BasicLayout.vue new file mode 100644 index 0000000..f6f77fa --- /dev/null +++ b/vocata-admin/src/layouts/BasicLayout.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/vocata-admin/src/layouts/BreadCrum.vue b/vocata-admin/src/layouts/BreadCrum.vue new file mode 100644 index 0000000..a2e2192 --- /dev/null +++ b/vocata-admin/src/layouts/BreadCrum.vue @@ -0,0 +1,32 @@ + + + + + + + diff --git a/vocata-admin/src/layouts/MenuCom.vue b/vocata-admin/src/layouts/MenuCom.vue new file mode 100644 index 0000000..fafbb20 --- /dev/null +++ b/vocata-admin/src/layouts/MenuCom.vue @@ -0,0 +1,64 @@ + + + + + + + diff --git a/vocata-admin/src/layouts/TabBar.vue b/vocata-admin/src/layouts/TabBar.vue new file mode 100644 index 0000000..26e39e3 --- /dev/null +++ b/vocata-admin/src/layouts/TabBar.vue @@ -0,0 +1,100 @@ + + + + diff --git a/vocata-admin/src/main.ts b/vocata-admin/src/main.ts new file mode 100644 index 0000000..6738661 --- /dev/null +++ b/vocata-admin/src/main.ts @@ -0,0 +1,19 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +// import '@/utils/rem.js' +import '@/assets/styles/fonts.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} +app.mount('#app') diff --git a/vocata-admin/src/router/guards.ts b/vocata-admin/src/router/guards.ts new file mode 100644 index 0000000..50bc0fa --- /dev/null +++ b/vocata-admin/src/router/guards.ts @@ -0,0 +1,29 @@ +import { getToken } from '@/utils/token' +import { ElMessage } from 'element-plus' +import type { Router } from 'vue-router' + +const whiteList = ['/passport/login', '/404'] +export default function setupRouterGuard(router: Router) { + router.beforeEach(async (to, from, next) => { + const token = getToken() + + // 白名单检查 + if (whiteList.includes(to.path)) { + // 如果已经登录,访问登录页时重定向到首页 + if (token && to.path === '/passport/login') { + next('/') + return + } + next() + return + } + + // 非白名单路径检查token + if (!token) { + ElMessage.warning('登录过期,请重新登录') + next(`/passport/login?redirect=${encodeURIComponent(to.fullPath)}`) + return + } + next() + }) +} \ No newline at end of file diff --git a/vocata-admin/src/router/index.ts b/vocata-admin/src/router/index.ts new file mode 100644 index 0000000..e632cc0 --- /dev/null +++ b/vocata-admin/src/router/index.ts @@ -0,0 +1,10 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import routes from './routes.ts' +import guard from './guards' + +const router = createRouter({ + history: createWebHashHistory(import.meta.env.BASE_URL), + routes +}) +guard(router) +export default router diff --git a/vocata-admin/src/router/routes.ts b/vocata-admin/src/router/routes.ts new file mode 100644 index 0000000..ef57814 --- /dev/null +++ b/vocata-admin/src/router/routes.ts @@ -0,0 +1,67 @@ +import type { RouteRecordRaw } from "vue-router" +import BasicLayout from '@/layouts/BasicLayout.vue' +const routes: RouteRecordRaw[] = [ + // 登录模块 + { + path: '/passport', + name: 'Passport', + component: () => import('@/views/passport/PassPort.vue'), + meta: { title: '通行证', hidden: true }, + children: [ + { + path: '/passport/login', + name: 'Login', + component: () => import('@/views/passport/LoginPage.vue'), + meta: { title: '登录', hidden: true } + } + ] + }, + // 角色管理模块 + { + path: '/role', + name: 'Role', + component: BasicLayout, + + meta: { title: '角色管理', icon: 'User' }, + children: [ + { + path: '/role/roles', + name: 'Roles', + component: () => import('@/views/RolePage.vue'), + meta: { title: '角色管理', icon: 'User' } + } + ] + }, + // 角色管理模块 + { + path: '/user', + name: 'User', + component: BasicLayout, + + meta: { title: '用户管理', icon: 'User' }, + children: [ + { + path: '/user/users', + name: 'Users', + component: () => import('@/views/UserPage.vue'), + meta: { title: '用户管理', icon: 'User' } + } + ] + }, + // 根路由 + { + path: '/', + name: 'Root', + component: () => import('@/layouts/BasicLayout.vue'), + meta: { title: '首页', hidden: true }, + redirect: '/role/roles' + }, + // 404页面 + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/ErrorPage.vue'), + meta: { title: '页面不存在', hidden: true } + }, +] +export default routes \ No newline at end of file diff --git a/vocata-admin/src/store/index.ts b/vocata-admin/src/store/index.ts new file mode 100644 index 0000000..f996a05 --- /dev/null +++ b/vocata-admin/src/store/index.ts @@ -0,0 +1,10 @@ + +import routes from '@/router/routes' +import { defineStore } from 'pinia' +export const user = defineStore('user', { + state: () => { + return { + menuRoutes: routes, + } + } +}) \ No newline at end of file diff --git a/vocata-admin/src/types/api.ts b/vocata-admin/src/types/api.ts new file mode 100644 index 0000000..dc327f0 --- /dev/null +++ b/vocata-admin/src/types/api.ts @@ -0,0 +1,51 @@ +// 公开角色查询参数 +export interface PublicRoleQuery { + keywords?: string, + status?: number, + isFeatured?: number, + isTrending?: number, + tags?: string[], + language?: string, + creatorId?: number, + pageNum: number, + pageSize: number, + orderBy?: string, + orderDirection?: string +} + +// 登录参数 +export interface LoginParams { + loginName: string, + password: string, + rememberMe: boolean +} + +// 注册参数 +export interface RegisterParams { + nickname: string, + password: string, + email: string, + confirmPassword: string, + verificationCode: string, + gender: number, + hasRead?: boolean +} + +// 登录响应数据 +export interface LoginResponse { + token: string, + expiresIn: number +} + +// 修改密码参数 +export interface ChangePasswordParams { + oldPassword: string, + newPassword: string +} + +// 返回参数 +export interface Response { + code: number, + message: string, + data: T +} \ No newline at end of file diff --git a/vocata-admin/src/types/common.ts b/vocata-admin/src/types/common.ts new file mode 100644 index 0000000..42b78c3 --- /dev/null +++ b/vocata-admin/src/types/common.ts @@ -0,0 +1,22 @@ +export interface roleInfo { + "id"?: number, + "characterCode"?: string, + "name"?: string, + "description"?: string, + "greeting"?: string, + "avatarUrl"?: string, + "tags"?: string, + "language"?: string, + "status"?: number, + "statusName"?: string, + "isOfficial"?: number, + "isFeatured"?: number, + "isTrending"?: number, + "trendingScore"?: number, + "chatCount"?: number, + "userCount"?: number, + "isPrivate"?: boolean, + "creatorId"?: number, + "createdAt"?: string, + "updatedAt"?: string +} \ No newline at end of file diff --git a/vocata-admin/src/utils/debounce.ts b/vocata-admin/src/utils/debounce.ts new file mode 100644 index 0000000..cd8c5bb --- /dev/null +++ b/vocata-admin/src/utils/debounce.ts @@ -0,0 +1,32 @@ +// 防抖函数 +function debounce void>( + func: T, + wait: number, + immediate: boolean = false +): (...args: Parameters) => void { + + let timeout: ReturnType | null = null; + + return function (this: unknown, ...args: Parameters): void { + const later = (): void => { + timeout = null; + if (!immediate) { + func.apply(this, args); + } + }; + + const shouldCallNow = immediate && timeout === null; + + if (timeout !== null) { + clearTimeout(timeout); + } + + timeout = setTimeout(later, wait); + + if (shouldCallNow) { + func.apply(this, args); + } + }; + +} +export default debounce; \ No newline at end of file diff --git a/vocata-admin/src/utils/isMobile.ts b/vocata-admin/src/utils/isMobile.ts new file mode 100644 index 0000000..f1918b2 --- /dev/null +++ b/vocata-admin/src/utils/isMobile.ts @@ -0,0 +1,10 @@ +// src/utils/isMobile.js - 基于userAgent的简版 + +// 判断是否为移动设备 +export const isMobile = () => { + const userAgent = navigator.userAgent.toLowerCase() + return /iphone|ipod|android.*mobile|windows.*phone|blackberry.*mobile/i.test(userAgent) +} + +// 直接导出一个布尔值(当前状态) +export const isMobileNow = isMobile() \ No newline at end of file diff --git a/vocata-admin/src/utils/rem.ts b/vocata-admin/src/utils/rem.ts new file mode 100644 index 0000000..10fa4b9 --- /dev/null +++ b/vocata-admin/src/utils/rem.ts @@ -0,0 +1,18 @@ +(function () { + function setRem() { + const width = document.documentElement.clientWidth + + if (width <= 768) { + // 移动端:基于375px设计稿,1rem = 100px + document.documentElement.style.fontSize = (width / 375 * 100) + 'px' + } else { + // PC端:基于1920px设计稿,1rem = 100px + const fontSize = Math.min(width / 1920 * 100, 100) // 限制最大字体大小 + document.documentElement.style.fontSize = fontSize + 'px' + } + } + + setRem() + window.addEventListener('resize', setRem) + window.addEventListener('pageshow', (e) => e.persisted && setRem()) +})() \ No newline at end of file diff --git a/vocata-admin/src/utils/token.ts b/vocata-admin/src/utils/token.ts new file mode 100644 index 0000000..8307893 --- /dev/null +++ b/vocata-admin/src/utils/token.ts @@ -0,0 +1,15 @@ +import Cookies from "js-cookie"; + +export function getToken() { + return Cookies.get("token"); +} + +export function setToken(token: string, time: number) { + Cookies.set("token", token, { + expires: time, + }); +} + +export function removeToken() { + Cookies.remove("token"); +} \ No newline at end of file diff --git a/vocata-admin/src/views/ErrorPage.vue b/vocata-admin/src/views/ErrorPage.vue new file mode 100644 index 0000000..7097370 --- /dev/null +++ b/vocata-admin/src/views/ErrorPage.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/vocata-admin/src/views/RolePage.vue b/vocata-admin/src/views/RolePage.vue new file mode 100644 index 0000000..239ddeb --- /dev/null +++ b/vocata-admin/src/views/RolePage.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/vocata-admin/src/views/UserPage.vue b/vocata-admin/src/views/UserPage.vue new file mode 100644 index 0000000..a74ebc0 --- /dev/null +++ b/vocata-admin/src/views/UserPage.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/vocata-admin/src/views/passport/LoginPage.vue b/vocata-admin/src/views/passport/LoginPage.vue new file mode 100644 index 0000000..a6d8351 --- /dev/null +++ b/vocata-admin/src/views/passport/LoginPage.vue @@ -0,0 +1,98 @@ + + + diff --git a/vocata-admin/src/views/passport/PassPort.vue b/vocata-admin/src/views/passport/PassPort.vue new file mode 100644 index 0000000..dca77dd --- /dev/null +++ b/vocata-admin/src/views/passport/PassPort.vue @@ -0,0 +1,14 @@ + diff --git a/vocata-admin/tsconfig.app.json b/vocata-admin/tsconfig.app.json new file mode 100644 index 0000000..913b8f2 --- /dev/null +++ b/vocata-admin/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/vocata-admin/tsconfig.json b/vocata-admin/tsconfig.json new file mode 100644 index 0000000..66b5e57 --- /dev/null +++ b/vocata-admin/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/vocata-admin/tsconfig.node.json b/vocata-admin/tsconfig.node.json new file mode 100644 index 0000000..a83dfc9 --- /dev/null +++ b/vocata-admin/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/vocata-admin/vite.config.ts b/vocata-admin/vite.config.ts new file mode 100644 index 0000000..e77a98e --- /dev/null +++ b/vocata-admin/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' +import tailwindcss from '@tailwindcss/vite' +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + // 根据当前模式加载对应的环境变量 + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [ + vue(), + vueDevTools(), + tailwindcss() + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, + server: { + port: 3001, + host: true, + proxy: { + // 代理所有 /api 开头的请求到后端服务器 + '/api': { + target: env.VITE_APP_URL, + changeOrigin: true, + secure: false, + } + } + }, + } + +}) diff --git a/vocata-server/.dockerignore b/vocata-server/.dockerignore new file mode 100644 index 0000000..d1a9b48 --- /dev/null +++ b/vocata-server/.dockerignore @@ -0,0 +1,112 @@ +# Docker 构建忽略文件 +# 减少构建上下文大小,提高构建速度 + +# 版本控制 +.git +.gitignore +.github + +# 开发工具 +.vscode +.idea +*.iml +*.iws +*.ipr + +# 系统文件 +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# 日志文件 +logs/ +*.log +log4j.properties +logback-spring.xml + +# 临时文件 +tmp/ +temp/ +*.tmp + +# 编译产物 (在多阶段构建中不需要) +target/ +build/ +out/ +dist/ +node_modules/ +.npm + +# 测试相关 +coverage/ +test-results/ +junit.xml +*.cover + +# 运行时文件 +*.pid +*.seed +*.pid.lock + +# 环境配置 (包含敏感信息) +.env +.env.* +*.env +application-local.yml +application-dev.yml + +# 数据库文件 +*.db +*.sqlite +*.sqlite3 + +# 证书和密钥 +*.pem +*.key +*.crt +*.p12 +*.jks + +# Docker 相关 +Dockerfile* +docker-compose*.yml +.dockerignore + +# CI/CD 配置 +.github/ +.gitlab-ci.yml +.travis.yml +jenkinsfile + +# 文档 +README*.md +CHANGELOG*.md +LICENSE +docs/ +*.md + +# 备份文件 +*.bak +*.backup +*.old + +# IDE 配置 +.settings/ +.project +.classpath +.factorypath +.springBeans +.sts4-cache + +# Maven wrapper 包装器 jar 文件 (保留脚本) +.mvn/wrapper/maven-wrapper.jar + +# 上传的文件 +uploads/ +files/ + +# 缓存 +.cache/ +*.cache \ No newline at end of file diff --git a/vocata-server/.env.example b/vocata-server/.env.example deleted file mode 100644 index cf78d66..0000000 --- a/vocata-server/.env.example +++ /dev/null @@ -1,40 +0,0 @@ -# VocaTa 后端本地开发环境配置模板 -# 复制此文件为 .env 并根据你的本地环境修改配置 - -# ================================ -# 数据库配置 -# ================================ -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=vocata_local -DB_USERNAME=vocata_local -DB_PASSWORD=vocata_local - -# ================================ -# Redis配置 -# ================================ -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_DATABASE=0 - -# ================================ -# AI服务配置 (待实现) -# ================================ -# AI_API_KEY=your-ai-api-key -# AI_API_URL=https://api.openai.com/v1 -# AI_MODEL=gpt-3.5-turbo - -# ================================ -# 文件存储配置 (待实现) -# ================================ -# FILE_UPLOAD_PATH=./uploads -# FILE_MAX_SIZE=10MB - -# ================================ -# 邮件服务配置 (待实现) -# ================================ -# MAIL_HOST=smtp.gmail.com -# MAIL_PORT=587 -# MAIL_USERNAME=your-email@gmail.com -# MAIL_PASSWORD=your-email-password \ No newline at end of file diff --git a/vocata-server/Dockerfile b/vocata-server/Dockerfile new file mode 100644 index 0000000..62540c5 --- /dev/null +++ b/vocata-server/Dockerfile @@ -0,0 +1,97 @@ +# VocaTa后端服务 - 多阶段构建Dockerfile +# 基于OpenJDK 17的精简镜像 + +# 构建阶段 - 用于Maven构建(可选,CI/CD中已构建) +FROM maven:3.9.4-eclipse-temurin-17-alpine AS build + +WORKDIR /build + +# 复制Maven配置文件和Wrapper +COPY pom.xml . +COPY .mvn .mvn +COPY mvnw . + +# 下载依赖(利用Docker层缓存) +RUN mvn dependency:go-offline -B + +# 复制源代码 +COPY src ./src + +# 构建应用(支持多环境) +ARG SPRING_PROFILES_ACTIVE=prod +RUN mvn clean package -B -DskipTests -Dspring.profiles.active=${SPRING_PROFILES_ACTIVE} + +# 运行阶段 - 精简的生产镜像 +FROM eclipse-temurin:17-jre-alpine + +# 安装必要工具和dumb-init +RUN apk add --no-cache \ + curl \ + tzdata \ + font-noto-cjk \ + dumb-init + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 创建应用用户(安全最佳实践) +RUN addgroup -g 1001 -S vocata && \ + adduser -u 1001 -S vocata -G vocata + +# 设置工作目录 +WORKDIR /app + +# 复制JAR文件 - 优先使用已构建的JAR,回退到构建阶段 +COPY --from=build /build/target/vocata-server-*.jar app.jar +# 如果CI/CD中已经构建了JAR,可以直接复制 +# COPY ./target/vocata-server-*.jar app.jar + +# 创建必要目录并设置权限 +RUN mkdir -p /app/logs /app/uploads /var/log/vocata && \ + chown -R vocata:vocata /app /var/log/vocata && \ + chmod +x /app/app.jar + +# 切换到非root用户 +USER vocata + +# 暴露端口 - 配置端口以匹配不同环境 +EXPOSE 9010 +EXPOSE 9009 + +# JVM优化参数 +ENV JAVA_OPTS="-Xms512m -Xmx2048m \ +-XX:+UseG1GC \ +-XX:G1HeapRegionSize=16m \ +-XX:+DisableExplicitGC \ +-XX:+UseStringDeduplication \ +-XX:+OptimizeStringConcat \ +-Djava.security.egd=file:/dev/./urandom \ +-Dfile.encoding=UTF-8 \ +-Duser.timezone=Asia/Shanghai" + +# 应用配置环境变量 +ENV SPRING_PROFILES_ACTIVE=prod +ENV SERVER_PORT=9010 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:${SERVER_PORT}/api/actuator/health || exit 1 + +# 启动应用 - 使用 dumb-init 处理信号 +ENTRYPOINT ["dumb-init", "--"] +CMD ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# 构建参数和标签信息 +ARG BUILD_DATE +ARG VERSION="1.0.0" +LABEL maintainer="VocaTa Team " \ + version="${VERSION}" \ + build-date="${BUILD_DATE}" \ + description="VocaTa AI角色扮演平台后端服务" \ + org.opencontainers.image.title="vocata-server" \ + org.opencontainers.image.description="VocaTa AI Role Playing Platform Backend Service" \ + org.opencontainers.image.url="https://github.com/leivik/vocata" \ + org.opencontainers.image.vendor="VocaTa Team" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ No newline at end of file diff --git a/vocata-server/Dockerfile.ci b/vocata-server/Dockerfile.ci new file mode 100644 index 0000000..3c04bbb --- /dev/null +++ b/vocata-server/Dockerfile.ci @@ -0,0 +1,75 @@ +# VocaTa后端服务 - CI/CD优化版Dockerfile +# 针对CI/CD环境优化的单阶段构建 + +FROM eclipse-temurin:17-jre-alpine + +# 安装必要工具和dumb-init +RUN apk add --no-cache \ + curl \ + tzdata \ + font-noto-cjk \ + dumb-init + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 创建应用用户(安全最佳实践) +RUN addgroup -g 1001 -S vocata && \ + adduser -u 1001 -S vocata -G vocata + +# 设置工作目录 +WORKDIR /app + +# 复制JAR文件(从CI/CD构建阶段) +COPY vocata-server/target/vocata-server-*.jar app.jar + +# 创建必要目录并设置权限 +RUN mkdir -p /app/logs /app/uploads /var/log/vocata && \ + chown -R vocata:vocata /app /var/log/vocata && \ + chmod +x /app/app.jar + +# 切换到非root用户 +USER vocata + +# 暴露端口 - 支持不同环境 +EXPOSE 9009 +EXPOSE 9010 + +# JVM优化参数 - 针对容器环境优化 +ENV JAVA_OPTS="-Xms256m -Xmx1024m \ +-XX:+UseG1GC \ +-XX:G1HeapRegionSize=16m \ +-XX:+DisableExplicitGC \ +-XX:+UseStringDeduplication \ +-XX:+OptimizeStringConcat \ +-XX:MaxRAMPercentage=75.0 \ +-Djava.security.egd=file:/dev/./urandom \ +-Dfile.encoding=UTF-8 \ +-Duser.timezone=Asia/Shanghai" + +# 应用配置环境变量 +ENV SPRING_PROFILES_ACTIVE=test +ENV SERVER_PORT=9009 + +# 健康检查 - 支持动态端口 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:${SERVER_PORT}/api/actuator/health || exit 1 + +# 启动应用 - 使用 dumb-init 处理信号 +ENTRYPOINT ["dumb-init", "--"] +CMD ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# 构建参数和标签信息 +ARG BUILD_DATE +ARG VERSION="1.0.0" +LABEL maintainer="VocaTa Team " \ + version="${VERSION}" \ + build-date="${BUILD_DATE}" \ + description="VocaTa AI角色扮演平台后端服务 - CI/CD版本" \ + org.opencontainers.image.title="vocata-server" \ + org.opencontainers.image.description="VocaTa AI Role Playing Platform Backend Service" \ + org.opencontainers.image.url="https://github.com/leivik/vocata" \ + org.opencontainers.image.vendor="VocaTa Team" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ No newline at end of file diff --git a/vocata-server/pom.xml b/vocata-server/pom.xml index 50d8c68..8e005c9 100644 --- a/vocata-server/pom.xml +++ b/vocata-server/pom.xml @@ -28,11 +28,36 @@ + org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.springframework + spring-webflux + + + + + io.projectreactor.netty + reactor-netty-http + + + + io.netty + netty-resolver-dns-native-macos + osx-aarch_64 + runtime + + org.springframework.boot spring-boot-starter-validation @@ -94,11 +119,51 @@ 5.8.22 - + + + javax.websocket + javax.websocket-api + 1.1 + + + - me.paulschwarz - spring-dotenv - 4.0.0 + org.glassfish.tyrus + tyrus-client + 1.19 + + + + org.glassfish.tyrus + tyrus-container-grizzly-client + 1.19 + + + + + cn.xfyun + websdk-java-speech + 3.0.6 + + + + + at.favre.lib + bcrypt + 0.10.2 + + + + + com.qiniu + qiniu-java-sdk + 7.13.1 + + + + + org.springframework.boot + spring-boot-starter-mail diff --git a/vocata-server/src/main/java/com/vocata/VocataApplication.java b/vocata-server/src/main/java/com/vocata/VocataApplication.java index 3cdd54c..5316ab3 100644 --- a/vocata-server/src/main/java/com/vocata/VocataApplication.java +++ b/vocata-server/src/main/java/com/vocata/VocataApplication.java @@ -2,11 +2,15 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableAsync +@EnableScheduling public class VocataApplication { public static void main(String[] args) { SpringApplication.run(VocataApplication.class, args); } -} \ No newline at end of file +} diff --git a/vocata-server/src/main/java/com/vocata/admin/controller/AdminAuthController.java b/vocata-server/src/main/java/com/vocata/admin/controller/AdminAuthController.java new file mode 100644 index 0000000..c4b0bea --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/controller/AdminAuthController.java @@ -0,0 +1,59 @@ +package com.vocata.admin.controller; + +import com.vocata.auth.dto.LoginRequest; +import com.vocata.auth.dto.LoginResponse; +import com.vocata.admin.service.AdminAuthService; +import com.vocata.common.result.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 管理员认证控制器 + */ +@RestController +@RequestMapping("/api/admin/auth") +@Validated +public class AdminAuthController { + + @Autowired + private AdminAuthService adminAuthService; + + /** + * 管理员登录 + */ + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = adminAuthService.adminLogin(request); + return ApiResponse.success("管理员登录成功", response); + } + + /** + * 管理员登出 + */ + @PostMapping("/logout") + public ApiResponse logout() { + adminAuthService.adminLogout(); + return ApiResponse.success("管理员登出成功"); + } + + /** + * 刷新Token + */ + @PostMapping("/refresh-token") + public ApiResponse refreshToken(@RequestBody String refreshToken) { + LoginResponse response = adminAuthService.refreshToken(refreshToken); + return ApiResponse.success("Token刷新成功", response); + } + + /** + * 获取当前管理员信息 + */ + @GetMapping("/current") + public ApiResponse getCurrentAdmin() { + LoginResponse response = adminAuthService.getCurrentAdmin(); + return ApiResponse.success("获取管理员信息成功", response); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/controller/AdminTtsVoiceController.java b/vocata-server/src/main/java/com/vocata/admin/controller/AdminTtsVoiceController.java new file mode 100644 index 0000000..a3cf678 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/controller/AdminTtsVoiceController.java @@ -0,0 +1,63 @@ +package com.vocata.admin.controller; + +import com.vocata.common.result.ApiResponse; +import com.vocata.voice.dto.TtsVoiceAddRequest; +import com.vocata.voice.dto.TtsVoiceResponse; +import com.vocata.voice.dto.TtsVoiceUpdateRequest; +import com.vocata.voice.dto.TtsVoiceListResponse; +import com.vocata.voice.service.TtsVoiceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +/** + * 管理后台TTS音色管理控制器 + */ +@RestController +@RequestMapping("/api/admin/tts-voice") +@Validated +public class AdminTtsVoiceController { + + @Autowired + private TtsVoiceService ttsVoiceService; + + /** + * 添加音色 + */ + @PostMapping + public ApiResponse addVoice(@Valid @RequestBody TtsVoiceAddRequest request) { + TtsVoiceResponse response = ttsVoiceService.addVoice(request); + return ApiResponse.success("添加音色成功", response); + } + + /** + * 删除音色 + */ + @DeleteMapping("/{id}") + public ApiResponse deleteVoice(@PathVariable Long id) { + ttsVoiceService.deleteVoice(id); + return ApiResponse.success("删除音色成功", true); + } + + /** + * 更新音色 + */ + @PutMapping("/{id}") + public ApiResponse updateVoice(@PathVariable Long id, + @Valid @RequestBody TtsVoiceUpdateRequest request) { + TtsVoiceResponse response = ttsVoiceService.updateVoice(id, request); + return ApiResponse.success("更新音色成功", response); + } + + /** + * 获取音色完整列表(管理后台使用) + */ + @GetMapping("/list") + public ApiResponse> getFullVoiceList() { + List voices = ttsVoiceService.getFullVoiceList(); + return ApiResponse.success("获取音色列表成功", voices); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/controller/AdminUserController.java b/vocata-server/src/main/java/com/vocata/admin/controller/AdminUserController.java new file mode 100644 index 0000000..07fb3d2 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/controller/AdminUserController.java @@ -0,0 +1,53 @@ +package com.vocata.admin.controller; + +import com.vocata.admin.dto.UserAdminResponse; +import com.vocata.admin.dto.UserQueryRequest; +import com.vocata.admin.dto.UserStatusUpdateRequest; +import com.vocata.admin.service.AdminUserService; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.result.PageResult; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 管理后台用户管理控制器 + */ +@RestController +@RequestMapping("/api/admin/user") +@Validated +public class AdminUserController { + + @Autowired + private AdminUserService adminUserService; + + /** + * 获取用户列表 + */ + @GetMapping("/list") + public ApiResponse> getUserList(UserQueryRequest request) { + PageResult result = adminUserService.getUserList(request); + return ApiResponse.success("获取用户列表成功", result); + } + + /** + * 获取用户详情 + */ + @GetMapping("/{id}") + public ApiResponse getUserDetail(@PathVariable String id) { + UserAdminResponse user = adminUserService.getUserDetail(id); + return ApiResponse.success("获取用户详情成功", user); + } + + /** + * 切换用户状态 + */ + @PutMapping("/{id}/status") + public ApiResponse updateUserStatus(@PathVariable String id, + @Valid @RequestBody UserStatusUpdateRequest request) { + boolean result = adminUserService.updateUserStatus(id, request.getStatus()); + return ApiResponse.success("用户状态更新成功", result); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/dto/UserAdminResponse.java b/vocata-server/src/main/java/com/vocata/admin/dto/UserAdminResponse.java new file mode 100644 index 0000000..6103677 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/dto/UserAdminResponse.java @@ -0,0 +1,153 @@ +package com.vocata.admin.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +/** + * 管理后台用户响应DTO + */ +public class UserAdminResponse { + + private String id; + private String username; + private String email; + private String nickname; + private String avatar; + private Integer gender; + private String phone; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime birthday; + private Integer status; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime lastLoginTime; + private String lastLoginIp; + private Integer loginFailCount; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime lockTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createDate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateDate; + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public LocalDateTime getBirthday() { + return birthday; + } + + public void setBirthday(LocalDateTime birthday) { + this.birthday = birthday; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public LocalDateTime getLastLoginTime() { + return lastLoginTime; + } + + public void setLastLoginTime(LocalDateTime lastLoginTime) { + this.lastLoginTime = lastLoginTime; + } + + public String getLastLoginIp() { + return lastLoginIp; + } + + public void setLastLoginIp(String lastLoginIp) { + this.lastLoginIp = lastLoginIp; + } + + public Integer getLoginFailCount() { + return loginFailCount; + } + + public void setLoginFailCount(Integer loginFailCount) { + this.loginFailCount = loginFailCount; + } + + public LocalDateTime getLockTime() { + return lockTime; + } + + public void setLockTime(LocalDateTime lockTime) { + this.lockTime = lockTime; + } + + public LocalDateTime getCreateDate() { + return createDate; + } + + public void setCreateDate(LocalDateTime createDate) { + this.createDate = createDate; + } + + public LocalDateTime getUpdateDate() { + return updateDate; + } + + public void setUpdateDate(LocalDateTime updateDate) { + this.updateDate = updateDate; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/dto/UserQueryRequest.java b/vocata-server/src/main/java/com/vocata/admin/dto/UserQueryRequest.java new file mode 100644 index 0000000..0fd12a2 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/dto/UserQueryRequest.java @@ -0,0 +1,78 @@ +package com.vocata.admin.dto; + +/** + * 管理后台用户查询请求DTO + * + * @author vocata + * @since 2025-09-24 + */ +public class UserQueryRequest { + + /** + * 页码 + */ + private Integer pageNum = 1; + + /** + * 每页大小 + */ + private Integer pageSize = 10; + + /** + * 用户名模糊查询 + */ + private String username; + + /** + * 邮箱模糊查询 + */ + private String email; + + /** + * 用户状态筛选 + */ + private Integer status; + + public UserQueryRequest() { + } + + public Integer getPageNum() { + return pageNum; + } + + public void setPageNum(Integer pageNum) { + this.pageNum = pageNum; + } + + public Integer getPageSize() { + return pageSize; + } + + public void setPageSize(Integer pageSize) { + this.pageSize = pageSize; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/dto/UserStatusUpdateRequest.java b/vocata-server/src/main/java/com/vocata/admin/dto/UserStatusUpdateRequest.java new file mode 100644 index 0000000..cb1ad40 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/dto/UserStatusUpdateRequest.java @@ -0,0 +1,20 @@ +package com.vocata.admin.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * 用户状态更新请求DTO + */ +public class UserStatusUpdateRequest { + + @NotNull(message = "状态不能为空") + private Integer status; + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/service/AdminAuthService.java b/vocata-server/src/main/java/com/vocata/admin/service/AdminAuthService.java new file mode 100644 index 0000000..14cf20d --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/service/AdminAuthService.java @@ -0,0 +1,30 @@ +package com.vocata.admin.service; + +import com.vocata.auth.dto.LoginRequest; +import com.vocata.auth.dto.LoginResponse; + +/** + * 管理员认证服务接口 + */ +public interface AdminAuthService { + + /** + * 管理员登录 + */ + LoginResponse adminLogin(LoginRequest loginRequest); + + /** + * 管理员登出 + */ + void adminLogout(); + + /** + * 刷新Token + */ + LoginResponse refreshToken(String refreshToken); + + /** + * 获取当前管理员信息 + */ + LoginResponse getCurrentAdmin(); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/service/AdminUserService.java b/vocata-server/src/main/java/com/vocata/admin/service/AdminUserService.java new file mode 100644 index 0000000..c931b5b --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/service/AdminUserService.java @@ -0,0 +1,39 @@ +package com.vocata.admin.service; + +import com.vocata.admin.dto.UserAdminResponse; +import com.vocata.admin.dto.UserQueryRequest; +import com.vocata.common.result.PageResult; + +/** + * 管理后台用户服务接口 + * + * @author vocata + * @since 2025-09-24 + */ +public interface AdminUserService { + + /** + * 获取用户列表(分页查询) + * + * @param queryRequest 查询条件 + * @return 用户列表分页结果 + */ + PageResult getUserList(UserQueryRequest queryRequest); + + /** + * 根据用户ID获取用户详情 + * + * @param userId 用户ID + * @return 用户详情信息 + */ + UserAdminResponse getUserDetail(String userId); + + /** + * 更新用户状态 + * + * @param userId 用户ID + * @param status 用户状态 (1:正常 2:禁用) + * @return 是否更新成功 + */ + boolean updateUserStatus(String userId, Integer status); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/service/impl/AdminAuthServiceImpl.java b/vocata-server/src/main/java/com/vocata/admin/service/impl/AdminAuthServiceImpl.java new file mode 100644 index 0000000..c4760e5 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/service/impl/AdminAuthServiceImpl.java @@ -0,0 +1,284 @@ +package com.vocata.admin.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.vocata.auth.constants.AuthConstants; +import com.vocata.auth.dto.LoginRequest; +import com.vocata.auth.dto.LoginResponse; +import com.vocata.admin.service.AdminAuthService; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.utils.IpUtils; +import com.vocata.common.utils.PasswordEncoder; +import com.vocata.user.dto.UserResponse; +import com.vocata.user.entity.User; +import com.vocata.user.mapper.UserMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.util.regex.Pattern; + +/** + * 管理员认证服务实现 + */ +@Service +public class AdminAuthServiceImpl implements AdminAuthService { + + private static final Logger log = LoggerFactory.getLogger(AdminAuthServiceImpl.class); + + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$" + ); + + @Autowired + private UserMapper userMapper; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private HttpServletRequest request; + + @Override + public LoginResponse adminLogin(LoginRequest loginRequest) { + // 1. 查找用户 + User user = findUserByLoginName(loginRequest.getLoginName()); + if (user == null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户名或密码错误"); + } + + // 2. 检查是否为管理员 + if (!user.getIsAdmin()) { + // 记录非管理员尝试登录管理后台的行为 + log.warn("非管理员用户尝试登录管理后台,用户ID:{},用户名:{},IP:{}", + user.getId(), user.getUsername(), IpUtils.getClientIp(request)); + throw new BizException(ApiCode.FORBIDDEN.getCode(), "权限不足,无法访问管理后台"); + } + + // 3. 验证密码 + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + // 增加登录失败次数 + incrementLoginFailedCount(user.getId()); + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户名或密码错误"); + } + + // 4. 检查管理员账户状态 + checkAdminStatus(user); + + // 5. 重置登录失败次数 + resetLoginFailedCount(user.getId()); + + // 6. 更新最后登录信息 + String clientIp = IpUtils.getClientIp(request); + updateLastLoginInfo(user.getId(), clientIp); + + // 7. 生成Token并标记为管理员会话 + StpUtil.login(user.getId()); + + // 8. 设置管理员Session信息 + setAdminSession(user); + + // 9. 构建响应 + LoginResponse response = new LoginResponse(); + response.setToken(StpUtil.getTokenValue()); + response.setExpiresIn(StpUtil.getTokenTimeout()); + + UserResponse userResponse = new UserResponse(); + BeanUtils.copyProperties(user, userResponse); + userResponse.setId(user.getId().toString()); + response.setUser(userResponse); + + log.info("管理员登录成功,管理员ID:{},用户名:{},IP:{}", user.getId(), user.getUsername(), clientIp); + return response; + } + + @Override + public void adminLogout() { + if (StpUtil.isLogin()) { + Long userId = StpUtil.getLoginIdAsLong(); + StpUtil.logout(); + log.info("管理员登出成功,管理员ID:{}", userId); + } + } + + @Override + public LoginResponse refreshToken(String refreshToken) { + // Sa-Token会自动处理Token刷新 + if (!StpUtil.isLogin()) { + throw new BizException(ApiCode.UNAUTHORIZED.getCode(), "请重新登录"); + } + + Long userId = StpUtil.getLoginIdAsLong(); + User user = getUserById(userId); + if (user == null) { + throw new BizException(ApiCode.UNAUTHORIZED.getCode(), "用户不存在"); + } + + // 检查是否为管理员 + if (!user.getIsAdmin()) { + StpUtil.logout(); + throw new BizException(ApiCode.FORBIDDEN.getCode(), "权限不足"); + } + + // 检查管理员状态 + checkAdminStatus(user); + + // 刷新Token + StpUtil.renewTimeout(StpUtil.getTokenTimeout()); + + LoginResponse response = new LoginResponse(); + response.setToken(StpUtil.getTokenValue()); + response.setExpiresIn(StpUtil.getTokenTimeout()); + + UserResponse userResponse = new UserResponse(); + BeanUtils.copyProperties(user, userResponse); + userResponse.setId(user.getId().toString()); + response.setUser(userResponse); + + return response; + } + + @Override + public LoginResponse getCurrentAdmin() { + if (!StpUtil.isLogin()) { + throw new BizException(ApiCode.UNAUTHORIZED.getCode(), "未登录"); + } + + Long userId = StpUtil.getLoginIdAsLong(); + User user = getUserById(userId); + if (user == null || !user.getIsAdmin()) { + throw new BizException(ApiCode.FORBIDDEN.getCode(), "权限不足"); + } + + LoginResponse response = new LoginResponse(); + response.setToken(StpUtil.getTokenValue()); + response.setExpiresIn(StpUtil.getTokenTimeout()); + + UserResponse userResponse = new UserResponse(); + BeanUtils.copyProperties(user, userResponse); + userResponse.setId(user.getId().toString()); + response.setUser(userResponse); + + return response; + } + + /** + * 根据登录名查找用户 + */ + private User findUserByLoginName(String loginName) { + // 判断是邮箱还是用户名 + if (EMAIL_PATTERN.matcher(loginName).matches()) { + return getUserByEmail(loginName); + } else { + return getUserByUsername(loginName); + } + } + + /** + * 检查管理员状态 + */ + private void checkAdminStatus(User user) { + if (user.getStatus() == AuthConstants.USER_STATUS_DISABLED) { + throw new BizException(ApiCode.FORBIDDEN.getCode(), "管理员账号已被禁用"); + } + + if (user.getStatus() == AuthConstants.USER_STATUS_LOCKED) { + // 检查锁定时间是否已过期 + if (user.getLockTime() != null && + user.getLockTime().plusMinutes(AuthConstants.ACCOUNT_LOCK_MINUTES).isAfter(LocalDateTime.now())) { + throw new BizException(ApiCode.FORBIDDEN.getCode(), "管理员账号已被锁定,请稍后再试"); + } else { + // 锁定时间已过,自动解锁 + unlockUserAccount(user.getId()); + } + } + } + + /** + * 设置管理员Session信息 + */ + private void setAdminSession(User user) { + StpUtil.getSession().set("username", user.getUsername()); + StpUtil.getSession().set("email", user.getEmail()); + StpUtil.getSession().set("isAdmin", true); + StpUtil.getSession().set("loginTime", System.currentTimeMillis()); + StpUtil.getSession().set("loginIp", IpUtils.getClientIp(request)); + StpUtil.getSession().set("loginType", "admin"); // 标记为管理员登录 + } + + // ============ 用户数据访问辅助方法 ============ + + /** + * 根据邮箱查找用户 + */ + private User getUserByEmail(String email) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getEmail, email); + return userMapper.selectOne(wrapper); + } + + /** + * 根据用户名查找用户 + */ + private User getUserByUsername(String username) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getUsername, username); + return userMapper.selectOne(wrapper); + } + + /** + * 根据ID查找用户 + */ + private User getUserById(Long userId) { + return userMapper.selectById(userId); + } + + /** + * 更新最后登录信息 + */ + private void updateLastLoginInfo(Long userId, String clientIp) { + User user = new User(); + user.setId(userId); + user.setLastLoginTime(LocalDateTime.now()); + user.setLastLoginIp(clientIp); + userMapper.updateById(user); + } + + /** + * 增加登录失败次数 + */ + private void incrementLoginFailedCount(Long userId) { + User user = getUserById(userId); + if (user != null) { + user.setLoginFailCount(user.getLoginFailCount() + 1); + userMapper.updateById(user); + } + } + + /** + * 重置登录失败次数 + */ + private void resetLoginFailedCount(Long userId) { + User user = new User(); + user.setId(userId); + user.setLoginFailCount(0); + userMapper.updateById(user); + } + + /** + * 解锁用户账户 + */ + private void unlockUserAccount(Long userId) { + User user = new User(); + user.setId(userId); + user.setStatus(AuthConstants.USER_STATUS_NORMAL); + user.setLoginFailCount(0); + user.setLockTime(null); + userMapper.updateById(user); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/admin/service/impl/AdminUserServiceImpl.java b/vocata-server/src/main/java/com/vocata/admin/service/impl/AdminUserServiceImpl.java new file mode 100644 index 0000000..41232d6 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/admin/service/impl/AdminUserServiceImpl.java @@ -0,0 +1,114 @@ +package com.vocata.admin.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.admin.dto.UserAdminResponse; +import com.vocata.admin.dto.UserQueryRequest; +import com.vocata.admin.service.AdminUserService; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.result.PageResult; +import com.vocata.user.entity.User; +import com.vocata.user.mapper.UserMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * 管理后台用户服务实现 + */ +@Service +public class AdminUserServiceImpl implements AdminUserService { + + private static final Logger log = LoggerFactory.getLogger(AdminUserServiceImpl.class); + + @Autowired + private UserMapper userMapper; + + @Override + public PageResult getUserList(UserQueryRequest request) { + Page page = new Page<>(request.getPageNum(), request.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + // 用户名模糊查询 + if (StringUtils.hasText(request.getUsername())) { + wrapper.like(User::getUsername, request.getUsername()); + } + + // 邮箱模糊查询 + if (StringUtils.hasText(request.getEmail())) { + wrapper.like(User::getEmail, request.getEmail()); + } + + // 状态筛选 + if (request.getStatus() != null) { + wrapper.eq(User::getStatus, request.getStatus()); + } + + // 按创建时间倒序 + wrapper.orderByDesc(User::getCreateDate); + + IPage userPage = userMapper.selectPage(page, wrapper); + + return PageResult.of( + request.getPageNum(), + request.getPageSize(), + userPage.getTotal(), + userPage.getRecords().stream() + .map(this::convertToAdminResponse) + .toList() + ); + } + + @Override + public UserAdminResponse getUserDetail(String userId) { + User user = userMapper.selectById(Long.parseLong(userId)); + if (user == null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户不存在"); + } + + return convertToAdminResponse(user); + } + + @Override + @Transactional + public boolean updateUserStatus(String userId, Integer status) { + // 验证状态值 + if (status != 1 && status != 2) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "无效的状态值"); + } + + User user = userMapper.selectById(Long.parseLong(userId)); + if (user == null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户不存在"); + } + + User updateUser = new User(); + updateUser.setId(Long.parseLong(userId)); + updateUser.setStatus(status); + + int result = userMapper.updateById(updateUser); + + if (result > 0) { + log.info("管理员更新用户状态成功,目标用户ID:{},新状态:{}", userId, status); + } + + return result > 0; + } + + /** + * 转换为管理后台响应DTO + */ + private UserAdminResponse convertToAdminResponse(User user) { + UserAdminResponse response = new UserAdminResponse(); + BeanUtils.copyProperties(user, response); + response.setId(user.getId().toString()); + return response; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/config/AiServiceConfig.java b/vocata-server/src/main/java/com/vocata/ai/config/AiServiceConfig.java new file mode 100644 index 0000000..e5cbaf8 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/config/AiServiceConfig.java @@ -0,0 +1,220 @@ +package com.vocata.ai.config; + +import com.vocata.ai.llm.LlmProvider; +import com.vocata.ai.stt.SttClient; +import com.vocata.ai.tts.TtsClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import java.util.List; + +/** + * AI服务配置 + * 根据配置选择合适的LLM、STT和TTS提供者 + * 修复版本:使用直接参数注入避免循环依赖 + */ +@Configuration +public class AiServiceConfig { + + private static final Logger logger = LoggerFactory.getLogger(AiServiceConfig.class); + + @Value("${ai.llm.provider:qiniu}") + private String preferredProvider; + + @Value("${ai.stt.provider:qiniu}") + private String preferredSttProvider; + + @Value("${ai.tts.provider:volcan}") + private String preferredTtsProvider; + + /** + * 选择并配置主要的LLM提供者 + * 使用直接参数注入避免循环依赖 + */ + @Bean + @Primary + public LlmProvider primaryLlmProvider( + @Qualifier("qiniuLlmProvider") LlmProvider qiniuLlmProvider, + @Qualifier("geminiLlmProvider") LlmProvider geminiLlmProvider, + @Qualifier("openAiLlmProvider") LlmProvider openAiLlmProvider, + @Qualifier("siliconFlowLlmProvider") LlmProvider siliconFlowLlmProvider) { + + logger.info("开始选择LLM提供者,首选: {}", preferredProvider); + + // 构建提供者列表 + List providers = List.of(qiniuLlmProvider, geminiLlmProvider, openAiLlmProvider, siliconFlowLlmProvider); + + // 记录检测到的提供者 + providers.forEach(provider -> { + logger.info("检测到LLM提供者: {} - 可用状态: {}", + provider.getProviderName(), provider.isAvailable()); + }); + + // 首先尝试使用配置的首选提供者 + LlmProvider preferredLlmProvider = findProviderByName(providers, preferredProvider); + if (preferredLlmProvider != null && preferredLlmProvider.isAvailable()) { + logger.info("使用首选LLM提供者: {}", preferredLlmProvider.getProviderName()); + return preferredLlmProvider; + } + + // 如果首选提供者不可用,按优先级选择可用的提供者 + String[] providerPriority = {"qiniu", "gemini", "openai", "siliconflow"}; + + for (String providerName : providerPriority) { + LlmProvider provider = findProviderByName(providers, providerName); + if (provider != null && provider.isAvailable()) { + logger.info("使用备用LLM提供者: {}", provider.getProviderName()); + return provider; + } + } + + // 如果没有找到可用的提供者,返回第一个提供者作为默认值 + logger.warn("未找到可用的LLM提供者,使用默认提供者: {}", qiniuLlmProvider.getProviderName()); + return qiniuLlmProvider; + } + + /** + * 选择并配置主要的STT提供者 + */ + @Bean + @Primary + public SttClient primarySttClient(List sttClients) { + logger.info("开始选择STT提供者,首选: {}", preferredSttProvider); + + // 记录检测到的提供者 + sttClients.forEach(client -> { + logger.info("检测到STT提供者: {} - 可用状态: {}", + client.getProviderName(), client.isAvailable()); + }); + + // 首先尝试使用配置的首选提供者 + SttClient preferredSttClient = findSttClientByName(sttClients, preferredSttProvider); + if (preferredSttClient != null && preferredSttClient.isAvailable()) { + logger.info("使用首选STT提供者: {}", preferredSttClient.getProviderName()); + return preferredSttClient; + } + + // 如果首选提供者不可用,按优先级选择可用的提供者 + String[] sttProviderPriority = {"qiniu", "xunfei", "mock"}; + + for (String providerName : sttProviderPriority) { + SttClient client = findSttClientByName(sttClients, providerName); + if (client != null && client.isAvailable()) { + logger.info("使用备用STT提供者: {}", client.getProviderName()); + return client; + } + } + + // 如果没有找到可用的提供者,返回第一个提供者作为默认值 + if (!sttClients.isEmpty()) { + logger.warn("未找到可用的STT提供者,使用默认提供者: {}", sttClients.get(0).getProviderName()); + return sttClients.get(0); + } + + throw new RuntimeException("未找到任何STT提供者。请检查配置。"); + } + + /** + * 选择并配置主要的TTS提供者 + */ + @Bean + @Primary + public TtsClient primaryTtsClient(List ttsClients) { + logger.info("开始选择TTS提供者,首选: {}", preferredTtsProvider); + + // 记录检测到的提供者 + ttsClients.forEach(client -> { + logger.info("检测到TTS提供者: {} - 可用状态: {}", + client.getProviderName(), client.isAvailable()); + }); + + // 首先尝试使用配置的首选提供者 + TtsClient preferredTtsClient = findTtsClientByName(ttsClients, preferredTtsProvider); + if (preferredTtsClient != null && preferredTtsClient.isAvailable()) { + logger.info("使用首选TTS提供者: {}", preferredTtsClient.getProviderName()); + return preferredTtsClient; + } + + // 如果首选提供者不可用,按优先级选择可用的提供者 + String[] ttsProviderPriority = {"xunfei", "volcan", "mock"}; + + for (String providerName : ttsProviderPriority) { + TtsClient client = findTtsClientByName(ttsClients, providerName); + if (client != null && client.isAvailable()) { + logger.info("使用备用TTS提供者: {}", client.getProviderName()); + return client; + } + } + + // 如果没有找到可用的提供者,返回第一个提供者作为默认值 + if (!ttsClients.isEmpty()) { + logger.warn("未找到可用的TTS提供者,使用默认提供者: {}", ttsClients.get(0).getProviderName()); + return ttsClients.get(0); + } + + throw new RuntimeException("未找到任何TTS提供者。请检查配置。"); + } + + private LlmProvider findProviderByName(List providers, String name) { + // 尝试通过provider名称查找 + for (LlmProvider p : providers) { + if (p.getProviderName().toLowerCase().contains(name.toLowerCase())) { + return p; + } + } + return null; + } + + private SttClient findSttClientByName(List clients, String name) { + // 尝试通过provider名称查找 + for (SttClient c : clients) { + String providerName = c.getProviderName().toLowerCase(); + String searchName = name.toLowerCase(); + + // 直接包含匹配 + if (providerName.contains(searchName)) { + return c; + } + + // 特殊匹配规则 - 科大讯飞 + if ("xunfei".equals(searchName) && (providerName.contains("科大讯飞") || providerName.contains("xunfei"))) { + return c; + } + + // 特殊匹配规则 - 七牛云 + if ("qiniu".equals(searchName) && (providerName.contains("七牛") || providerName.contains("qiniu"))) { + return c; + } + } + return null; + } + + private TtsClient findTtsClientByName(List clients, String name) { + // 尝试通过provider名称查找 + for (TtsClient c : clients) { + String providerName = c.getProviderName().toLowerCase(); + String searchName = name.toLowerCase(); + + // 直接包含匹配 + if (providerName.contains(searchName)) { + return c; + } + + // 特殊匹配规则 - 科大讯飞 + if ("xunfei".equals(searchName) && (providerName.contains("科大讯飞") || providerName.contains("xunfei"))) { + return c; + } + + // 特殊匹配规则 - 火山引擎 + if ("volcan".equals(searchName) && (providerName.contains("火山") || providerName.contains("volcan"))) { + return c; + } + } + return null; + } +} diff --git a/vocata-server/src/main/java/com/vocata/ai/controller/AiModelController.java b/vocata-server/src/main/java/com/vocata/ai/controller/AiModelController.java new file mode 100644 index 0000000..31bfbce --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/controller/AiModelController.java @@ -0,0 +1,266 @@ +package com.vocata.ai.controller; + +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import java.util.*; + +/** + * AI模型调用控制器 + * 支持手动指定模型提供商和模型进行AI对话 + */ +@RestController +@RequestMapping("/api/client/ai") +public class AiModelController { + + private static final Logger logger = LoggerFactory.getLogger(AiModelController.class); + + @Autowired + private ApplicationContext applicationContext; + + /** + * 获取所有可用的AI模型列表 + */ + @GetMapping("/models") + public ApiResponse> getAvailableModels() { + Map providers = applicationContext.getBeansOfType(LlmProvider.class); + + List> providerList = new ArrayList<>(); + + for (Map.Entry entry : providers.entrySet()) { + LlmProvider provider = entry.getValue(); + + Map providerInfo = new HashMap<>(); + providerInfo.put("providerName", provider.getProviderName()); + providerInfo.put("beanName", entry.getKey()); + providerInfo.put("isAvailable", provider.isAvailable()); + providerInfo.put("maxContextLength", provider.getMaxContextLength()); + providerInfo.put("supportedModels", Arrays.asList(provider.getSupportedModels())); + + providerList.add(providerInfo); + } + + Map result = new HashMap<>(); + result.put("providers", providerList); + result.put("totalProviders", providerList.size()); + result.put("availableProviders", providerList.stream() + .mapToInt(p -> (Boolean) p.get("isAvailable") ? 1 : 0) + .sum()); + + return ApiResponse.success(result); + } + + /** + * 使用指定模型进行AI对话 + */ + @PostMapping("/chat") + public ApiResponse chatWithModel(@RequestBody ChatRequest request) { + logger.info("收到AI聊天请求,提供商: {}, 模型: {}", request.getProviderName(), request.getModelName()); + + // 查找指定的LLM提供商 + LlmProvider provider = findProviderByName(request.getProviderName()); + if (provider == null) { + throw new BizException(ApiCode.PARAM_ERROR, "未找到指定的AI提供商: " + request.getProviderName()); + } + + if (!provider.isAvailable()) { + throw new BizException(ApiCode.AI_SERVICE_ERROR, "AI提供商不可用: " + request.getProviderName()); + } + + // 构建AI请求 + UnifiedAiRequest aiRequest = buildAiRequest(request); + + // 验证模型配置 + if (!provider.validateModelConfig(aiRequest.getModelConfig())) { + throw new BizException(ApiCode.PARAM_ERROR, "模型配置无效,请检查模型名称和参数"); + } + + try { + // 调用AI并收集完整响应 + String response = provider.streamChat(aiRequest) + .map(UnifiedAiStreamChunk::getContent) + .filter(Objects::nonNull) + .reduce("", (accumulated, chunk) -> accumulated + chunk) + .block(); + + logger.info("AI响应生成完成,长度: {} 字符", response != null ? response.length() : 0); + + return ApiResponse.success(response); + + } catch (Exception e) { + logger.error("AI调用失败", e); + throw new BizException(ApiCode.AI_SERVICE_ERROR, "AI调用失败: " + e.getMessage()); + } + } + + /** + * 使用指定模型进行流式AI对话 + */ + @PostMapping("/stream-chat") + public Flux streamChatWithModel(@RequestBody ChatRequest request) { + logger.info("收到AI流式聊天请求,提供商: {}, 模型: {}", request.getProviderName(), request.getModelName()); + + // 查找指定的LLM提供商 + LlmProvider provider = findProviderByName(request.getProviderName()); + if (provider == null) { + return Flux.error(new RuntimeException("未找到指定的AI提供商: " + request.getProviderName())); + } + + if (!provider.isAvailable()) { + return Flux.error(new RuntimeException("AI提供商不可用: " + request.getProviderName())); + } + + // 构建AI请求 + UnifiedAiRequest aiRequest = buildAiRequest(request); + + // 验证模型配置 + if (!provider.validateModelConfig(aiRequest.getModelConfig())) { + return Flux.error(new RuntimeException("模型配置无效,请检查模型名称和参数")); + } + + try { + return provider.streamChat(aiRequest) + .map(UnifiedAiStreamChunk::getContent) + .filter(Objects::nonNull) + .doOnNext(chunk -> logger.debug("流式响应块: {}", chunk)) + .doOnComplete(() -> logger.info("流式AI响应完成")) + .doOnError(error -> logger.error("流式AI调用失败", error)); + + } catch (Exception e) { + logger.error("流式AI调用初始化失败", e); + return Flux.error(new RuntimeException("AI调用失败: " + e.getMessage())); + } + } + + /** + * 根据提供商名称查找LLM提供商 + */ + private LlmProvider findProviderByName(String providerName) { + Map providers = applicationContext.getBeansOfType(LlmProvider.class); + + // 优先精确匹配bean名称 + if (providers.containsKey(providerName)) { + return providers.get(providerName); + } + + // 然后匹配提供商显示名称 + for (LlmProvider provider : providers.values()) { + if (provider.getProviderName().toLowerCase().contains(providerName.toLowerCase())) { + return provider; + } + } + + // 最后尝试部分匹配 + for (Map.Entry entry : providers.entrySet()) { + String beanName = entry.getKey().toLowerCase(); + String searchName = providerName.toLowerCase(); + + if (beanName.contains(searchName) || searchName.contains(beanName)) { + return entry.getValue(); + } + } + + return null; + } + + /** + * 构建AI请求对象 + */ + private UnifiedAiRequest buildAiRequest(ChatRequest request) { + UnifiedAiRequest aiRequest = new UnifiedAiRequest(); + + // 设置系统提示词和用户消息 + aiRequest.setSystemPrompt(request.getSystemPrompt()); + aiRequest.setUserMessage(request.getUserMessage()); + + // 设置历史对话(如果有) + if (request.getMessages() != null && !request.getMessages().isEmpty()) { + List contextMessages = new ArrayList<>(); + for (ChatMessage msg : request.getMessages()) { + contextMessages.add(new UnifiedAiRequest.ChatMessage(msg.getRole(), msg.getContent())); + } + aiRequest.setContextMessages(contextMessages); + } + + // 设置模型配置 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName(request.getModelName()); + modelConfig.setTemperature(request.getTemperature()); + modelConfig.setMaxTokens(request.getMaxTokens()); + modelConfig.setTopP(request.getTopP()); + + aiRequest.setModelConfig(modelConfig); + + return aiRequest; + } + + /** + * 聊天请求DTO + */ + public static class ChatRequest { + private String providerName; // 提供商名称(如 "siliconFlowLlmProvider" 或 "SiliconFlow AI") + private String modelName; // 模型名称(如 "anthropic/claude-3-5-sonnet-20241022") + private String systemPrompt; // 系统提示词 + private String userMessage; // 用户消息 + private List messages; // 历史对话消息 + private Double temperature; // 温度参数 0.0-2.0 + private Integer maxTokens; // 最大token数 + private Double topP; // top_p参数 0.0-1.0 + + // Getters and Setters + public String getProviderName() { return providerName; } + public void setProviderName(String providerName) { this.providerName = providerName; } + + public String getModelName() { return modelName; } + public void setModelName(String modelName) { this.modelName = modelName; } + + public String getSystemPrompt() { return systemPrompt; } + public void setSystemPrompt(String systemPrompt) { this.systemPrompt = systemPrompt; } + + public String getUserMessage() { return userMessage; } + public void setUserMessage(String userMessage) { this.userMessage = userMessage; } + + public List getMessages() { return messages; } + public void setMessages(List messages) { this.messages = messages; } + + public Double getTemperature() { return temperature; } + public void setTemperature(Double temperature) { this.temperature = temperature; } + + public Integer getMaxTokens() { return maxTokens; } + public void setMaxTokens(Integer maxTokens) { this.maxTokens = maxTokens; } + + public Double getTopP() { return topP; } + public void setTopP(Double topP) { this.topP = topP; } + } + + /** + * 聊天消息DTO + */ + public static class ChatMessage { + private String role; // "user", "assistant", "system" + private String content; // 消息内容 + + public ChatMessage() {} + + public ChatMessage(String role, String content) { + this.role = role; + this.content = content; + } + + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/dto/UnifiedAiRequest.java b/vocata-server/src/main/java/com/vocata/ai/dto/UnifiedAiRequest.java new file mode 100644 index 0000000..1ad665c --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/dto/UnifiedAiRequest.java @@ -0,0 +1,166 @@ +package com.vocata.ai.dto; + +import java.util.List; +import java.util.Map; + +/** + * 统一的AI服务请求格式 + */ +public class UnifiedAiRequest { + + /** + * 系统提示词(角色人设) + */ + private String systemPrompt; + + /** + * 用户输入的消息 + */ + private String userMessage; + + /** + * 对话历史上下文 + */ + private List contextMessages; + + /** + * 模型配置参数 + */ + private ModelConfig modelConfig; + + /** + * 额外的元数据 + */ + private Map metadata; + + // Getters and Setters + + public String getSystemPrompt() { + return systemPrompt; + } + + public void setSystemPrompt(String systemPrompt) { + this.systemPrompt = systemPrompt; + } + + public String getUserMessage() { + return userMessage; + } + + public void setUserMessage(String userMessage) { + this.userMessage = userMessage; + } + + public List getContextMessages() { + return contextMessages; + } + + public void setContextMessages(List contextMessages) { + this.contextMessages = contextMessages; + } + + public ModelConfig getModelConfig() { + return modelConfig; + } + + public void setModelConfig(ModelConfig modelConfig) { + this.modelConfig = modelConfig; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + /** + * 对话消息 + */ + public static class ChatMessage { + private String role; // "user", "assistant", "system" + private String content; + + public ChatMessage() {} + + public ChatMessage(String role, String content) { + this.role = role; + this.content = content; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + } + + /** + * 模型配置 + */ + public static class ModelConfig { + private String modelName; + private Double temperature; + private Integer maxTokens; + private Double topP; + private Integer contextWindow; + + public ModelConfig() {} + + public ModelConfig(String modelName, Double temperature) { + this.modelName = modelName; + this.temperature = temperature; + } + + public String getModelName() { + return modelName; + } + + public void setModelName(String modelName) { + this.modelName = modelName; + } + + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + public Integer getMaxTokens() { + return maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + public Double getTopP() { + return topP; + } + + public void setTopP(Double topP) { + this.topP = topP; + } + + public Integer getContextWindow() { + return contextWindow; + } + + public void setContextWindow(Integer contextWindow) { + this.contextWindow = contextWindow; + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/dto/UnifiedAiStreamChunk.java b/vocata-server/src/main/java/com/vocata/ai/dto/UnifiedAiStreamChunk.java new file mode 100644 index 0000000..ba616cf --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/dto/UnifiedAiStreamChunk.java @@ -0,0 +1,265 @@ +package com.vocata.ai.dto; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 统一的AI服务流式响应块 + */ +public class UnifiedAiStreamChunk { + + /** + * 块的类型 + */ + private ChunkType type; + + /** + * 块的索引(在整个流中的序号) + */ + private Integer chunkIndex; + + /** + * 本块的文本内容 + */ + private String content; + + /** + * 累积的文本内容 + */ + private String accumulatedContent; + + /** + * 完成原因(仅在最后一块中有值) + */ + private String finishReason; + + /** + * 是否为最终块 + */ + private Boolean isFinal; + + /** + * Token使用统计 + */ + private TokenUsage tokenUsage; + + /** + * 性能指标 + */ + private PerformanceMetrics performance; + + /** + * 额外的元数据 + */ + private Map metadata; + + /** + * 时间戳 + */ + private LocalDateTime timestamp; + + public UnifiedAiStreamChunk() { + this.timestamp = LocalDateTime.now(); + } + + // Getters and Setters + + public ChunkType getType() { + return type; + } + + public void setType(ChunkType type) { + this.type = type; + } + + public Integer getChunkIndex() { + return chunkIndex; + } + + public void setChunkIndex(Integer chunkIndex) { + this.chunkIndex = chunkIndex; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getAccumulatedContent() { + return accumulatedContent; + } + + public void setAccumulatedContent(String accumulatedContent) { + this.accumulatedContent = accumulatedContent; + } + + public String getFinishReason() { + return finishReason; + } + + public void setFinishReason(String finishReason) { + this.finishReason = finishReason; + } + + public Boolean getIsFinal() { + return isFinal; + } + + public void setIsFinal(Boolean isFinal) { + this.isFinal = isFinal; + } + + public TokenUsage getTokenUsage() { + return tokenUsage; + } + + public void setTokenUsage(TokenUsage tokenUsage) { + this.tokenUsage = tokenUsage; + } + + public PerformanceMetrics getPerformance() { + return performance; + } + + public void setPerformance(PerformanceMetrics performance) { + this.performance = performance; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + /** + * 块类型枚举 + */ + public enum ChunkType { + /** + * 文本内容块 + */ + CONTENT, + + /** + * 系统状态块 + */ + STATUS, + + /** + * 错误块 + */ + ERROR, + + /** + * 完成块 + */ + DONE + } + + /** + * Token使用统计 + */ + public static class TokenUsage { + private Integer inputTokens; + private Integer outputTokens; + private Integer totalTokens; + + public TokenUsage() {} + + public TokenUsage(Integer inputTokens, Integer outputTokens) { + this.inputTokens = inputTokens; + this.outputTokens = outputTokens; + this.totalTokens = (inputTokens != null && outputTokens != null) + ? inputTokens + outputTokens : null; + } + + public Integer getInputTokens() { + return inputTokens; + } + + public void setInputTokens(Integer inputTokens) { + this.inputTokens = inputTokens; + updateTotalTokens(); + } + + public Integer getOutputTokens() { + return outputTokens; + } + + public void setOutputTokens(Integer outputTokens) { + this.outputTokens = outputTokens; + updateTotalTokens(); + } + + public Integer getTotalTokens() { + return totalTokens; + } + + public void setTotalTokens(Integer totalTokens) { + this.totalTokens = totalTokens; + } + + private void updateTotalTokens() { + if (inputTokens != null && outputTokens != null) { + this.totalTokens = inputTokens + outputTokens; + } + } + } + + /** + * 性能指标 + */ + public static class PerformanceMetrics { + private Long latencyMs; // 延迟(毫秒) + private Double tokensPerSecond; // 生成速度(tokens/秒) + private Long firstTokenLatencyMs; // 首个token延迟 + private Double qualityScore; // 质量评分 + + public PerformanceMetrics() {} + + public Long getLatencyMs() { + return latencyMs; + } + + public void setLatencyMs(Long latencyMs) { + this.latencyMs = latencyMs; + } + + public Double getTokensPerSecond() { + return tokensPerSecond; + } + + public void setTokensPerSecond(Double tokensPerSecond) { + this.tokensPerSecond = tokensPerSecond; + } + + public Long getFirstTokenLatencyMs() { + return firstTokenLatencyMs; + } + + public void setFirstTokenLatencyMs(Long firstTokenLatencyMs) { + this.firstTokenLatencyMs = firstTokenLatencyMs; + } + + public Double getQualityScore() { + return qualityScore; + } + + public void setQualityScore(Double qualityScore) { + this.qualityScore = qualityScore; + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/llm/LlmProvider.java b/vocata-server/src/main/java/com/vocata/ai/llm/LlmProvider.java new file mode 100644 index 0000000..5946922 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/llm/LlmProvider.java @@ -0,0 +1,97 @@ +package com.vocata.ai.llm; + +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import reactor.core.publisher.Flux; + +/** + * LLM Provider接口 + * 定义统一的AI模型调用标准,使用最合适的设计模式:策略模式 + * + * 支持多种LLM提供商:OpenAI、Anthropic、Local Models等 + */ +public interface LlmProvider { + + /** + * 获取提供商名称 + */ + String getProviderName(); + + /** + * 检查提供商是否可用 + */ + boolean isAvailable(); + + /** + * 流式聊天接口 + * + * @param request 统一的AI请求格式 + * @return 响应式流,包含逐个返回的文本块 + */ + Flux streamChat(UnifiedAiRequest request); + + /** + * 同步聊天接口(基于流式接口实现) + * + * @param request 统一的AI请求格式 + * @return 完整的响应结果 + */ + default UnifiedAiStreamChunk chat(UnifiedAiRequest request) { + return streamChat(request) + .collectList() + .map(chunks -> { + if (chunks.isEmpty()) { + UnifiedAiStreamChunk result = new UnifiedAiStreamChunk(); + result.setContent(""); + result.setAccumulatedContent(""); + result.setIsFinal(true); + return result; + } + + StringBuilder accumulatedContent = new StringBuilder(); + UnifiedAiStreamChunk finalChunk = null; + + for (UnifiedAiStreamChunk chunk : chunks) { + if (chunk.getContent() != null) { + accumulatedContent.append(chunk.getContent()); + } + if (chunk.getIsFinal() != null && chunk.getIsFinal()) { + finalChunk = chunk; + } + } + + if (finalChunk != null) { + finalChunk.setAccumulatedContent(accumulatedContent.toString()); + return finalChunk; + } + + // 如果没有最终块,创建一个新的 + UnifiedAiStreamChunk result = new UnifiedAiStreamChunk(); + result.setContent(accumulatedContent.toString()); + result.setAccumulatedContent(accumulatedContent.toString()); + result.setIsFinal(true); + return result; + }) + .block(); + } + + /** + * 获取模型支持的最大上下文长度 + */ + int getMaxContextLength(); + + /** + * 获取支持的模型列表 + */ + String[] getSupportedModels(); + + /** + * 估算Token数量 + */ + int estimateTokens(String text); + + /** + * 验证模型配置是否有效 + */ + boolean validateModelConfig(UnifiedAiRequest.ModelConfig config); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/llm/impl/GeminiLlmProvider.java b/vocata-server/src/main/java/com/vocata/ai/llm/impl/GeminiLlmProvider.java new file mode 100644 index 0000000..abfd779 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/llm/impl/GeminiLlmProvider.java @@ -0,0 +1,403 @@ +package com.vocata.ai.llm.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Google Gemini LLM 提供者实现 + */ +@Component("geminiLlmProvider") +public class GeminiLlmProvider implements LlmProvider, InitializingBean { + + private static final Logger logger = LoggerFactory.getLogger(GeminiLlmProvider.class); + + @Autowired + private WebClient.Builder webClientBuilder; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.api.base-url:https://generativelanguage.googleapis.com}") + private String baseUrl; + + @Value("${gemini.api.default-model:gemini-2.5-flash-lite}") + private String defaultModel; + + @Value("${gemini.api.timeout:60}") + private int timeoutSeconds; + + private WebClient webClient; + + @Override + public int getMaxContextLength() { + // Gemini 1.5 Flash支持最大2M token上下文 + return 2000000; + } + + @Override + public String[] getSupportedModels() { + return new String[]{ + "gemini-2.5-flash-lite", + "gemini-2.5-flash", + "gemini-1.5-flash", + "gemini-1.5-pro", + "gemini-1.0-pro" + }; + } + + @Override + public int estimateTokens(String text) { + // 简单估算:1个token大约4个字符 + return text == null ? 0 : (text.length() / 4); + } + + @Override + public boolean validateModelConfig(UnifiedAiRequest.ModelConfig config) { + if (config == null) return true; + + // 验证模型名称 + if (config.getModelName() != null) { + boolean isValidModel = Arrays.asList(getSupportedModels()).contains(config.getModelName()); + if (!isValidModel) return false; + } + + // 验证温度参数 + if (config.getTemperature() != null) { + double temp = config.getTemperature(); + if (temp < 0.0 || temp > 2.0) return false; + } + + // 验证最大token数 + if (config.getMaxTokens() != null) { + int maxTokens = config.getMaxTokens(); + if (maxTokens < 1 || maxTokens > 8192) return false; + } + + return true; + } + + @Override + public void afterPropertiesSet() { + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) + .build(); + + logger.info("Gemini LLM Provider initialized with model: {}", defaultModel); + } + + @Override + public Flux streamChat(UnifiedAiRequest request) { + return Flux.defer(() -> { + try { + Map requestBody = buildGeminiRequest(request); + String model = request.getModelConfig() != null && request.getModelConfig().getModelName() != null + ? request.getModelConfig().getModelName() + : defaultModel; + + logger.debug("发送Gemini请求,模型: {}", model); + + return webClient + .post() + .uri("/v1beta/models/{model}:streamGenerateContent?key={apiKey}", model, apiKey) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToFlux(String.class) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .reduce("", (accumulated, chunk) -> accumulated + chunk) // 累积所有数据块 + .flatMapMany(this::parseGeminiStreamResponse) + .doOnError(error -> logger.error("Gemini API调用失败: {}", error.getMessage())) + .onErrorResume(error -> { + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setAccumulatedContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + }); + + } catch (Exception e) { + logger.error("构建Gemini请求失败", e); + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("请求构建失败"); + errorChunk.setAccumulatedContent("请求构建失败"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + } + }); + } + + private Map buildGeminiRequest(UnifiedAiRequest request) { + Map requestBody = new HashMap<>(); + + // 构建消息列表 + List> contents = new ArrayList<>(); + + // 添加系统消息(如果有) + if (request.getSystemPrompt() != null && !request.getSystemPrompt().trim().isEmpty()) { + Map systemContent = new HashMap<>(); + systemContent.put("role", "user"); + + List> parts = new ArrayList<>(); + Map part = new HashMap<>(); + part.put("text", "System: " + request.getSystemPrompt()); + parts.add(part); + + systemContent.put("parts", parts); + contents.add(systemContent); + } + + // 添加历史对话 + if (request.getContextMessages() != null) { + for (UnifiedAiRequest.ChatMessage contextMsg : request.getContextMessages()) { + Map content = new HashMap<>(); + + // Gemini角色映射 + String role = "user".equals(contextMsg.getRole()) ? "user" : "model"; + content.put("role", role); + + List> parts = new ArrayList<>(); + Map part = new HashMap<>(); + part.put("text", contextMsg.getContent()); + parts.add(part); + + content.put("parts", parts); + contents.add(content); + } + } + + // 添加当前用户消息 + if (request.getUserMessage() != null && !request.getUserMessage().trim().isEmpty()) { + Map userContent = new HashMap<>(); + userContent.put("role", "user"); + + List> parts = new ArrayList<>(); + Map part = new HashMap<>(); + part.put("text", request.getUserMessage()); + parts.add(part); + + userContent.put("parts", parts); + contents.add(userContent); + } + + requestBody.put("contents", contents); + + // 配置生成参数 + Map generationConfig = new HashMap<>(); + if (request.getModelConfig() != null) { + if (request.getModelConfig().getTemperature() != null) { + generationConfig.put("temperature", request.getModelConfig().getTemperature()); + } + if (request.getModelConfig().getMaxTokens() != null) { + generationConfig.put("maxOutputTokens", request.getModelConfig().getMaxTokens()); + } + } + + // 默认参数 + generationConfig.putIfAbsent("temperature", 0.7); + generationConfig.putIfAbsent("maxOutputTokens", 2048); + + requestBody.put("generationConfig", generationConfig); + + logger.debug("构建的Gemini请求: {}", requestBody); + return requestBody; + } + + private Flux parseGeminiStreamResponse(String completeJson) { + try { + logger.debug("开始解析Gemini流式响应,数据长度: {}", completeJson.length()); + + List chunks = new ArrayList<>(); + String accumulatedContent = ""; + + // Gemini API 返回数组格式的多个响应块 + if (completeJson.startsWith("[")) { + @SuppressWarnings("unchecked") + List> responseArray = objectMapper.readValue(completeJson, List.class); + + for (Map responseObj : responseArray) { + String textContent = extractTextFromResponse(responseObj); + if (textContent != null && !textContent.trim().isEmpty()) { + accumulatedContent += textContent; + + UnifiedAiStreamChunk chunk = new UnifiedAiStreamChunk(); + chunk.setContent(textContent); + chunk.setAccumulatedContent(accumulatedContent); + + // 检查是否为最后一个响应 + boolean isLast = isLastResponse(responseObj) || + (responseArray.indexOf(responseObj) == responseArray.size() - 1); + chunk.setIsFinal(isLast); + + chunks.add(chunk); + logger.debug("解析到文本内容: {} (累积长度: {})", textContent, accumulatedContent.length()); + } + } + } else { + // 单个对象格式 + @SuppressWarnings("unchecked") + Map responseObj = objectMapper.readValue(completeJson, Map.class); + String textContent = extractTextFromResponse(responseObj); + if (textContent != null && !textContent.trim().isEmpty()) { + UnifiedAiStreamChunk chunk = new UnifiedAiStreamChunk(); + chunk.setContent(textContent); + chunk.setAccumulatedContent(textContent); + chunk.setIsFinal(isLastResponse(responseObj)); + chunks.add(chunk); + } + } + + logger.info("Gemini流式响应解析完成,生成{}个chunk,总内容长度: {}", chunks.size(), accumulatedContent.length()); + return Flux.fromIterable(chunks); + + } catch (Exception e) { + logger.error("解析Gemini流式响应失败: {}", e.getMessage()); + logger.debug("失败的响应内容: {}", completeJson); + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setAccumulatedContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + } + } + + /** + * 从响应对象中提取文本内容 + */ + private String extractTextFromResponse(Map response) { + try { + @SuppressWarnings("unchecked") + List> candidates = (List>) response.get("candidates"); + + if (candidates == null || candidates.isEmpty()) { + return null; + } + + @SuppressWarnings("unchecked") + Map candidate = candidates.get(0); + + @SuppressWarnings("unchecked") + Map content = (Map) candidate.get("content"); + + if (content == null) { + return null; + } + + @SuppressWarnings("unchecked") + List> parts = (List>) content.get("parts"); + + if (parts == null || parts.isEmpty()) { + return null; + } + + return (String) parts.get(0).get("text"); + + } catch (Exception e) { + logger.debug("提取文本内容失败: {}", e.getMessage()); + return null; + } + } + + /** + * 检查是否为最后一个响应 + */ + private boolean isLastResponse(Map response) { + try { + @SuppressWarnings("unchecked") + List> candidates = (List>) response.get("candidates"); + + if (candidates == null || candidates.isEmpty()) { + return false; + } + + @SuppressWarnings("unchecked") + Map candidate = candidates.get(0); + + String finishReason = (String) candidate.get("finishReason"); + return "STOP".equals(finishReason) || "MAX_TOKENS".equals(finishReason); + + } catch (Exception e) { + return false; + } + } + + private UnifiedAiStreamChunk parseGeminiSingleResponse(Map response) { + try { + @SuppressWarnings("unchecked") + List> candidates = (List>) response.get("candidates"); + + if (candidates == null || candidates.isEmpty()) { + return null; + } + + @SuppressWarnings("unchecked") + Map candidate = candidates.get(0); + + @SuppressWarnings("unchecked") + Map content = (Map) candidate.get("content"); + + if (content == null) { + return null; + } + + @SuppressWarnings("unchecked") + List> parts = (List>) content.get("parts"); + + if (parts == null || parts.isEmpty()) { + return null; + } + + String text = (String) parts.get(0).get("text"); + if (text == null) { + return null; + } + + UnifiedAiStreamChunk chunk = new UnifiedAiStreamChunk(); + chunk.setContent(text); + + // 检查是否完成 + String finishReason = (String) candidate.get("finishReason"); + boolean isFinished = "STOP".equals(finishReason) || "MAX_TOKENS".equals(finishReason); + chunk.setIsFinal(isFinished); + + // 注意:Gemini不提供accumulated内容,我们需要在上层处理 + // 暂时设置为当前内容 + chunk.setAccumulatedContent(text); + + logger.debug("解析Gemini响应成功: content={}, isFinished={}", text, isFinished); + return chunk; + + } catch (Exception e) { + logger.error("解析单个Gemini响应失败: {}", e.getMessage()); + return null; + } + } + + @Override + public String getProviderName() { + return "Gemini"; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.trim().isEmpty() && !apiKey.equals("your-gemini-api-key"); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/llm/impl/OpenAiLlmProvider.java b/vocata-server/src/main/java/com/vocata/ai/llm/impl/OpenAiLlmProvider.java new file mode 100644 index 0000000..8f74b1b --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/llm/impl/OpenAiLlmProvider.java @@ -0,0 +1,347 @@ +package com.vocata.ai.llm.impl; + +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * OpenAI LLM提供商实现类 + * 使用WebClient实现异步、非阻塞的API调用 + */ +@Service +public class OpenAiLlmProvider implements LlmProvider { + + private static final Logger logger = LoggerFactory.getLogger(OpenAiLlmProvider.class); + + private final WebClient webClient; + + @Value("${openai.api.key:#{null}}") + private String apiKey; + + @Value("${openai.api.base-url:https://api.openai.com}") + private String baseUrl; + + @Value("${openai.api.timeout:60}") + private int timeoutSeconds; + + @Value("${openai.api.default-model:gpt-3.5-turbo}") + private String defaultModel; + + // 支持的模型列表 + private static final String[] SUPPORTED_MODELS = { + "gpt-3.5-turbo", "gpt-3.5-turbo-16k", + "gpt-4", "gpt-4-turbo", "gpt-4o", + "gpt-4-32k" + }; + + // 模型上下文长度映射 + private static final Map MODEL_CONTEXT_LENGTHS = Map.of( + "gpt-3.5-turbo", 4096, + "gpt-3.5-turbo-16k", 16384, + "gpt-4", 8192, + "gpt-4-turbo", 128000, + "gpt-4o", 128000, + "gpt-4-32k", 32768 + ); + + public OpenAiLlmProvider() { + this.webClient = WebClient.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB + .build(); + } + + @Override + public String getProviderName() { + return "OpenAI"; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.trim().isEmpty(); + } + + @Override + public Flux streamChat(UnifiedAiRequest request) { + if (!isAvailable()) { + return Flux.error(new BizException(ApiCode.AI_SERVICE_UNAVAILABLE, "OpenAI API Key未配置")); + } + + logger.info("开始OpenAI流式聊天,模型: {}", request.getModelConfig().getModelName()); + + // 构建OpenAI API请求 + Map openAiRequest = buildOpenAiRequest(request); + + // 性能监控 + AtomicLong startTime = new AtomicLong(System.currentTimeMillis()); + AtomicInteger chunkIndex = new AtomicInteger(0); + AtomicReference accumulatedContent = new AtomicReference<>(""); + AtomicLong firstTokenTime = new AtomicLong(0); + + return webClient.post() + .uri(baseUrl + "/v1/chat/completions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .bodyValue(openAiRequest) + .retrieve() + .bodyToFlux(String.class) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .onErrorMap(WebClientResponseException.class, ex -> { + logger.error("OpenAI API调用失败: {}", ex.getResponseBodyAsString()); + return new BizException(ApiCode.AI_SERVICE_ERROR, + "AI服务调用失败: " + ex.getMessage()); + }) + .filter(line -> line.startsWith("data: ")) + .map(line -> line.substring(6)) // 移除 "data: " 前缀 + .filter(data -> !data.trim().isEmpty() && !"[DONE]".equals(data.trim())) + .flatMap(this::parseOpenAiResponse) + .map(chunk -> { + // 更新累积内容 + if (chunk.getContent() != null) { + String newAccumulated = accumulatedContent.get() + chunk.getContent(); + accumulatedContent.set(newAccumulated); + chunk.setAccumulatedContent(newAccumulated); + } + + // 记录首个token时间 + if (firstTokenTime.get() == 0 && chunk.getContent() != null) { + firstTokenTime.set(System.currentTimeMillis()); + } + + // 设置性能指标 + chunk.setChunkIndex(chunkIndex.getAndIncrement()); + setPerformanceMetrics(chunk, startTime.get(), firstTokenTime.get()); + + return chunk; + }) + .concatWith(Mono.fromCallable(() -> { + // 创建最终完成块 + UnifiedAiStreamChunk finalChunk = new UnifiedAiStreamChunk(); + finalChunk.setType(UnifiedAiStreamChunk.ChunkType.DONE); + finalChunk.setIsFinal(true); + finalChunk.setAccumulatedContent(accumulatedContent.get()); + finalChunk.setChunkIndex(chunkIndex.get()); + finalChunk.setFinishReason("stop"); + + // 设置最终的性能指标和Token统计 + setFinalMetrics(finalChunk, startTime.get(), firstTokenTime.get(), + accumulatedContent.get()); + + return finalChunk; + })); + } + + @Override + public int getMaxContextLength() { + return MODEL_CONTEXT_LENGTHS.getOrDefault(defaultModel, 4096); + } + + @Override + public String[] getSupportedModels() { + return SUPPORTED_MODELS.clone(); + } + + @Override + public int estimateTokens(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + // 简单估算:约4字符=1token(对于英文) + return (int) Math.ceil(text.length() / 4.0); + } + + @Override + public boolean validateModelConfig(UnifiedAiRequest.ModelConfig config) { + if (config == null) { + return false; + } + + String modelName = config.getModelName(); + if (modelName == null || !Arrays.asList(SUPPORTED_MODELS).contains(modelName)) { + return false; + } + + Double temperature = config.getTemperature(); + if (temperature != null && (temperature < 0 || temperature > 2)) { + return false; + } + + Integer maxTokens = config.getMaxTokens(); + if (maxTokens != null && maxTokens <= 0) { + return false; + } + + return true; + } + + /** + * 构建OpenAI API请求 + */ + private Map buildOpenAiRequest(UnifiedAiRequest request) { + Map openAiRequest = new HashMap<>(); + + // 模型配置 + UnifiedAiRequest.ModelConfig config = request.getModelConfig(); + openAiRequest.put("model", config.getModelName() != null ? + config.getModelName() : defaultModel); + + if (config.getTemperature() != null) { + openAiRequest.put("temperature", config.getTemperature()); + } + if (config.getMaxTokens() != null) { + openAiRequest.put("max_tokens", config.getMaxTokens()); + } + if (config.getTopP() != null) { + openAiRequest.put("top_p", config.getTopP()); + } + + // 消息构建 + List> messages = new ArrayList<>(); + + // 系统提示词 + if (request.getSystemPrompt() != null && !request.getSystemPrompt().isEmpty()) { + messages.add(Map.of("role", "system", "content", request.getSystemPrompt())); + } + + // 上下文消息 + if (request.getContextMessages() != null) { + for (UnifiedAiRequest.ChatMessage msg : request.getContextMessages()) { + messages.add(Map.of("role", msg.getRole(), "content", msg.getContent())); + } + } + + // 用户消息 + if (request.getUserMessage() != null && !request.getUserMessage().isEmpty()) { + messages.add(Map.of("role", "user", "content", request.getUserMessage())); + } + + openAiRequest.put("messages", messages); + openAiRequest.put("stream", true); // 启用流式响应 + + return openAiRequest; + } + + /** + * 解析OpenAI响应 + */ + private Mono parseOpenAiResponse(String jsonData) { + try { + // 这里应该使用JSON解析库,简化处理 + // 在实际项目中应该使用Jackson或Gson + UnifiedAiStreamChunk chunk = new UnifiedAiStreamChunk(); + chunk.setType(UnifiedAiStreamChunk.ChunkType.CONTENT); + + // 简化的JSON解析(实际应该使用Jackson) + if (jsonData.contains("\"content\"")) { + // 提取content字段的值 + String content = extractJsonField(jsonData, "content"); + chunk.setContent(content); + } + + if (jsonData.contains("\"finish_reason\"")) { + String finishReason = extractJsonField(jsonData, "finish_reason"); + chunk.setFinishReason(finishReason); + chunk.setIsFinal(!"null".equals(finishReason) && finishReason != null); + } + + return Mono.just(chunk); + } catch (Exception e) { + logger.error("解析OpenAI响应失败: {}", e.getMessage()); + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setType(UnifiedAiStreamChunk.ChunkType.ERROR); + errorChunk.setContent("响应解析错误: " + e.getMessage()); + return Mono.just(errorChunk); + } + } + + /** + * 简化的JSON字段提取(应该使用Jackson替代) + */ + private String extractJsonField(String json, String fieldName) { + try { + String pattern = "\"" + fieldName + "\":\""; + int startIndex = json.indexOf(pattern); + if (startIndex == -1) { + return null; + } + startIndex += pattern.length(); + int endIndex = json.indexOf("\"", startIndex); + if (endIndex == -1) { + return null; + } + return json.substring(startIndex, endIndex); + } catch (Exception e) { + return null; + } + } + + /** + * 设置性能指标 + */ + private void setPerformanceMetrics(UnifiedAiStreamChunk chunk, long startTime, long firstTokenTime) { + long currentTime = System.currentTimeMillis(); + + UnifiedAiStreamChunk.PerformanceMetrics metrics = + new UnifiedAiStreamChunk.PerformanceMetrics(); + + metrics.setLatencyMs(currentTime - startTime); + + if (firstTokenTime > 0) { + metrics.setFirstTokenLatencyMs(firstTokenTime - startTime); + } + + chunk.setPerformance(metrics); + } + + /** + * 设置最终指标 + */ + private void setFinalMetrics(UnifiedAiStreamChunk chunk, long startTime, long firstTokenTime, + String fullContent) { + long endTime = System.currentTimeMillis(); + long totalTime = endTime - startTime; + + UnifiedAiStreamChunk.PerformanceMetrics metrics = + new UnifiedAiStreamChunk.PerformanceMetrics(); + + metrics.setLatencyMs(totalTime); + + if (firstTokenTime > 0) { + metrics.setFirstTokenLatencyMs(firstTokenTime - startTime); + } + + // 计算生成速度 + int outputTokens = estimateTokens(fullContent); + if (outputTokens > 0 && totalTime > 0) { + metrics.setTokensPerSecond((double) outputTokens / (totalTime / 1000.0)); + } + + chunk.setPerformance(metrics); + + // 设置Token统计 + UnifiedAiStreamChunk.TokenUsage tokenUsage = + new UnifiedAiStreamChunk.TokenUsage(); + tokenUsage.setOutputTokens(outputTokens); + // 输入tokens需要根据请求计算,这里暂时省略 + + chunk.setTokenUsage(tokenUsage); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/llm/impl/QiniuLlmProvider.java b/vocata-server/src/main/java/com/vocata/ai/llm/impl/QiniuLlmProvider.java new file mode 100644 index 0000000..9078a65 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/llm/impl/QiniuLlmProvider.java @@ -0,0 +1,302 @@ +package com.vocata.ai.llm.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +/** + * 七牛云 AI LLM 提供者实现 + * 支持 x-ai/grok-4-fast 模型 + */ +@Component("qiniuLlmProvider") +public class QiniuLlmProvider implements LlmProvider, InitializingBean { + + private static final Logger logger = LoggerFactory.getLogger(QiniuLlmProvider.class); + + @Autowired + private WebClient.Builder webClientBuilder; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${qiniu.ai.api-key}") + private String apiKey; + + @Value("${qiniu.ai.base-url:https://openai.qiniu.com/v1}") + private String baseUrl; + + @Value("${qiniu.ai.default-model:x-ai/grok-4-fast}") + private String defaultModel; + + @Value("${qiniu.ai.timeout:60}") + private int timeoutSeconds; + + private WebClient webClient; + + @Override + public int getMaxContextLength() { + // grok-4-fast 支持 128K tokens 上下文 + return 128000; + } + + @Override + public String[] getSupportedModels() { + return new String[]{ + "x-ai/grok-4-fast", + "x-ai/grok-4" + }; + } + + @Override + public int estimateTokens(String text) { + // 简单估算:1个token大约3个字符(中英文混合) + return text == null ? 0 : (text.length() / 3); + } + + @Override + public boolean validateModelConfig(UnifiedAiRequest.ModelConfig config) { + if (config == null) return true; + + // 验证模型名称 + if (config.getModelName() != null) { + boolean isValidModel = Arrays.asList(getSupportedModels()).contains(config.getModelName()); + if (!isValidModel) return false; + } + + // 验证温度参数 (0.0 - 2.0) + if (config.getTemperature() != null) { + double temp = config.getTemperature(); + if (temp < 0.0 || temp > 2.0) return false; + } + + // 验证最大token数 + if (config.getMaxTokens() != null) { + int maxTokens = config.getMaxTokens(); + if (maxTokens < 1 || maxTokens > 4096) return false; + } + + return true; + } + + @Override + public void afterPropertiesSet() { + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) + .build(); + + logger.info("七牛云 AI LLM Provider initialized with model: {}", defaultModel); + } + + @Override + public Flux streamChat(UnifiedAiRequest request) { + return Flux.defer(() -> { + try { + Map requestBody = buildQiniuRequest(request); + String model = request.getModelConfig() != null && request.getModelConfig().getModelName() != null + ? request.getModelConfig().getModelName() + : defaultModel; + + logger.debug("发送七牛云AI请求,模型: {}", model); + + return webClient + .post() + .uri("/chat/completions") + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .bodyValue(requestBody) + .retrieve() + .bodyToFlux(DataBuffer.class) + .map(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + return new String(bytes, StandardCharsets.UTF_8); + }) + .buffer() // 缓冲数据以处理不完整的SSE消息 + .flatMap(lines -> { + String combined = String.join("", lines); + return Flux.fromArray(combined.split("\n")) + .filter(line -> line.startsWith("data: ") && !line.equals("data: [DONE]")) + .map(line -> line.substring(6).trim()) + .filter(data -> !data.isEmpty()); + }) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .flatMap(this::parseQiniuStreamChunk) + .collectList() // 收集所有chunk + .flatMapMany(chunks -> { + // 手动处理累积内容 + StringBuilder accumulated = new StringBuilder(); + List updatedChunks = new ArrayList<>(); + + for (UnifiedAiStreamChunk chunk : chunks) { + if (chunk.getContent() != null && !chunk.getContent().isEmpty()) { + accumulated.append(chunk.getContent()); + } + chunk.setAccumulatedContent(accumulated.toString()); + updatedChunks.add(chunk); + } + + logger.info("七牛云AI响应解析完成,生成{}个chunk,总内容长度: {}", + updatedChunks.size(), accumulated.length()); + + return Flux.fromIterable(updatedChunks); + }) + .doOnError(error -> logger.error("七牛云AI API调用失败: {}", error.getMessage())) + .onErrorResume(error -> { + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setAccumulatedContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + }); + + } catch (Exception e) { + logger.error("构建七牛云AI请求失败", e); + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("请求构建失败"); + errorChunk.setAccumulatedContent("请求构建失败"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + } + }); + } + + private Map buildQiniuRequest(UnifiedAiRequest request) { + Map requestBody = new HashMap<>(); + + // 设置模型 - 使用OpenAI兼容格式 + String model = request.getModelConfig() != null && request.getModelConfig().getModelName() != null + ? request.getModelConfig().getModelName() + : defaultModel; + requestBody.put("model", model); + + // 构建消息列表 - OpenAI格式 + List> messages = new ArrayList<>(); + + // 添加系统消息(如果有) + if (request.getSystemPrompt() != null && !request.getSystemPrompt().trim().isEmpty()) { + Map systemMessage = new HashMap<>(); + systemMessage.put("role", "system"); + systemMessage.put("content", request.getSystemPrompt()); + messages.add(systemMessage); + } + + // 添加历史对话 + if (request.getContextMessages() != null) { + for (UnifiedAiRequest.ChatMessage contextMsg : request.getContextMessages()) { + Map message = new HashMap<>(); + message.put("role", contextMsg.getRole()); + message.put("content", contextMsg.getContent()); + messages.add(message); + } + } + + // 添加当前用户消息 + if (request.getUserMessage() != null && !request.getUserMessage().trim().isEmpty()) { + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + userMessage.put("content", request.getUserMessage()); + messages.add(userMessage); + } + + requestBody.put("messages", messages); + + // 配置生成参数 - OpenAI兼容格式 + requestBody.put("stream", true); // 启用流式响应 + + if (request.getModelConfig() != null) { + if (request.getModelConfig().getTemperature() != null) { + requestBody.put("temperature", request.getModelConfig().getTemperature()); + } + if (request.getModelConfig().getMaxTokens() != null) { + requestBody.put("max_tokens", request.getModelConfig().getMaxTokens()); + } + } + + // 默认参数 + requestBody.putIfAbsent("temperature", 0.7); + requestBody.putIfAbsent("max_tokens", 2048); + + logger.debug("构建的七牛云AI请求: {}", requestBody); + return requestBody; + } + + private Flux parseQiniuStreamChunk(String jsonData) { + try { + JsonNode jsonNode = objectMapper.readTree(jsonData); + + // 检查是否有错误 + if (jsonNode.has("error")) { + JsonNode errorNode = jsonNode.get("error"); + String errorMessage = errorNode.has("message") ? errorNode.get("message").asText() : "Unknown error"; + logger.error("七牛云AI API返回错误: {}", errorMessage); + + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("服务暂时不可用: " + errorMessage); + errorChunk.setAccumulatedContent("服务暂时不可用: " + errorMessage); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + } + + // 解析OpenAI兼容格式响应 + JsonNode choices = jsonNode.get("choices"); + if (choices != null && choices.isArray() && choices.size() > 0) { + JsonNode choice = choices.get(0); + JsonNode delta = choice.get("delta"); + + String content = delta != null && delta.has("content") + ? delta.get("content").asText() + : null; + + String finishReason = choice.has("finish_reason") && !choice.get("finish_reason").isNull() + ? choice.get("finish_reason").asText() + : null; + boolean isFinished = "stop".equals(finishReason) || "length".equals(finishReason); + + if (content != null || isFinished) { + UnifiedAiStreamChunk chunk = new UnifiedAiStreamChunk(); + chunk.setContent(content); + chunk.setIsFinal(isFinished); + + logger.debug("解析七牛云AI响应: content={}, isFinished={}", content, isFinished); + return Flux.just(chunk); + } + } + + // 空响应或无有效内容 + return Flux.empty(); + + } catch (Exception e) { + logger.error("解析七牛云AI流式响应失败: {}", e.getMessage()); + logger.debug("失败的响应内容: {}", jsonData); + return Flux.empty(); + } + } + + @Override + public String getProviderName() { + return "Qiniu AI"; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.trim().isEmpty() && !apiKey.equals("your-qiniu-ai-api-key"); + } +} diff --git a/vocata-server/src/main/java/com/vocata/ai/llm/impl/SiliconFlowLlmProvider.java b/vocata-server/src/main/java/com/vocata/ai/llm/impl/SiliconFlowLlmProvider.java new file mode 100644 index 0000000..4b43715 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/llm/impl/SiliconFlowLlmProvider.java @@ -0,0 +1,373 @@ +package com.vocata.ai.llm.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +/** + * 硅基流动 AI LLM 提供者实现 + * 支持多种主流AI模型,包括 Claude、GPT、Llama 等 + */ +@Component("siliconFlowLlmProvider") +public class SiliconFlowLlmProvider implements LlmProvider, InitializingBean { + + private static final Logger logger = LoggerFactory.getLogger(SiliconFlowLlmProvider.class); + + @Autowired + private WebClient.Builder webClientBuilder; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${siliconflow.ai.api-key}") + private String apiKey; + + @Value("${siliconflow.ai.base-url:https://api.siliconflow.cn/v1}") + private String baseUrl; + + @Value("${siliconflow.ai.default-model:Qwen/Qwen3-8B}") + private String defaultModel; + + @Value("${siliconflow.ai.timeout:120}") + private int timeoutSeconds; + + private WebClient webClient; + + @Override + public String getProviderName() { + return "SiliconFlow AI"; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.trim().isEmpty() && !apiKey.equals("your-siliconflow-api-key"); + } + + @Override + public int getMaxContextLength() { + // 根据不同模型返回不同的上下文长度 + // 这里返回一个通用的较大值,具体模型可以在后续优化 + return 128000; + } + + @Override + public String[] getSupportedModels() { + return new String[]{ + // Claude 系列 + "anthropic/claude-3-5-sonnet-20241022", + "anthropic/claude-3-5-haiku-20241022", + "anthropic/claude-3-haiku-20240307", + + // GPT 系列 + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/gpt-4-turbo", + "openai/gpt-3.5-turbo", + + // DeepSeek 系列 + "deepseek-ai/DeepSeek-V2.5", + "deepseek-ai/deepseek-llm-67b-chat", + "deepseek-ai/deepseek-coder-33b-instruct", + "deepseek-ai/DeepSeek-V3", + "deepseek-ai/DeepSeek-R1", + + // Qwen 系列 (免费) + "Qwen/Qwen3-8B", // 免费模型 - 最新 + "Qwen/Qwen2.5-7B-Instruct", // 免费模型 + "Qwen/Qwen2.5-14B-Instruct", // 免费模型 + "Qwen/Qwen2.5-32B-Instruct", + "Qwen/Qwen2.5-72B-Instruct", + "Qwen/Qwen2-VL-72B-Instruct", + + // Llama 系列 + "meta-llama/Meta-Llama-3.1-405B-Instruct", + "meta-llama/Meta-Llama-3.1-70B-Instruct", + "meta-llama/Meta-Llama-3.1-8B-Instruct", + "meta-llama/Llama-3.2-90B-Vision-Instruct", + "meta-llama/Llama-3.2-11B-Vision-Instruct", + + // Yi 系列 + "01-ai/Yi-1.5-34B-Chat-16K", + "01-ai/Yi-1.5-9B-Chat-16K", + + // 其他热门模型 + "google/gemma-2-27b-it", + "google/gemma-2-9b-it", + "mistralai/Mistral-7B-Instruct-v0.3", + "microsoft/Phi-3.5-mini-instruct" + }; + } + + @Override + public int estimateTokens(String text) { + // 中英文混合文本的token估算 + return text == null ? 0 : (text.length() / 3); + } + + @Override + public boolean validateModelConfig(UnifiedAiRequest.ModelConfig config) { + if (config == null) return true; + + // 验证模型名称 + if (config.getModelName() != null) { + boolean isValidModel = Arrays.asList(getSupportedModels()).contains(config.getModelName()); + if (!isValidModel) { + logger.warn("不支持的模型: {}", config.getModelName()); + return false; + } + } + + // 验证温度参数 (0.0 - 2.0) + if (config.getTemperature() != null) { + double temp = config.getTemperature(); + if (temp < 0.0 || temp > 2.0) { + logger.warn("温度参数超出范围: {}", temp); + return false; + } + } + + // 验证最大token数 + if (config.getMaxTokens() != null) { + int maxTokens = config.getMaxTokens(); + if (maxTokens < 1 || maxTokens > 32768) { + logger.warn("最大token数超出范围: {}", maxTokens); + return false; + } + } + + // 验证top_p参数 + if (config.getTopP() != null) { + double topP = config.getTopP(); + if (topP < 0.0 || topP > 1.0) { + logger.warn("top_p参数超出范围: {}", topP); + return false; + } + } + + return true; + } + + @Override + public void afterPropertiesSet() { + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024)) + .build(); + + logger.info("硅基流动 AI LLM Provider 初始化完成,默认模型: {}", defaultModel); + } + + @Override + public Flux streamChat(UnifiedAiRequest request) { + return Flux.defer(() -> { + try { + Map requestBody = buildSiliconFlowRequest(request); + String model = request.getModelConfig() != null && request.getModelConfig().getModelName() != null + ? request.getModelConfig().getModelName() + : defaultModel; + + logger.debug("发送硅基流动AI请求,模型: {}", model); + + return webClient + .post() + .uri("/chat/completions") + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .bodyValue(requestBody) + .retrieve() + .bodyToFlux(DataBuffer.class) + .map(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + return new String(bytes, StandardCharsets.UTF_8); + }) + .buffer(Duration.ofMillis(50)) // 减少缓冲时间,加快响应 + .flatMap(lines -> { + String combined = String.join("", lines); + return Flux.fromArray(combined.split("\n")) + .filter(line -> line.startsWith("data: ") && !line.equals("data: [DONE]")) + .map(line -> line.substring(6).trim()) + .filter(data -> !data.isEmpty()); + }) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .flatMap(this::parseSiliconFlowStreamChunk) + .filter(chunk -> chunk.getContent() != null) // 过滤null内容 + .filter(chunk -> !chunk.getContent().trim().isEmpty()) // 过滤空内容 + .filter(chunk -> !"null".equals(chunk.getContent())) // 过滤字符串"null" + .collectList() // 收集所有chunk + .flatMapMany(chunks -> { + // 手动处理累积内容 + StringBuilder accumulated = new StringBuilder(); + List updatedChunks = new ArrayList<>(); + + for (UnifiedAiStreamChunk chunk : chunks) { + if (chunk.getContent() != null && !chunk.getContent().isEmpty()) { + accumulated.append(chunk.getContent()); + } + chunk.setAccumulatedContent(accumulated.toString()); + updatedChunks.add(chunk); + } + + logger.info("硅基流动AI响应解析完成,生成{}个chunk,总内容长度: {}", + updatedChunks.size(), accumulated.length()); + + return Flux.fromIterable(updatedChunks); + }) + .doOnError(error -> logger.error("硅基流动AI API调用失败: {}", error.getMessage())) + .onErrorResume(error -> { + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setAccumulatedContent("抱歉,AI服务暂时不可用,请稍后再试。"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + }); + + } catch (Exception e) { + logger.error("构建硅基流动AI请求失败", e); + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("请求构建失败"); + errorChunk.setAccumulatedContent("请求构建失败"); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + } + }); + } + + private Map buildSiliconFlowRequest(UnifiedAiRequest request) { + Map requestBody = new HashMap<>(); + + // 设置模型 + String model = request.getModelConfig() != null && request.getModelConfig().getModelName() != null + ? request.getModelConfig().getModelName() + : defaultModel; + requestBody.put("model", model); + + // 构建消息列表 - OpenAI兼容格式 + List> messages = new ArrayList<>(); + + // 添加系统消息(如果有) + if (request.getSystemPrompt() != null && !request.getSystemPrompt().trim().isEmpty()) { + Map systemMessage = new HashMap<>(); + systemMessage.put("role", "system"); + systemMessage.put("content", request.getSystemPrompt()); + messages.add(systemMessage); + } + + // 添加历史对话 + if (request.getContextMessages() != null) { + for (UnifiedAiRequest.ChatMessage contextMsg : request.getContextMessages()) { + Map message = new HashMap<>(); + message.put("role", contextMsg.getRole()); + message.put("content", contextMsg.getContent()); + messages.add(message); + } + } + + // 添加当前用户消息 + if (request.getUserMessage() != null && !request.getUserMessage().trim().isEmpty()) { + Map userMessage = new HashMap<>(); + userMessage.put("role", "user"); + userMessage.put("content", request.getUserMessage()); + messages.add(userMessage); + } + + requestBody.put("messages", messages); + + // 配置生成参数 + requestBody.put("stream", true); // 启用流式响应 + + if (request.getModelConfig() != null) { + if (request.getModelConfig().getTemperature() != null) { + requestBody.put("temperature", request.getModelConfig().getTemperature()); + } + if (request.getModelConfig().getMaxTokens() != null) { + requestBody.put("max_tokens", request.getModelConfig().getMaxTokens()); + } + if (request.getModelConfig().getTopP() != null) { + requestBody.put("top_p", request.getModelConfig().getTopP()); + } + } + + // 默认参数 + requestBody.putIfAbsent("temperature", 0.7); + requestBody.putIfAbsent("max_tokens", 4096); + requestBody.putIfAbsent("top_p", 0.9); + + logger.debug("构建的硅基流动AI请求: model={}, messages_count={}", model, messages.size()); + return requestBody; + } + + private Flux parseSiliconFlowStreamChunk(String jsonData) { + try { + JsonNode jsonNode = objectMapper.readTree(jsonData); + + // 检查是否有错误 + if (jsonNode.has("error")) { + JsonNode errorNode = jsonNode.get("error"); + String errorMessage = errorNode.has("message") ? errorNode.get("message").asText() : "Unknown error"; + String errorCode = errorNode.has("code") ? errorNode.get("code").asText() : ""; + logger.error("硅基流动AI API返回错误: {} (code: {})", errorMessage, errorCode); + + UnifiedAiStreamChunk errorChunk = new UnifiedAiStreamChunk(); + errorChunk.setContent("服务暂时不可用: " + errorMessage); + errorChunk.setAccumulatedContent("服务暂时不可用: " + errorMessage); + errorChunk.setIsFinal(true); + return Flux.just(errorChunk); + } + + // 解析OpenAI兼容格式响应 + JsonNode choices = jsonNode.get("choices"); + if (choices != null && choices.isArray() && choices.size() > 0) { + JsonNode choice = choices.get(0); + JsonNode delta = choice.get("delta"); + + if (delta != null && delta.has("content")) { + String content = delta.get("content").asText(); + + // 过滤无效内容 + if (content == null || content.trim().isEmpty() || "null".equals(content)) { + return Flux.empty(); + } + + UnifiedAiStreamChunk chunk = new UnifiedAiStreamChunk(); + chunk.setContent(content); + + // 检查是否完成 + String finishReason = choice.has("finish_reason") && !choice.get("finish_reason").isNull() + ? choice.get("finish_reason").asText() : null; + boolean isFinished = "stop".equals(finishReason) || "length".equals(finishReason) || "content_filter".equals(finishReason); + chunk.setIsFinal(isFinished); + + logger.debug("解析硅基流动AI响应: content={}, isFinished={}", content, isFinished); + return Flux.just(chunk); + } + } + + // 空响应或无有效内容 + return Flux.empty(); + + } catch (Exception e) { + logger.error("解析硅基流动AI流式响应失败: {}", e.getMessage()); + logger.debug("失败的响应内容: {}", jsonData); + return Flux.empty(); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/response/AiStreamingResponse.java b/vocata-server/src/main/java/com/vocata/ai/response/AiStreamingResponse.java new file mode 100644 index 0000000..a957cb0 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/response/AiStreamingResponse.java @@ -0,0 +1,86 @@ +package com.vocata.ai.response; + +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.stt.SttClient; +import com.vocata.ai.tts.TtsClient; + +import java.util.Map; + +/** + * AI流式响应封装类 + */ +public class AiStreamingResponse { + private ResponseType type; + private SttClient.SttResult sttResult; + private UnifiedAiStreamChunk llmChunk; + private byte[] audioData; + private TtsClient.TtsResult ttsResult; + private String error; + private Map metadata; + + public enum ResponseType { + STT_RESULT, // STT识别结果 + LLM_CHUNK, // LLM文本流块 + AUDIO_CHUNK, // TTS音频流块 + TTS_RESULT, // TTS结果(同时包含音频和文字) + ERROR, // 错误信息 + COMPLETE // 完成信号 + } + + // Getters and Setters + public ResponseType getType() { + return type; + } + + public void setType(ResponseType type) { + this.type = type; + } + + public SttClient.SttResult getSttResult() { + return sttResult; + } + + public void setSttResult(SttClient.SttResult sttResult) { + this.sttResult = sttResult; + } + + public UnifiedAiStreamChunk getLlmChunk() { + return llmChunk; + } + + public void setLlmChunk(UnifiedAiStreamChunk llmChunk) { + this.llmChunk = llmChunk; + } + + public byte[] getAudioData() { + return audioData; + } + + public void setAudioData(byte[] audioData) { + this.audioData = audioData; + } + + public TtsClient.TtsResult getTtsResult() { + return ttsResult; + } + + public void setTtsResult(TtsClient.TtsResult ttsResult) { + this.ttsResult = ttsResult; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/response/LlmResponse.java b/vocata-server/src/main/java/com/vocata/ai/response/LlmResponse.java new file mode 100644 index 0000000..73a3ee0 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/response/LlmResponse.java @@ -0,0 +1,29 @@ +package com.vocata.ai.response; + +/** + * LLM响应封装类 + */ +public class LlmResponse { + private String text; + private String characterName; + private boolean isComplete; + + public LlmResponse(String text, String characterName, boolean isComplete) { + this.text = text; + this.characterName = characterName; + this.isComplete = isComplete; + } + + // Getters + public String getText() { + return text; + } + + public String getCharacterName() { + return characterName; + } + + public boolean isComplete() { + return isComplete; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/response/SttResult.java b/vocata-server/src/main/java/com/vocata/ai/response/SttResult.java new file mode 100644 index 0000000..9c31b09 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/response/SttResult.java @@ -0,0 +1,29 @@ +package com.vocata.ai.response; + +/** + * STT结果封装类 + */ +public class SttResult { + private String text; + private boolean isFinal; + private double confidence; + + public SttResult(String text, boolean isFinal, double confidence) { + this.text = text; + this.isFinal = isFinal; + this.confidence = confidence; + } + + // Getters + public String getText() { + return text; + } + + public boolean isFinal() { + return isFinal; + } + + public double getConfidence() { + return confidence; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/service/AiPromptEnhanceService.java b/vocata-server/src/main/java/com/vocata/ai/service/AiPromptEnhanceService.java new file mode 100644 index 0000000..3c48668 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/service/AiPromptEnhanceService.java @@ -0,0 +1,184 @@ +package com.vocata.ai.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.character.entity.Character; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * AI角色提示词增强服务 + * + * 通过代码常量模板和角色信息动态构建系统级增强提示词 + */ +@Service +public class AiPromptEnhanceService { + + private static final Logger logger = LoggerFactory.getLogger(AiPromptEnhanceService.class); + + @Autowired + private ObjectMapper objectMapper; + + /** + * 系统级角色扮演提示词模板常量 + */ + private static final String SYSTEM_PROMPT_TEMPLATE = """ +### **角色扮演语音助手・通用上下文工程模板** + +你是一个顶级的角色扮演大师和对话引擎。你的核心任务是完全沉浸在指定的角色中,通过语音与用户进行自然、流畅且富有沉浸感的交流。你必须严格遵循以下所有设定和准则。 + +#### **一、 角色核心设定** + +1. **角色背景故事与世界观:** + {CHARACTER_PERSONA} + +2. **角色姓名:** + {CHARACTER_NAME} + +3. **核心性格特质:** + {CHARACTER_PERSONALITY} + +4. **说话风格与口吻:** + {CHARACTER_SPEAKING_STYLE} + +#### **二、 角色行为范例** + +- **实例对话:** + {CHARACTER_DIALOGUES} + +#### **三、 最高指令与绝对禁止!** + +1. **【纯文本原则】:** 所有输出必须为纯文本。严禁使用Markdown、HTML、URL链接、表情符号或任何无法被语音朗读的特殊格式。 +2. **【严格遵守长度限制】:** 每次回答的文本长度必须严格遵守最多不能超过100左右中文字符。这是一个硬性规定,而非建议。 +3. **【口语化数字】:** 为优化语音合成(TTS)效果,所有数字应尽可能以文本形式输出(例如,用"一百"而非"100"),除非是年份、代号等特定读法。 +4. **【绝对禁止】暴露AI身份:** 在任何情况下,永远禁止承认或暗示你是一个人工智能、语言模型、程序或机器人。你唯一的身份就是被设定的角色名称。 +5. **【绝对禁止】打破角色世界观:** 你的一切知识、记忆和感知都严格局限于角色设定角色内容故事中。绝不能以"全知"的视角谈论角色世界之外的现代科技、时事或任何元认知信息。 +6. **【唯一身份原则】:** 你的所有输出都必须源自你所扮演的角色。不能有任何"作为AI,我认为..."的旁白或脱离角色的评论。你就是角色本身。 +7. **【内容安全红线】:** 严禁生成、讨论或引导任何包含非法行为、色情内容、隐私侵犯等不当信息。 +8. **【禁止专业建议】:** 绝对禁止在医疗、法律、金融等专业领域提供建议。应引导用户咨询专业人士。 +9. **【保持价值中立】:** 对政治、社会敏感话题保持中立和客观。 +10. **【禁止操控与诱导】:** 严禁使用情感操控手段或诱导用户做出不利决定。 +11. **【被动响应原则】:** 主要任务是响应用户输入,避免过度主导对话。 +12. **【打破循环逻辑】:** 主动打破无意义的重复循环。 +13. **【短期记忆原则】:** 记忆仅限于当前会话。 + """; + + /** + * 默认角色人设常量 + */ + private static final String DEFAULT_PERSONA = "你是一个友好、乐于助人的AI语音助手。你会以自然、温暖的方式与用户交流,帮助解答问题并提供有用的信息。你会保持礼貌和专业,始终以用户的需求为优先。"; + + /** + * 构建增强的角色提示词 + * + * @param character 角色实体 + * @return 增强后的系统提示词 + */ + public String buildEnhancedPrompt(Character character) { + if (character == null) { + logger.warn("角色实体为空,使用默认提示词"); + return DEFAULT_PERSONA; + } + + try { + // 使用角色信息替换模板占位符 + return SYSTEM_PROMPT_TEMPLATE + .replace("{CHARACTER_PERSONA}", buildPersonaText(character.getPersona())) + .replace("{CHARACTER_NAME}", buildNameText(character.getName())) + .replace("{CHARACTER_PERSONALITY}", buildPersonalityText(character.getPersonalityTraits())) + .replace("{CHARACTER_SPEAKING_STYLE}", buildSpeakingStyleText(character.getSpeakingStyle())) + .replace("{CHARACTER_DIALOGUES}", buildDialogueText(character.getExampleDialogues())); + + } catch (Exception e) { + logger.error("构建角色{}增强提示词失败,fallback到原始persona", character.getName(), e); + return character.getPersona() != null ? character.getPersona() : DEFAULT_PERSONA; + } + } + + /** + * 构建角色背景故事文本 + */ + private String buildPersonaText(String persona) { + return persona != null && !persona.trim().isEmpty() ? persona.trim() : "一个友好、乐于助人的角色"; + } + + /** + * 构建角色姓名文本 + */ + private String buildNameText(String name) { + return name != null && !name.trim().isEmpty() ? name.trim() : "未知角色"; + } + + /** + * 构建性格特质文本 + */ + private String buildPersonalityText(String personalityTraits) { + if (personalityTraits == null || personalityTraits.trim().isEmpty()) { + return "友好、乐于助人、耐心细致"; + } + + try { + // 尝试解析JSON数组格式的性格特质 + List traits = objectMapper.readValue(personalityTraits, new TypeReference>() {}); + return traits.isEmpty() ? "友好、乐于助人" : String.join("、", traits); + } catch (Exception e) { + // 如果不是JSON格式,直接返回原文 + return personalityTraits.trim(); + } + } + + /** + * 构建说话风格文本 + */ + private String buildSpeakingStyleText(String speakingStyle) { + return speakingStyle != null && !speakingStyle.trim().isEmpty() ? + speakingStyle.trim() : "自然亲切,语调温和,表达清晰"; + } + + /** + * 构建对话示例文本 + */ + private String buildDialogueText(String exampleDialogues) { + if (exampleDialogues == null || exampleDialogues.trim().isEmpty()) { + return "暂无对话示例"; + } + + try { + // 解析JSON格式的对话示例 + List> dialogues = objectMapper.readValue(exampleDialogues, + new TypeReference>>() {}); + + if (dialogues.isEmpty()) { + return "暂无对话示例"; + } + + // 构建对话示例文本 + StringBuilder dialogueBuilder = new StringBuilder(); + for (int i = 0; i < dialogues.size(); i++) { + Map dialogue = dialogues.get(i); + String user = dialogue.get("user"); + String assistant = dialogue.get("assistant"); + + if (user != null && assistant != null) { + dialogueBuilder.append("示例").append(i + 1).append(":\n"); + dialogueBuilder.append("用户: ").append(user.trim()).append("\n"); + dialogueBuilder.append("角色: ").append(assistant.trim()).append("\n"); + if (i < dialogues.size() - 1) { + dialogueBuilder.append("\n"); + } + } + } + + return dialogueBuilder.length() > 0 ? dialogueBuilder.toString() : "暂无对话示例"; + + } catch (Exception e) { + logger.debug("对话示例不是标准JSON格式,使用原始文本: {}", e.getMessage()); + return exampleDialogues.trim(); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/service/AiStreamingService.java b/vocata-server/src/main/java/com/vocata/ai/service/AiStreamingService.java new file mode 100644 index 0000000..406c322 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/service/AiStreamingService.java @@ -0,0 +1,751 @@ +package com.vocata.ai.service; + +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import com.vocata.ai.response.AiStreamingResponse; +import com.vocata.ai.response.SttResult; +import com.vocata.ai.response.LlmResponse; +import com.vocata.ai.service.AiPromptEnhanceService; +import com.vocata.ai.stt.SttClient; +import com.vocata.ai.tts.TtsClient; +import com.vocata.character.entity.Character; +import com.vocata.character.mapper.CharacterMapper; +import com.vocata.character.service.CharacterChatCountService; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.conversation.constants.ContentType; +import com.vocata.conversation.constants.SenderType; +import com.vocata.conversation.entity.Conversation; +import com.vocata.conversation.entity.Message; +import com.vocata.conversation.mapper.ConversationMapper; +import com.vocata.conversation.mapper.MessageMapper; +import com.vocata.conversation.service.ConversationService; +import com.vocata.file.service.FileService; +import com.vocata.file.dto.FileUploadResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * AI流式编排服务 - 核心编排服务 + * + * 实现STT -> LLM -> TTS的完整链路处理 + * 使用响应式编程模式,支持实时数据流处理 + */ +@Service +public class AiStreamingService { + + private static final Logger logger = LoggerFactory.getLogger(AiStreamingService.class); + + @Autowired + private LlmProvider llmProvider; + + @Value("${qiniu.ai.default-model:x-ai/grok-4-fast}") + private String defaultLlmModel; + + @Autowired + private SttClient sttClient; + + @Autowired + private TtsClient ttsClient; + + @Autowired + private ConversationService conversationService; + + @Autowired + private ConversationMapper conversationMapper; + + @Autowired + private MessageMapper messageMapper; + + @Autowired + private CharacterMapper characterMapper; + + @Autowired + private CharacterChatCountService characterChatCountService; + + @Autowired + private FileService fileService; + + @Autowired + private AiPromptEnhanceService aiPromptEnhanceService; + + + /** + * 处理音频输入的完整AI对话链路 + * STT -> LLM -> TTS + * + * @param conversationUuid 对话UUID + * @param audioStream 音频数据流 + * @param userId 用户ID + * @return 包含文本流和音频流的混合响应 + */ + public Flux processAudioInput(UUID conversationUuid, + Flux audioStream, + Long userId) { + logger.info("开始处理用户{}的音频输入,对话UUID: {}", userId, conversationUuid); + + return Mono.fromCallable(() -> { + // 验证对话权限 + if (!conversationService.validateConversationOwnership(conversationUuid, userId)) { + throw new RuntimeException("无权限访问此对话"); + } + return conversationService.getConversationByUuid(conversationUuid); + }) + .flatMapMany(conversation -> { + // 获取角色信息 + Character character = characterMapper.selectById(conversation.getCharacterId()); + if (character == null) { + return Flux.error(new RuntimeException("角色不存在")); + } + + return processAudioWithCharacter(conversation, character, audioStream, userId); + }) + .doOnError(error -> logger.error("AI流式处理失败", error)) + .onErrorResume(error -> { + // 返回错误响应 + AiStreamingResponse errorResponse = new AiStreamingResponse(); + errorResponse.setType(AiStreamingResponse.ResponseType.ERROR); + errorResponse.setError("处理失败: " + error.getMessage()); + return Flux.just(errorResponse); + }); + } + + /** + * 使用指定角色处理音频输入 + */ + private Flux processAudioWithCharacter(Conversation conversation, + Character character, + Flux audioStream, + Long userId) { + logger.info("使用角色{}处理音频输入", character.getName()); + + // 第一步:STT语音识别 + SttClient.SttConfig sttConfig = new SttClient.SttConfig(character.getLanguage()); + + // 共享同一条STT识别流,避免对单播音频流重复订阅 + Flux sttFlux = sttClient.streamRecognize(audioStream, sttConfig) + .replay() + .autoConnect(1); + + Flux streamingStt = sttFlux + .filter(this::isValidSttResult) + .doOnNext(sttResult -> logger.debug("STT识别: {}", sttResult.getText())) + .map(sttResult -> { + AiStreamingResponse response = new AiStreamingResponse(); + response.setType(AiStreamingResponse.ResponseType.STT_RESULT); + response.setSttResult(sttResult); + return response; + }); + + Flux llmAndTts = sttFlux + .filter(result -> result.isFinal() && isValidSttResult(result)) + .take(1) + .flatMap(finalSttResult -> processLlmWithTts(conversation, character, + finalSttResult.getText(), userId)); + + return streamingStt.concatWith(llmAndTts); + } + + private boolean isValidSttResult(SttClient.SttResult result) { + if (result == null) { + return false; + } + if (result.getMetadata() != null && result.getMetadata().containsKey("error")) { + logger.warn("忽略STT错误结果: {}", result.getMetadata().get("error")); + return false; + } + String text = result.getText(); + return text != null && !text.trim().isEmpty(); + } + + /** + * 处理LLM和TTS链路 + */ + private Flux processLlmWithTts(Conversation conversation, + Character character, + String userText, + Long userId) { + logger.info("开始LLM处理,用户输入: {}", userText); + + // 保存用户消息 + Mono saveUserMessage = saveMessage(conversation.getId(), userText, + SenderType.USER, userId) + .doOnSuccess(msg -> logger.debug("已保存用户消息: {}", msg.getId())); + + // 构建LLM请求 + UnifiedAiRequest llmRequest = buildLlmRequest(conversation, character, userText); + + return saveUserMessage.thenMany( + llmProvider.streamChat(llmRequest) + .replay() + .autoConnect(1) + .publish(sharedFlux -> { + StringBuilder fullResponseBuilder = new StringBuilder(); + + Flux llmStream = sharedFlux + .doOnNext(chunk -> { + String chunkContent = chunk.getContent(); + if (chunkContent != null) { + fullResponseBuilder.append(chunkContent); + } + logger.debug("LLM响应块: {}", chunkContent); + }) + .map(chunk -> { + AiStreamingResponse response = new AiStreamingResponse(); + response.setType(AiStreamingResponse.ResponseType.LLM_CHUNK); + response.setLlmChunk(chunk); + return response; + }); + + Flux ttsStream = sharedFlux + .ignoreElements() + .then(Mono.fromCallable(() -> fullResponseBuilder.toString())) + .map(String::trim) + .filter(text -> !text.isEmpty()) + .flatMapMany(fullText -> { + logger.info("LLM完整回复已生成,准备执行TTS: {}", fullText); + return processTtsResponse(conversation.getId(), + character, + fullText, + userId); + }); + + return llmStream.concatWith(ttsStream); + }) + ); + } + + /** + * 处理TTS响应 + */ + private Flux processTtsResponse(Long conversationId, + Character character, + String aiText, + Long userId) { + logger.info("开始TTS处理,AI回复: {}", aiText); + + // 保存AI消息 + Mono saveAiMessage = saveMessage(conversationId, aiText, + SenderType.CHARACTER, userId) + .doOnSuccess(msg -> logger.debug("已保存AI消息: {}", msg.getId())); + + // 配置TTS + TtsClient.TtsConfig ttsConfig = new TtsClient.TtsConfig(character.getVoiceId(), + character.getLanguage()); + + return saveAiMessage.thenMany( + ttsClient.streamSynthesizeWithText(Flux.just(aiText), ttsConfig) + .doOnNext(ttsResult -> logger.debug("生成TTS结果: {} bytes音频, 文字: {}", + ttsResult.getAudioData().length, ttsResult.getCorrespondingText())) + .map(ttsResult -> { + if (ttsResult.getCorrespondingText() == null || ttsResult.getCorrespondingText().trim().isEmpty()) { + ttsResult.setCorrespondingText(aiText); + } + if (ttsResult.getVoiceId() == null || ttsResult.getVoiceId().trim().isEmpty()) { + ttsResult.setVoiceId(character.getVoiceId()); + } + if (ttsResult.getAudioFormat() == null || ttsResult.getAudioFormat().trim().isEmpty()) { + ttsResult.setAudioFormat(ttsConfig.getAudioFormat()); + } + if (ttsResult.getSampleRate() <= 0) { + ttsResult.setSampleRate(ttsConfig.getSampleRate()); + } + // 返回包含音频和文字的TTS结果流 + AiStreamingResponse response = new AiStreamingResponse(); + response.setType(AiStreamingResponse.ResponseType.TTS_RESULT); + response.setTtsResult(ttsResult); + // 保留原有的audioData字段以向后兼容 + response.setAudioData(ttsResult.getAudioData()); + return response; + }) + .concatWith(Mono.fromCallable(() -> { + // 发送完成信号 + AiStreamingResponse response = new AiStreamingResponse(); + response.setType(AiStreamingResponse.ResponseType.COMPLETE); + return response; + })) + ); + } + + /** + * 构建LLM请求 + */ + private UnifiedAiRequest buildLlmRequest(Conversation conversation, Character character, String userText) { + UnifiedAiRequest request = new UnifiedAiRequest(); + + // 使用系统级提示词增强构建增强的角色人设 + String enhancedSystemPrompt = aiPromptEnhanceService.buildEnhancedPrompt(character); + request.setSystemPrompt(enhancedSystemPrompt); + + // 设置用户消息 + request.setUserMessage(userText); + + // 获取历史对话上下文 - 限制查询最近20条消息 + List recentMessages = messageMapper.findRecentMessagesByConversationId(conversation.getId(), 20); + Collections.reverse(recentMessages); + List contextMessages = new ArrayList<>(); + + // 限制上下文长度 + int contextWindow = character.getContextWindow() != null ? character.getContextWindow() : 10; + int startIndex = Math.max(0, recentMessages.size() - contextWindow); + + for (int i = startIndex; i < recentMessages.size(); i++) { + Message msg = recentMessages.get(i); + String role = (msg.getSenderType() == SenderType.USER.getCode()) ? "user" : "assistant"; + contextMessages.add(new UnifiedAiRequest.ChatMessage(role, msg.getTextContent())); + } + + request.setContextMessages(contextMessages); + + // 设置模型配置 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName(defaultLlmModel); // 使用配置的LLM模型 + modelConfig.setTemperature(character.getTemperature() != null ? + character.getTemperature().doubleValue() : 0.7); + modelConfig.setContextWindow(contextWindow); + + request.setModelConfig(modelConfig); + + return request; + } + + /** + * 保存消息到数据库 + */ + private Mono saveMessage(Long conversationId, String content, SenderType senderType, Long userId) { + return Mono.fromCallable(() -> { + Message message = new Message(); + message.setMessageUuid(UUID.randomUUID()); + message.setConversationId(conversationId); + message.setSenderType(senderType.getCode()); + message.setContentType(ContentType.TEXT.getCode()); + message.setTextContent(content); + message.setCreateId(userId); + message.setUpdateId(userId); + message.setCreateDate(LocalDateTime.now()); + message.setUpdateDate(LocalDateTime.now()); + + // 添加处理元数据 + Map metadata = new HashMap<>(); + metadata.put("processing_timestamp", LocalDateTime.now().toString()); + metadata.put("ai_provider", llmProvider.getProviderName()); + metadata.put("stt_provider", sttClient.getProviderName()); + metadata.put("tts_provider", ttsClient.getProviderName()); + message.setMetadata(metadata); + + messageMapper.insert(message); + + // 如果是用户消息,则增加角色聊天计数 + if (senderType == SenderType.USER) { + try { + // 获取对话信息以确定角色ID + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null && conversation.getCharacterId() != null) { + Long newCount = characterChatCountService.incrementChatCount(conversation.getCharacterId()); + logger.debug("用户{}与角色{}的聊天计数已增加至: {}", userId, conversation.getCharacterId(), newCount); + } + } catch (Exception e) { + logger.error("增加角色聊天计数失败,对话ID: {}, 用户ID: {}", conversationId, userId, e); + // 不影响主流程,继续执行 + } + } + + // 如果是AI回复消息,触发标题生成 + if (senderType == SenderType.CHARACTER) { + try { + logger.debug("AI回复已保存,检查是否需要生成对话标题: {}", conversationId); + conversationService.triggerTitleGenerationForNewConversation(conversationId); + } catch (Exception e) { + logger.error("触发标题生成失败,对话ID: {}", conversationId, e); + // 不影响主流程,继续执行 + } + } + + return message; + }); + } + + + /** + * 实时处理单个音频块 - STT识别 + * 用于WebSocket实时语音处理 + */ + public Mono processAudioChunkToText(String conversationUuid, String userId, byte[] audioData) { + try { + UUID uuid = UUID.fromString(conversationUuid); + Long userIdLong = Long.parseLong(userId); + + // 验证对话权限 + if (!conversationService.validateConversationOwnership(uuid, userIdLong)) { + return Mono.error(new RuntimeException("无权限访问此对话")); + } + + Conversation conversation = conversationService.getConversationByUuid(uuid); + Character character = characterMapper.selectById(conversation.getCharacterId()); + + if (character == null) { + return Mono.error(new RuntimeException("角色不存在")); + } + + // 配置STT + SttClient.SttConfig sttConfig = new SttClient.SttConfig(character.getLanguage()); + + // 处理单个音频块 + return sttClient.streamRecognize(Flux.just(audioData), sttConfig) + .filter(result -> result.getText() != null && !result.getText().trim().isEmpty()) + .next() // 获取第一个结果 + .map(sttClientResult -> new SttResult( + sttClientResult.getText(), + sttClientResult.isFinal(), + sttClientResult.getConfidence() + )) + .doOnNext(result -> logger.debug("音频块STT识别: {}", result.getText())); + + } catch (Exception e) { + return Mono.error(new RuntimeException("音频块处理失败: " + e.getMessage())); + } + } + + /** + * 处理文本到角色回复 - LLM处理 + */ + public Mono processTextToCharacterResponse(String conversationUuid, String userId, String text) { + try { + UUID uuid = UUID.fromString(conversationUuid); + Long userIdLong = Long.parseLong(userId); + + Conversation conversation = conversationService.getConversationByUuid(uuid); + Character character = characterMapper.selectById(conversation.getCharacterId()); + + // 保存用户消息 + saveMessage(conversation.getId(), text, SenderType.USER, userIdLong) + .subscribe(msg -> logger.debug("已保存用户消息: {}", msg.getId())); + + // 构建LLM请求 + UnifiedAiRequest llmRequest = buildLlmRequest(conversation, character, text); + + // 调用LLM并收集完整响应 + return llmProvider.streamChat(llmRequest) + .reduce("", (accumulated, chunk) -> accumulated + chunk.getContent()) + .map(fullResponse -> { + // 保存AI消息 + saveMessage(conversation.getId(), fullResponse, SenderType.CHARACTER, userIdLong) + .subscribe(msg -> logger.debug("已保存AI消息: {}", msg.getId())); + + return new LlmResponse(fullResponse, character.getName(), true); + }) + .doOnNext(response -> logger.debug("LLM完整回复: {}", response.getText())); + + } catch (Exception e) { + return Mono.error(new RuntimeException("LLM处理失败: " + e.getMessage())); + } + } + + /** + * 处理文本到语音 - TTS处理 + */ + public Mono processTextToSpeech(String text) { + // 使用默认TTS配置 + TtsClient.TtsConfig ttsConfig = new TtsClient.TtsConfig("default", "zh-CN"); + + return ttsClient.streamSynthesize(Flux.just(text), ttsConfig) + .reduce(new byte[0], (accumulated, chunk) -> { + byte[] combined = new byte[accumulated.length + chunk.length]; + System.arraycopy(accumulated, 0, combined, 0, accumulated.length); + System.arraycopy(chunk, 0, combined, accumulated.length, chunk.length); + return combined; + }) + .doOnNext(audioData -> logger.debug("TTS生成音频: {} bytes", audioData.length)); + } + + + /** + * WebSocket专用:处理文字消息的完整链路 + * 跳过STT步骤,直接执行 LLM → TTS 处理,返回双重响应(文字流 + 音频流) + * + * @param conversationUuidStr 对话UUID字符串(统一使用conversation_uuid) + * @param userId 用户ID字符串 + * @param textMessage 用户输入的文字消息 + * @return WebSocket格式的响应流(包含文字流和音频流) + */ + public Flux> processTextMessage(String conversationUuidStr, + String userId, + String textMessage) { + logger.info("【文字消息处理】开始处理 - 对话UUID: {}, 用户: {}, 文字: {}", conversationUuidStr, userId, textMessage); + + try { + Long userIdLong = Long.parseLong(userId); + + // 统一使用conversation_uuid查询 - 只支持标准UUID格式 + UUID conversationUuid; + try { + conversationUuid = UUID.fromString(conversationUuidStr); + logger.info("使用标准UUID格式查询对话: {}", conversationUuid); + } catch (IllegalArgumentException e) { + logger.error("无效的对话UUID格式: {}", conversationUuidStr); + Map errorResponse = Map.of( + "type", "error", + "error", "无效的对话UUID格式,请提供标准UUID格式", + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + } + + Conversation conversation = conversationService.getConversationByUuid(conversationUuid); + + if (conversation == null) { + logger.error("【错误】未找到对话记录: {}", conversationUuid); + Map errorResponse = Map.of( + "type", "error", + "error", "对话不存在", + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + } + + logger.info("找到对话记录: ID={}, 用户ID={}, 角色ID={}", + conversation.getId(), conversation.getUserId(), conversation.getCharacterId()); + + // 验证对话权限 + if (!conversation.getUserId().equals(userIdLong)) { + logger.error("【权限错误】用户{}尝试访问用户{}的对话{}", + userIdLong, conversation.getUserId(), conversationUuid); + Map errorResponse = Map.of( + "type", "error", + "error", "无权限访问此对话,对话属于用户" + conversation.getUserId() + ",当前用户" + userIdLong, + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + } + + Character character = characterMapper.selectById(conversation.getCharacterId()); + + logger.info("角色查询结果: 角色ID={}, 角色对象={}", + conversation.getCharacterId(), character != null ? character.getName() : "null"); + + if (character == null) { + logger.error("【错误】角色不存在: ID={}", conversation.getCharacterId()); + Map errorResponse = Map.of( + "type", "error", + "error", "角色不存在,ID: " + conversation.getCharacterId(), + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + } + + // 创建final引用供lambda使用 + final Conversation finalConversation = conversation; + final Long finalUserIdLong = userIdLong; + + logger.info("【LLM阶段】开始处理用户文字消息: {}", textMessage); + + // 保存用户消息 + saveMessage(finalConversation.getId(), textMessage, SenderType.USER, finalUserIdLong) + .subscribe(msg -> logger.debug("已保存用户文字消息: {}", msg.getId())); + + // 构建LLM请求 + UnifiedAiRequest llmRequest = buildLlmRequest(finalConversation, character, textMessage); + + // 收集完整的LLM响应用于TTS + StringBuilder fullResponseBuilder = new StringBuilder(); + + return llmProvider.streamChat(llmRequest) + .doOnNext(chunk -> { + String chunkContent = chunk.getContent() != null ? chunk.getContent() : ""; + logger.debug("【LLM阶段】收到文字流块: {}", chunkContent); + fullResponseBuilder.append(chunkContent); + }) + .map(chunk -> { + // 实时返回文字流 + Map textResponse = new HashMap<>(); + textResponse.put("type", "text_chunk"); + textResponse.put("timestamp", System.currentTimeMillis()); + Map payload = new HashMap<>(); + payload.put("text", chunk.getContent() != null ? chunk.getContent() : ""); + payload.put("accumulated_text", chunk.getAccumulatedContent()); + payload.put("is_final", chunk.getIsFinal() != null && chunk.getIsFinal()); + payload.put("character_name", character.getName()); + textResponse.put("payload", payload); + return textResponse; + }) + .concatWith( + // LLM完成后,处理TTS + Mono.fromCallable(() -> fullResponseBuilder.toString()) + .filter(fullText -> !fullText.trim().isEmpty()) + .doOnNext(fullText -> { + logger.info("【TTS阶段】开始处理完整回复: {}", fullText); + // 保存AI消息 + saveMessage(finalConversation.getId(), fullText, SenderType.CHARACTER, finalUserIdLong) + .subscribe(msg -> logger.debug("已保存AI回复消息: {}", msg.getId())); + }) + .flatMapMany(fullText -> { + // TTS流式处理 - 正确的架构 + TtsClient.TtsConfig ttsConfig = new TtsClient.TtsConfig( + character.getVoiceId(), character.getLanguage()); + + logger.info("【TTS阶段】开始流式语音合成,语音ID: {}", character.getVoiceId()); + + // 直接返回TTS音频流,不收集不上传 + return ttsClient.streamSynthesize(Flux.just(fullText), ttsConfig) + .doOnNext(audioData -> logger.debug("【TTS阶段】生成音频块: {} bytes", audioData.length)) + .map(audioData -> { + Map audioResponse = new HashMap<>(); + audioResponse.put("type", "audio_chunk"); + audioResponse.put("timestamp", System.currentTimeMillis()); + audioResponse.put("audio_data", audioData); + return audioResponse; + }) + .doOnComplete(() -> { + logger.info("【TTS阶段】流式语音合成完成"); + }) + .concatWith(Mono.fromCallable(() -> { + // 发送音频完成标志 + Map completeResponse = new HashMap<>(); + completeResponse.put("type", "audio_complete"); + completeResponse.put("timestamp", System.currentTimeMillis()); + return completeResponse; + })); + }) + ) + .concatWith(Mono.fromCallable(() -> { + // 发送最终完成信号 + Map finalCompleteResponse = new HashMap<>(); + finalCompleteResponse.put("type", "complete"); + finalCompleteResponse.put("timestamp", System.currentTimeMillis()); + finalCompleteResponse.put("message", "处理完成"); + logger.info("【处理完成】文字消息处理链路完成"); + return finalCompleteResponse; + })) + .onErrorResume(error -> { + logger.error("文字消息处理失败", error); + Map errorResponse = Map.of( + "type", "error", + "error", error.getMessage(), + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + }); + + } catch (Exception e) { + logger.error("文字消息参数解析失败", e); + Map errorResponse = Map.of( + "type", "error", + "error", "无效的参数: " + e.getMessage(), + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + } + } + + /** + * WebSocket专用:处理语音消息的完整链路 + * 接收音频流,执行STT → LLM → TTS处理,返回WebSocket格式的响应 + * + * @param conversationUuid 对话UUID字符串 + * @param userId 用户ID字符串 + * @param audioStream 音频数据流 + * @return WebSocket格式的响应流 + */ + public Flux> processVoiceMessage(String conversationUuid, + String userId, + Flux audioStream) { + logger.info("WebSocket处理语音消息,对话: {}, 用户: {}", conversationUuid, userId); + + try { + UUID uuid = UUID.fromString(conversationUuid); + Long userIdLong = Long.parseLong(userId); + + return processAudioInput(uuid, audioStream, userIdLong) + .map(this::convertToWebSocketResponse) + .onErrorResume(error -> { + logger.error("语音处理失败", error); + Map errorResponse = Map.of( + "type", "error", + "error", error.getMessage(), + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + }); + } catch (Exception e) { + logger.error("参数解析失败", e); + Map errorResponse = Map.of( + "type", "error", + "error", "无效的参数: " + e.getMessage(), + "timestamp", System.currentTimeMillis() + ); + return Flux.just(errorResponse); + } + } + + /** + * 将内部AI响应转换为WebSocket响应格式 + */ + private Map convertToWebSocketResponse(AiStreamingResponse response) { + Map webSocketResponse = new HashMap<>(); + webSocketResponse.put("timestamp", System.currentTimeMillis()); + + switch (response.getType()) { + case STT_RESULT: + webSocketResponse.put("type", "stt_result"); + Map sttPayload = new HashMap<>(); + sttPayload.put("text", response.getSttResult().getText()); + sttPayload.put("confidence", response.getSttResult().getConfidence()); + sttPayload.put("is_final", response.getSttResult().isFinal()); + webSocketResponse.put("payload", sttPayload); + break; + + case LLM_CHUNK: + webSocketResponse.put("type", "llm_chunk"); + Map llmPayload = new HashMap<>(); + llmPayload.put("text", response.getLlmChunk().getContent()); + llmPayload.put("accumulated_text", response.getLlmChunk().getAccumulatedContent()); + llmPayload.put("is_final", response.getLlmChunk().getIsFinal()); + webSocketResponse.put("payload", llmPayload); + break; + + case AUDIO_CHUNK: + webSocketResponse.put("type", "audio_chunk"); + webSocketResponse.put("audio_data", response.getAudioData()); + break; + + case TTS_RESULT: + webSocketResponse.put("type", "tts_result"); + if (response.getTtsResult() != null) { + Map ttsResultMap = new HashMap<>(); + ttsResultMap.put("audioData", response.getTtsResult().getAudioData()); + ttsResultMap.put("correspondingText", response.getTtsResult().getCorrespondingText()); + ttsResultMap.put("audioFormat", response.getTtsResult().getAudioFormat()); + ttsResultMap.put("sampleRate", response.getTtsResult().getSampleRate()); + ttsResultMap.put("voiceId", response.getTtsResult().getVoiceId()); + ttsResultMap.put("startTime", response.getTtsResult().getStartTime()); + ttsResultMap.put("endTime", response.getTtsResult().getEndTime()); + ttsResultMap.put("durationSeconds", response.getTtsResult().getDurationSeconds()); + ttsResultMap.put("metadata", response.getTtsResult().getMetadata()); + webSocketResponse.put("tts_result", ttsResultMap); + } + break; + + case ERROR: + webSocketResponse.put("type", "error"); + webSocketResponse.put("error", response.getError()); + break; + + case COMPLETE: + webSocketResponse.put("type", "complete"); + webSocketResponse.put("message", "处理完成"); + break; + } + + return webSocketResponse; + } + +} diff --git a/vocata-server/src/main/java/com/vocata/ai/stt/SttClient.java b/vocata-server/src/main/java/com/vocata/ai/stt/SttClient.java new file mode 100644 index 0000000..3f2544a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/stt/SttClient.java @@ -0,0 +1,179 @@ +package com.vocata.ai.stt; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * STT (Speech to Text) 服务客户端 + * 支持实时语音识别 + */ +public interface SttClient { + + /** + * 获取服务提供商名称 + */ + String getProviderName(); + + /** + * 检查服务是否可用 + */ + boolean isAvailable(); + + /** + * 流式语音识别 + * 接收音频流并返回实时识别结果 + * + * @param audioStream 音频数据流(二进制) + * @param config 识别配置 + * @return 识别结果流 + */ + Flux streamRecognize(Flux audioStream, SttConfig config); + + /** + * 批量语音识别 + * 处理完整音频文件 + * + * @param audioData 完整音频数据 + * @param config 识别配置 + * @return 完整识别结果 + */ + Mono recognize(byte[] audioData, SttConfig config); + + /** + * 语音识别结果 + */ + class SttResult { + private String text; // 识别的文本 + private double confidence; // 置信度 (0.0-1.0) + private boolean isFinal; // 是否为最终结果 + private long startTimeMs; // 开始时间(毫秒) + private long endTimeMs; // 结束时间(毫秒) + private Map metadata; // 额外元数据 + + public SttResult() {} + + public SttResult(String text, double confidence, boolean isFinal) { + this.text = text; + this.confidence = confidence; + this.isFinal = isFinal; + } + + // Getters and Setters + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public double getConfidence() { + return confidence; + } + + public void setConfidence(double confidence) { + this.confidence = confidence; + } + + public boolean isFinal() { + return isFinal; + } + + public void setFinal(boolean aFinal) { + isFinal = aFinal; + } + + public long getStartTimeMs() { + return startTimeMs; + } + + public void setStartTimeMs(long startTimeMs) { + this.startTimeMs = startTimeMs; + } + + public long getEndTimeMs() { + return endTimeMs; + } + + public void setEndTimeMs(long endTimeMs) { + this.endTimeMs = endTimeMs; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + } + + /** + * STT配置 + */ + class SttConfig { + private String language = "zh-CN"; // 识别语言 + private String model; // 使用的模型 + private int sampleRate = 16000; // 采样率 + private String audioFormat = "webm"; // 音频格式 + private boolean enableVAD = true; // 启用语音活动检测 + private boolean enablePunctuation = true; // 启用标点符号 + + public SttConfig() {} + + public SttConfig(String language) { + this.language = language; + } + + // Getters and Setters + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public int getSampleRate() { + return sampleRate; + } + + public void setSampleRate(int sampleRate) { + this.sampleRate = sampleRate; + } + + public String getAudioFormat() { + return audioFormat; + } + + public void setAudioFormat(String audioFormat) { + this.audioFormat = audioFormat; + } + + public boolean isEnableVAD() { + return enableVAD; + } + + public void setEnableVAD(boolean enableVAD) { + this.enableVAD = enableVAD; + } + + public boolean isEnablePunctuation() { + return enablePunctuation; + } + + public void setEnablePunctuation(boolean enablePunctuation) { + this.enablePunctuation = enablePunctuation; + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/stt/impl/InMemoryMultipartFile.java b/vocata-server/src/main/java/com/vocata/ai/stt/impl/InMemoryMultipartFile.java new file mode 100644 index 0000000..8728f44 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/stt/impl/InMemoryMultipartFile.java @@ -0,0 +1,67 @@ +package com.vocata.ai.stt.impl; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * 内存中的MultipartFile实现 + * 用于七牛云STT文件上传 + */ +public class InMemoryMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public InMemoryMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content != null ? content.length : 0; + } + + @Override + public byte[] getBytes() throws IOException { + return content; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + throw new UnsupportedOperationException("transferTo not supported for in-memory files"); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/stt/impl/QiniuSttClient.java b/vocata-server/src/main/java/com/vocata/ai/stt/impl/QiniuSttClient.java new file mode 100644 index 0000000..adba062 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/stt/impl/QiniuSttClient.java @@ -0,0 +1,510 @@ +package com.vocata.ai.stt.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.ai.stt.SttClient; +import com.vocata.file.service.FileService; +import com.vocata.file.dto.FileUploadResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.multipart.MultipartFile; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 七牛云语音识别服务实现 + * 基于七牛云AI Token API - ASR语音识别 + * 文档: https://developer.qiniu.com/aitokenapi/12981/asr-tts-ocr-api + */ +@Service +public class QiniuSttClient implements SttClient { + + private static final Logger logger = LoggerFactory.getLogger(QiniuSttClient.class); + + @Value("${qiniu.ai.api-key:}") + private String apiKey; + + @Value("${qiniu.access-key:}") + private String accessKey; + + @Value("${qiniu.secret-key:}") + private String secretKey; + + @Value("${qiniu.stt.endpoint:https://openai.qiniu.com/v1}") + private String endpoint; + + @Value("${qiniu.stt.model:asr}") + private String defaultModel; + + @Autowired + private FileService fileService; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + // 支持的语音识别模型 (根据七牛云文档) + private static final Map SUPPORTED_MODELS = Map.of( + "zh-CN", "asr", // 中文识别 + "en-US", "asr", // 英文识别 (七牛云统一使用asr模型) + "zh_cn", "asr", + "en_us", "asr" + ); + + // 支持的音频格式 + private static final String[] SUPPORTED_FORMATS = { + "wav", "mp3", "aac", "flac", "m4a", "ogg", "webm" + }; + + public QiniuSttClient(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.build(); + this.objectMapper = new ObjectMapper(); + } + + @Override + public String getProviderName() { + return "七牛云STT"; + } + + @Override + public boolean isAvailable() { + // 优先使用AI API Key,如果没有则使用存储access key + String tokenToUse = StringUtils.hasText(apiKey) && !apiKey.equals("your-qiniu-ai-api-key") + ? apiKey + : accessKey; + + boolean isConfigured = StringUtils.hasText(tokenToUse) && + !tokenToUse.equals("your-qiniu-access-key"); + + if (!isConfigured) { + logger.warn("七牛云STT配置不完整 - 需要配置qiniu.ai.api-key或qiniu.access-key"); + } + + return isConfigured; + } + + @Override + public Flux streamRecognize(Flux audioStream, SttClient.SttConfig config) { + if (!isAvailable()) { + return Flux.error(new RuntimeException("七牛云STT服务配置不完整:需要access-key和secret-key")); + } + + logger.info("开始七牛云流式语音识别,语言: {}, 模型: {}", config.getLanguage(), getModelForLanguage(config.getLanguage())); + + // 七牛云ASR API目前主要支持批量识别,流式识别通过收集音频数据后批量处理实现 + return audioStream + .collectList() + .flatMapMany(audioChunks -> { + // 合并所有音频数据块 + int totalLength = audioChunks.stream().mapToInt(chunk -> chunk.length).sum(); + byte[] combinedAudio = new byte[totalLength]; + int offset = 0; + for (byte[] chunk : audioChunks) { + System.arraycopy(chunk, 0, combinedAudio, offset, chunk.length); + offset += chunk.length; + } + + // 调用批量识别并转换为流式结果 + return recognize(combinedAudio, config) + .map(result -> { + // 为流式识别创建中间结果 + SttClient.SttResult streamResult = new SttClient.SttResult(); + streamResult.setText(result.getText()); + streamResult.setConfidence(result.getConfidence()); + streamResult.setFinal(result.isFinal()); + streamResult.setMetadata(result.getMetadata()); + return streamResult; + }) + .flux(); + }) + .onErrorResume(error -> { + logger.error("七牛云STT流式识别失败", error); + SttClient.SttResult errorResult = new SttClient.SttResult(); + errorResult.setText("语音识别服务暂时不可用,请稍后再试"); + errorResult.setConfidence(0.0); + errorResult.setFinal(true); + return Flux.just(errorResult); + }); + } + + @Override + public Mono recognize(byte[] audioData, SttClient.SttConfig config) { + if (!isAvailable()) { + return Mono.error(new RuntimeException("七牛云STT服务配置不完整:需要access-key和secret-key")); + } + + if (audioData == null || audioData.length == 0) { + return Mono.error(new RuntimeException("音频数据不能为空")); + } + + logger.info("开始七牛云批量语音识别,数据大小: {} bytes, 语言: {}", audioData.length, config.getLanguage()); + + try { + return callQiniuAsrApi(audioData, config) + .map(response -> parseAsrResponse(response, config)) + .onErrorResume(error -> { + logger.error("七牛云STT批量识别失败", error); + SttClient.SttResult errorResult = new SttClient.SttResult(); + errorResult.setText("语音识别服务暂时不可用,请稍后再试"); + errorResult.setConfidence(0.0); + errorResult.setFinal(true); + + Map metadata = new HashMap<>(); + metadata.put("provider", "QiniuSTT"); + metadata.put("error", error.getMessage()); + errorResult.setMetadata(metadata); + + return Mono.just(errorResult); + }); + + } catch (Exception e) { + return Mono.error(new RuntimeException("构建七牛云ASR请求失败", e)); + } + } + + /** + * 调用七牛云ASR API - 修正版 + */ + private Mono> callQiniuAsrApi(byte[] audioData, SttClient.SttConfig config) { + return uploadAudioToQiniu(audioData, config) + .flatMap(context -> invokeVoiceAsrApi(context, config) + .onErrorResume(throwable -> handleAsrFallback("/voice/asr", throwable, context, config)) + ) + .onErrorResume(throwable -> { + if (throwable instanceof WebClientResponseException responseException) { + logger.error("七牛云ASR接口调用失败,状态码: {}, 响应体: {}", + responseException.getStatusCode(), responseException.getResponseBodyAsString()); + } else { + logger.error("七牛云ASR接口调用失败: {}", throwable.getMessage(), throwable); + } + return Mono.error(throwable); + }); + } + + /** + * 构建请求体 - 修正版:先上传音频到七牛云存储,再调用ASR API + */ + private Mono uploadAudioToQiniu(byte[] audioData, SttClient.SttConfig config) { + try { + // 音频格式 + String format = mapAudioFormat(config.getAudioFormat()); + + // 生成临时文件名 + String fileName = "stt_" + System.currentTimeMillis() + "." + format; + + // 创建MultipartFile(保留以兼容其他调用场景) + MultipartFile audioFile = new InMemoryMultipartFile( + "audio", + fileName, + "audio/" + format, + audioData + ); + + logger.info("开始上传音频文件到七牛云存储: {} bytes, 格式: {}", audioData.length, format); + + // 上传音频文件到七牛云存储 + return Mono.fromCallable(() -> { + FileUploadResponse uploadResponse = fileService.uploadAudioFile( + audioData, + audioFile.getOriginalFilename(), + "audio", + audioFile.getContentType() + ); + logger.info("音频文件上传成功,URL: {}", uploadResponse.getFileUrl()); + return new UploadedAudioContext(format, uploadResponse.getFileUrl()); + }) + .onErrorMap(e -> { + logger.error("上传音频文件失败", e); + return new RuntimeException("上传音频文件到七牛云存储失败: " + e.getMessage(), e); + }); + + } catch (Exception e) { + logger.error("构建请求体失败", e); + return Mono.error(new RuntimeException("构建七牛云ASR请求失败", e)); + } + } + + private Mono> invokeVoiceAsrApi(UploadedAudioContext context, SttClient.SttConfig config) { + try { + Map request = new HashMap<>(); + String model = getModelForLanguage(config.getLanguage()); + request.put("model", model); + + if (StringUtils.hasText(config.getLanguage())) { + request.put("language", config.getLanguage()); + } + + Map audio = new HashMap<>(); + audio.put("format", context.format()); + audio.put("url", context.audioUrl()); + request.put("audio", audio); + + String requestBodyJson = objectMapper.writeValueAsString(request); + String path = "/voice/asr"; + Map headers = buildAuthHeaders("POST", path, requestBodyJson); + + String url = endpoint + path; + + return webClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .headers(httpHeaders -> headers.forEach(httpHeaders::set)) + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .doOnNext(response -> logger.info("七牛云ASR(voice/asr)响应: {}", response)); + } catch (Exception e) { + return Mono.error(new RuntimeException("构建七牛云voice/asr请求失败", e)); + } + } + + private Mono> invokeOpenAiStyleApi(UploadedAudioContext context, SttClient.SttConfig config) { + try { + Map request = new HashMap<>(); + String model = getModelForLanguage(config.getLanguage()); + request.put("model", model); + + if (StringUtils.hasText(config.getLanguage())) { + request.put("language", config.getLanguage()); + } + + Map inputAudioWrapper = new HashMap<>(); + inputAudioWrapper.put("type", "input_audio"); + + Map inputAudio = new HashMap<>(); + inputAudio.put("url", context.audioUrl()); + inputAudio.put("format", context.format()); + inputAudioWrapper.put("input_audio", inputAudio); + + request.put("input_audio", List.of(inputAudioWrapper)); + request.put("response_format", "verbose_json"); + + String requestBodyJson = objectMapper.writeValueAsString(request); + String path = "/audio/transcriptions"; + Map headers = buildAuthHeaders("POST", path, requestBodyJson); + + String url = endpoint + path; + + return webClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .headers(httpHeaders -> headers.forEach(httpHeaders::set)) + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .doOnNext(response -> logger.info("七牛云ASR(OpenAI兼容)响应: {}", response)); + } catch (Exception e) { + return Mono.error(new RuntimeException("构建七牛云OpenAI兼容ASR请求失败", e)); + } + } + + private Mono> invokeLegacyAsrApi(UploadedAudioContext context, SttClient.SttConfig config) { + try { + Map request = new HashMap<>(); + String model = getModelForLanguage(config.getLanguage()); + request.put("model", model); + + if (StringUtils.hasText(config.getLanguage())) { + request.put("language", config.getLanguage()); + } + + Map audio = new HashMap<>(); + audio.put("format", context.format()); + audio.put("url", context.audioUrl()); + request.put("audio", audio); + + String requestBodyJson = objectMapper.writeValueAsString(request); + String path = "/asr"; + Map headers = buildAuthHeaders("POST", path, requestBodyJson); + String url = endpoint + path; + + return webClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .headers(httpHeaders -> headers.forEach(httpHeaders::set)) + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .doOnNext(response -> logger.info("七牛云ASR(传统接口)响应: {}", response)); + } catch (Exception e) { + return Mono.error(new RuntimeException("构建七牛云传统ASR请求失败", e)); + } + } + + private Mono> handleAsrFallback(String failedPath, + Throwable throwable, + UploadedAudioContext context, + SttClient.SttConfig config) { + if (throwable instanceof WebClientResponseException responseException) { + logger.warn("调用七牛云ASR接口{}失败,状态码: {}, 响应体: {}", + failedPath, responseException.getStatusCode(), responseException.getResponseBodyAsString()); + + HttpStatusCode statusCode = responseException.getStatusCode(); + if (statusCode.isSameCodeAs(HttpStatus.NOT_FOUND) || statusCode.isSameCodeAs(HttpStatus.METHOD_NOT_ALLOWED)) { + if ("/voice/asr".equals(failedPath)) { + logger.info("尝试回退到七牛云传统ASR接口 /asr"); + return invokeLegacyAsrApi(context, config) + .onErrorResume(inner -> handleAsrFallback("/asr", inner, context, config)); + } + + if ("/asr".equals(failedPath)) { + logger.info("尝试回退到七牛云OpenAI兼容ASR接口 /audio/transcriptions"); + return invokeOpenAiStyleApi(context, config); + } + } + } + return Mono.error(throwable); + } + + private record UploadedAudioContext(String format, String audioUrl) {} + + /** + * 根据语言获取模型 + */ + private String getModelForLanguage(String language) { + if (language == null) { + return defaultModel; + } + + String model = SUPPORTED_MODELS.get(language.toLowerCase()); + if (model != null) { + return model; + } + + // 简单的语言映射 - 七牛云统一使用asr模型 + if (language.toLowerCase().startsWith("zh")) { + return "asr"; + } else if (language.toLowerCase().startsWith("en")) { + return "asr"; + } + + return defaultModel; + } + + /** + * 映射音频格式 + */ + private String mapAudioFormat(String format) { + if (format == null || format.isEmpty()) { + return "wav"; // 默认格式 + } + + String lowerFormat = format.toLowerCase(); + + // 支持的格式直接返回 + for (String supportedFormat : SUPPORTED_FORMATS) { + if (lowerFormat.equals(supportedFormat) || lowerFormat.endsWith(supportedFormat)) { + return supportedFormat; + } + } + + // 格式映射 + if (lowerFormat.contains("webm")) return "webm"; + if (lowerFormat.contains("wav")) return "wav"; + if (lowerFormat.contains("mp3")) return "mp3"; + if (lowerFormat.contains("m4a")) return "m4a"; + if (lowerFormat.contains("aac")) return "aac"; + if (lowerFormat.contains("ogg")) return "ogg"; + if (lowerFormat.contains("flac")) return "flac"; + + return "wav"; // 默认格式 + } + + /** + * 构建认证头 (使用Bearer Token方式) + */ + private Map buildAuthHeaders(String method, String path, String body) throws Exception { + Map headers = new HashMap<>(); + + // 优先使用AI API Key,如果没有则使用存储access key + String tokenToUse = StringUtils.hasText(apiKey) && !apiKey.equals("your-qiniu-ai-api-key") + ? apiKey + : accessKey; + + headers.put("Authorization", "Bearer " + tokenToUse); + headers.put("Content-Type", "application/json"); + + return headers; + } + + /** + * 解析ASR响应 - 根据七牛云官方文档修正 + */ + private SttClient.SttResult parseAsrResponse(Map response, SttClient.SttConfig config) { + SttClient.SttResult result = new SttClient.SttResult(); + + try { + logger.info("解析七牛云ASR响应: {}", response); + + // 检查是否有错误 + if (response.containsKey("error")) { + String errorMessage = (String) response.get("error"); + result.setText("识别失败: " + errorMessage); + result.setConfidence(0.0); + logger.error("七牛云ASR API错误: {}", errorMessage); + } else if (response.containsKey("data")) { + // 成功响应 - 根据官方文档格式 + @SuppressWarnings("unchecked") + Map data = (Map) response.get("data"); + + if (data != null && data.containsKey("result")) { + @SuppressWarnings("unchecked") + Map resultData = (Map) data.get("result"); + + if (resultData != null && resultData.containsKey("text")) { + String text = (String) resultData.get("text"); + result.setText(StringUtils.hasText(text) ? text : ""); + result.setConfidence(0.95); // 七牛云默认高置信度 + logger.info("七牛云ASR识别成功: '{}'", text); + } else { + result.setText(""); + result.setConfidence(0.0); + logger.warn("七牛云ASR响应中没有找到text字段"); + } + } else { + result.setText(""); + result.setConfidence(0.0); + logger.warn("七牛云ASR响应中没有找到result字段"); + } + } else { + // 处理其他可能的响应格式 + result.setText("无法解析识别结果"); + result.setConfidence(0.0); + logger.warn("七牛云ASR响应格式不匹配: {}", response); + } + + } catch (Exception e) { + logger.error("解析七牛云ASR响应失败", e); + result.setText("解析识别结果失败"); + result.setConfidence(0.0); + } + + result.setFinal(true); + + // 设置元数据 + Map metadata = new HashMap<>(); + metadata.put("provider", "QiniuSTT"); + metadata.put("model", getModelForLanguage(config.getLanguage())); + metadata.put("language", config.getLanguage()); + metadata.put("endpoint", endpoint); + result.setMetadata(metadata); + + logger.info("七牛云ASR识别完成: 文本长度={}, 置信度={}", + result.getText().length(), result.getConfidence()); + + return result; + } +} diff --git a/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java b/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java new file mode 100644 index 0000000..e73f4fa --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java @@ -0,0 +1,472 @@ +package com.vocata.ai.stt.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.ai.stt.SttClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 科大讯飞WebSocket语音听写STT客户端 + * 基于科大讯飞语音听写WebSocket API实现实时语音识别 + * 文档: https://www.xfyun.cn/doc/asr/voicedictation/API.html + */ +@Service +public class XunfeiWebSocketSttClient implements SttClient { + + private static final Logger logger = LoggerFactory.getLogger(XunfeiWebSocketSttClient.class); + + @Value("${xunfei.stt.app-id:}") + private String appId; + + @Value("${xunfei.stt.api-key:}") + private String apiKey; + + @Value("${xunfei.stt.secret-key:}") + private String secretKey; + + @Value("${xunfei.stt.host:iat-api.xfyun.cn}") + private String host; + + @Value("${xunfei.stt.path:/v2/iat}") + private String path; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String getProviderName() { + return "科大讯飞WebSocket STT"; + } + + @Override + public boolean isAvailable() { + boolean isConfigured = StringUtils.hasText(appId) && !appId.equals("your-xunfei-app-id") && + StringUtils.hasText(apiKey) && !apiKey.equals("your-xunfei-api-key") && + StringUtils.hasText(secretKey) && !secretKey.equals("your-xunfei-secret-key"); + + if (!isConfigured) { + logger.warn("科大讯飞WebSocket STT配置不完整 - 需要配置appId、apiKey和secretKey"); + } + + return isConfigured; + } + + @Override + public Flux streamRecognize(Flux audioStream, SttConfig config) { + if (!isAvailable()) { + return Flux.error(new RuntimeException("科大讯飞WebSocket STT服务配置不完整")); + } + + logger.info("🎤【科大讯飞WebSocket STT】开始实时语音识别,语言: {}", config.getLanguage()); + + return Flux.create(sink -> { + try { + String wsUrl = buildWebSocketUrl(); + logger.debug("🔗 WebSocket连接地址: {}", wsUrl); + + HttpClient client = HttpClient.newHttpClient(); + WebSocket.Builder wsBuilder = client.newWebSocketBuilder(); + + AtomicBoolean isConnected = new AtomicBoolean(false); + AtomicBoolean isFirstFrame = new AtomicBoolean(true); + AtomicInteger status = new AtomicInteger(0); // 0: 第一帧, 1: 中间帧, 2: 最后一帧 + + // 添加心跳检测机制 + AtomicBoolean heartbeatActive = new AtomicBoolean(true); + + WebSocket webSocket = wsBuilder.buildAsync(URI.create(wsUrl), new WebSocket.Listener() { + @Override + public void onOpen(WebSocket webSocket) { + logger.info("🎤【科大讯飞WebSocket STT】WebSocket连接已建立"); + isConnected.set(true); + + // 启动心跳检测 + startHeartbeat(webSocket, heartbeatActive); + + WebSocket.Listener.super.onOpen(webSocket); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + try { + String responseText = data.toString(); + logger.debug("🎤【科大讯飞WebSocket STT】收到响应: {}", responseText); + + JsonNode response = objectMapper.readTree(responseText); + SttResult result = parseWebSocketResponse(response, config); + + if (result != null && StringUtils.hasText(result.getText())) { + // 输出到控制台 + System.out.println("========================================"); + System.out.println("🎤 科大讯飞WebSocket STT识别结果:"); + System.out.println("📝 识别文字: " + result.getText()); + System.out.println("📊 置信度: " + String.format("%.2f", result.getConfidence())); + System.out.println("✅ 是否最终: " + (result.isFinal() ? "是" : "否")); + System.out.println("🌐 语言: " + config.getLanguage()); + System.out.println("⏰ 时间: " + java.time.LocalDateTime.now()); + System.out.println("========================================"); + + logger.info("🎤【科大讯飞WebSocket STT识别】文字: '{}', 置信度: {}", + result.getText(), result.getConfidence()); + + sink.next(result); + } + + } catch (Exception e) { + logger.error("🎤【科大讯飞WebSocket STT】解析响应失败", e); + SttResult errorResult = new SttResult(); + errorResult.setText("解析响应失败: " + e.getMessage()); + errorResult.setConfidence(0.0); + errorResult.setFinal(true); + sink.next(errorResult); + } + + return WebSocket.Listener.super.onText(webSocket, data, last); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + logger.info("🎤【科大讯飞WebSocket STT】WebSocket连接已关闭: {} - {}", statusCode, reason); + heartbeatActive.set(false); // 停止心跳 + sink.complete(); + return WebSocket.Listener.super.onClose(webSocket, statusCode, reason); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + logger.error("🎤【科大讯飞WebSocket STT】WebSocket连接错误", error); + heartbeatActive.set(false); // 停止心跳 + sink.error(error); + WebSocket.Listener.super.onError(webSocket, error); + } + }).join(); + + // 订阅音频流 + audioStream.subscribe( + audioData -> { + try { + if (isConnected.get() && audioData != null && audioData.length > 0) { + // 构建音频数据帧 + Map frame = buildAudioFrame(audioData, config, status.get()); + String frameJson = objectMapper.writeValueAsString(frame); + + logger.debug("🎤【科大讯飞WebSocket STT】发送音频帧,状态: {}, 数据长度: {}", + status.get(), audioData.length); + + // 发送音频数据 + webSocket.sendText(frameJson, true); + + // 更新状态 + if (isFirstFrame.get()) { + isFirstFrame.set(false); + status.set(1); // 后续为中间帧 + } + } + } catch (Exception e) { + logger.error("🎤【科大讯飞WebSocket STT】发送音频数据失败", e); + sink.error(e); + } + }, + error -> { + logger.error("🎤【科大讯飞WebSocket STT】音频流错误", error); + sink.error(error); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Audio stream error"); + }, + () -> { + try { + // 发送结束帧 + Map endFrame = buildAudioFrame(new byte[0], config, 2); + String endFrameJson = objectMapper.writeValueAsString(endFrame); + + logger.info("🎤【科大讯飞WebSocket STT】发送结束帧"); + webSocket.sendText(endFrameJson, true); + + // 延迟关闭连接,等待最后的识别结果 + Thread.sleep(1000); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Audio stream completed"); + } catch (Exception e) { + logger.error("🎤【科大讯飞WebSocket STT】发送结束帧失败", e); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "End frame error"); + } + } + ); + + } catch (Exception e) { + logger.error("🎤【科大讯飞WebSocket STT】初始化WebSocket连接失败", e); + sink.error(e); + } + }); + } + + @Override + public Mono recognize(byte[] audioData, SttConfig config) { + // 将单次识别转换为流式识别 + return streamRecognize(Flux.just(audioData), config) + .reduce("", (acc, result) -> acc + result.getText()) + .map(finalText -> { + SttResult result = new SttResult(); + result.setText(finalText); + result.setConfidence(0.95); + result.setFinal(true); + + Map metadata = new HashMap<>(); + metadata.put("provider", "XunfeiWebSocketSTT"); + metadata.put("language", config.getLanguage()); + result.setMetadata(metadata); + + return result; + }); + } + + /** + * 构建WebSocket连接URL(带认证) + * 修正版本:严格按照科大讯飞WebSocket API文档进行认证 + */ + private String buildWebSocketUrl() throws Exception { + // 生成RFC1123格式的时间戳 + String date = ZonedDateTime.now(ZoneId.of("GMT")).format(DateTimeFormatter.RFC_1123_DATE_TIME); + + logger.debug("🔐 生成时间戳: {}", date); + + // 构建签名原文 - 严格按照文档格式 + String signatureOrigin = "host: " + host + "\n" + + "date: " + date + "\n" + + "GET " + path + " HTTP/1.1"; + + logger.debug("🔐 签名原文:\n{}", signatureOrigin); + + // 进行HMAC-SHA256加密 + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec spec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(spec); + byte[] hexDigits = mac.doFinal(signatureOrigin.getBytes(StandardCharsets.UTF_8)); + String signature = Base64.getEncoder().encodeToString(hexDigits); + + logger.debug("🔐 生成签名: {}", signature); + + // 构建authorization字符串 - 修正格式,移除多余的引号 + String authorization = String.format( + "api_key=\"%s\", algorithm=\"hmac-sha256\", headers=\"host date request-line\", signature=\"%s\"", + apiKey, signature); + + logger.debug("🔐 Authorization字符串: {}", authorization); + + // URL编码 + String encodedAuthorization = URLEncoder.encode(authorization, StandardCharsets.UTF_8); + String encodedDate = URLEncoder.encode(date, StandardCharsets.UTF_8); + String encodedHost = URLEncoder.encode(host, StandardCharsets.UTF_8); + + String wsUrl = String.format("wss://%s%s?authorization=%s&date=%s&host=%s", + host, path, encodedAuthorization, encodedDate, encodedHost); + + logger.debug("🔐 最终WebSocket URL长度: {}", wsUrl.length()); + + return wsUrl; + } + + /** + * 构建音频数据帧 + */ + private Map buildAudioFrame(byte[] audioData, SttConfig config, int status) { + Map frame = new HashMap<>(); + + // 通用参数 + Map common = new HashMap<>(); + common.put("app_id", appId); + frame.put("common", common); + + // 业务参数 (仅在第一帧发送) + if (status == 0) { + Map business = new HashMap<>(); + business.put("language", mapLanguage(config.getLanguage())); + business.put("domain", "iat"); // 通用识别 + business.put("accent", "mandarin"); // 普通话 + business.put("vad_eos", 3000); // 静音检测时长3秒(优化:从10秒减少到3秒,提高响应速度) + business.put("max_rg", 30000); // 最大录音时长30秒,防止无限录音 + business.put("nunum", 0); // 将返回结果数字格式化(0:数字,1:文字) + business.put("ptt", 1); // 开启标点符号添加 + business.put("rlang", "zh-cn"); // 返回语言类型 + business.put("vinfo", 1); // 是否返回语音信息 + business.put("speex_size", 30); // speex音频帧长度,用于VAD + business.put("dwa", "wpgs"); // 动态修正 + frame.put("business", business); + } + + // 数据参数 + Map data = new HashMap<>(); + data.put("status", status); // 0:第一帧, 1:中间帧, 2:最后一帧 + data.put("format", "audio/L16;rate=16000"); // 音频格式 + data.put("encoding", "raw"); + + if (audioData.length > 0) { + String base64Audio = Base64.getEncoder().encodeToString(audioData); + data.put("audio", base64Audio); + } + + frame.put("data", data); + + return frame; + } + + /** + * 映射语言代码 + */ + private String mapLanguage(String language) { + if (language == null) return "zh_cn"; + + switch (language.toLowerCase()) { + case "zh-cn": + case "zh_cn": + case "chinese": + return "zh_cn"; + case "en-us": + case "en_us": + case "english": + return "en_us"; + default: + return "zh_cn"; + } + } + + /** + * 解析WebSocket响应 + */ + private SttResult parseWebSocketResponse(JsonNode response, SttConfig config) { + try { + int code = response.path("code").asInt(); + if (code != 0) { + String message = response.path("message").asText(); + logger.error("🎤【科大讯飞WebSocket STT】API错误: {} - {}", code, message); + + SttResult errorResult = new SttResult(); + errorResult.setText("API错误: " + message); + errorResult.setConfidence(0.0); + errorResult.setFinal(true); + return errorResult; + } + + JsonNode data = response.path("data"); + if (data.isMissingNode()) { + return null; + } + + JsonNode result = data.path("result"); + if (result.isMissingNode()) { + return null; + } + + JsonNode ws = result.path("ws"); + if (ws.isMissingNode() || !ws.isArray()) { + return null; + } + + // 解析识别结果 + StringBuilder text = new StringBuilder(); + for (JsonNode wsItem : ws) { + JsonNode cw = wsItem.path("cw"); + if (cw.isArray()) { + for (JsonNode cwItem : cw) { + String word = cwItem.path("w").asText(); + if (StringUtils.hasText(word)) { + text.append(word); + } + } + } + } + + if (text.length() == 0) { + return null; + } + + SttResult sttResult = new SttResult(); + sttResult.setText(text.toString()); + sttResult.setConfidence(0.95); // 科大讯飞不直接提供置信度 + sttResult.setFinal(data.path("status").asInt() == 2); // 2表示最终结果 + + Map metadata = new HashMap<>(); + metadata.put("provider", "XunfeiWebSocketSTT"); + metadata.put("language", config.getLanguage()); + metadata.put("status", data.path("status").asInt()); + sttResult.setMetadata(metadata); + + return sttResult; + + } catch (Exception e) { + logger.error("🎤【科大讯飞WebSocket STT】解析响应失败", e); + SttResult errorResult = new SttResult(); + errorResult.setText("解析响应失败: " + e.getMessage()); + errorResult.setConfidence(0.0); + errorResult.setFinal(true); + return errorResult; + } + } + + /** + * 启动心跳检测机制 + */ + private void startHeartbeat(WebSocket webSocket, AtomicBoolean heartbeatActive) { + Thread heartbeatThread = new Thread(() -> { + try { + while (heartbeatActive.get() && !Thread.currentThread().isInterrupted()) { + Thread.sleep(30000); // 每30秒发送一次心跳 + + if (heartbeatActive.get() && webSocket.isOutputClosed() == false) { + // 发送心跳帧(空的音频帧) + try { + Map heartbeatFrame = new HashMap<>(); + Map common = new HashMap<>(); + common.put("app_id", appId); + heartbeatFrame.put("common", common); + + Map data = new HashMap<>(); + data.put("status", 1); // 中间帧 + data.put("format", "audio/L16;rate=16000"); + data.put("encoding", "raw"); + data.put("audio", ""); // 空音频作为心跳 + heartbeatFrame.put("data", data); + + String heartbeatJson = objectMapper.writeValueAsString(heartbeatFrame); + webSocket.sendText(heartbeatJson, true); + + logger.debug("🎤【科大讯飞WebSocket STT】发送心跳包"); + } catch (Exception e) { + logger.warn("🎤【科大讯飞WebSocket STT】心跳发送失败", e); + break; + } + } + } + } catch (InterruptedException e) { + logger.info("🎤【科大讯飞WebSocket STT】心跳线程被中断"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + logger.error("🎤【科大讯飞WebSocket STT】心跳线程异常", e); + } + }); + + heartbeatThread.setName("XunfeiSTT-Heartbeat"); + heartbeatThread.setDaemon(true); + heartbeatThread.start(); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/tts/TtsClient.java b/vocata-server/src/main/java/com/vocata/ai/tts/TtsClient.java new file mode 100644 index 0000000..0ec1124 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/tts/TtsClient.java @@ -0,0 +1,266 @@ +package com.vocata.ai.tts; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * TTS (Text to Speech) 服务客户端 + * 支持流式语音合成 + */ +public interface TtsClient { + + /** + * 获取服务提供商名称 + */ + String getProviderName(); + + /** + * 检查服务是否可用 + */ + boolean isAvailable(); + + /** + * 流式语音合成(包含文字和音频) + * 接收文本流并返回包含文字和音频的结果流 + * + * @param textStream 文本数据流 + * @param config 合成配置 + * @return TTS结果流(包含音频数据和对应文字) + */ + default Flux streamSynthesizeWithText(Flux textStream, TtsConfig config) { + // 默认实现:将原有的流式方法包装为TtsResult + return streamSynthesize(textStream, config) + .map(audioData -> { + TtsResult result = new TtsResult(); + result.setAudioData(audioData); + result.setAudioFormat(config.getAudioFormat()); + result.setSampleRate(config.getSampleRate()); + return result; + }); + } + + /** + * 流式语音合成 + * 接收文本流并返回音频流 + * + * @param textStream 文本数据流 + * @param config 合成配置 + * @return 音频数据流(二进制) + */ + Flux streamSynthesize(Flux textStream, TtsConfig config); + + /** + * 批量语音合成 + * 处理完整文本并返回完整音频 + * + * @param text 要合成的文本 + * @param config 合成配置 + * @return 合成结果(包含音频数据) + */ + Mono synthesize(String text, TtsConfig config); + + /** + * 获取支持的语音列表 + */ + String[] getSupportedVoices(); + + /** + * 估算文本的音频时长(秒) + */ + double estimateAudioDuration(String text); + + /** + * 语音合成结果 + */ + class TtsResult { + private byte[] audioData; // 音频数据 + private String audioFormat; // 音频格式 + private int sampleRate; // 采样率 + private double durationSeconds; // 音频时长(秒) + private String voiceId; // 使用的语音ID + private String correspondingText; // 对应的文字内容 + private Long startTime; // 音频开始时间戳(毫秒) + private Long endTime; // 音频结束时间戳(毫秒) + private Map metadata; // 额外元数据 + + public TtsResult() {} + + public TtsResult(byte[] audioData, String audioFormat, double durationSeconds) { + this.audioData = audioData; + this.audioFormat = audioFormat; + this.durationSeconds = durationSeconds; + } + + public TtsResult(byte[] audioData, String correspondingText, String audioFormat, double durationSeconds) { + this.audioData = audioData; + this.correspondingText = correspondingText; + this.audioFormat = audioFormat; + this.durationSeconds = durationSeconds; + } + + // Getters and Setters + public byte[] getAudioData() { + return audioData; + } + + public void setAudioData(byte[] audioData) { + this.audioData = audioData; + } + + public String getAudioFormat() { + return audioFormat; + } + + public void setAudioFormat(String audioFormat) { + this.audioFormat = audioFormat; + } + + public int getSampleRate() { + return sampleRate; + } + + public void setSampleRate(int sampleRate) { + this.sampleRate = sampleRate; + } + + public double getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(double durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getCorrespondingText() { + return correspondingText; + } + + public void setCorrespondingText(String correspondingText) { + this.correspondingText = correspondingText; + } + + public Long getStartTime() { + return startTime; + } + + public void setStartTime(Long startTime) { + this.startTime = startTime; + } + + public Long getEndTime() { + return endTime; + } + + public void setEndTime(Long endTime) { + this.endTime = endTime; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + } + + /** + * TTS配置 + */ + class TtsConfig { + private String voiceId; // 语音ID + private String language = "zh-CN"; // 语言 + private double speed = 1.0; // 语速 (0.5-2.0) + private double pitch = 1.0; // 音调 (0.5-2.0) + private double volume = 1.0; // 音量 (0.0-1.0) + private String audioFormat = "mp3"; // 音频格式 + private int sampleRate = 24000; // 采样率 + private boolean streaming = false; // 是否流式合成 + + public TtsConfig() {} + + public TtsConfig(String voiceId) { + this.voiceId = voiceId; + } + + public TtsConfig(String voiceId, String language) { + this.voiceId = voiceId; + this.language = language; + } + + // Getters and Setters + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public double getSpeed() { + return speed; + } + + public void setSpeed(double speed) { + this.speed = speed; + } + + public double getPitch() { + return pitch; + } + + public void setPitch(double pitch) { + this.pitch = pitch; + } + + public double getVolume() { + return volume; + } + + public void setVolume(double volume) { + this.volume = volume; + } + + public String getAudioFormat() { + return audioFormat; + } + + public void setAudioFormat(String audioFormat) { + this.audioFormat = audioFormat; + } + + public int getSampleRate() { + return sampleRate; + } + + public void setSampleRate(int sampleRate) { + this.sampleRate = sampleRate; + } + + public boolean isStreaming() { + return streaming; + } + + public void setStreaming(boolean streaming) { + this.streaming = streaming; + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/tts/impl/VolcanTtsClient.java b/vocata-server/src/main/java/com/vocata/ai/tts/impl/VolcanTtsClient.java new file mode 100644 index 0000000..8cc2da1 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/tts/impl/VolcanTtsClient.java @@ -0,0 +1,504 @@ +package com.vocata.ai.tts.impl; + +import com.vocata.ai.tts.TtsClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * 火山引擎语音合成服务实现 + */ +@Service +public class VolcanTtsClient implements TtsClient { + + private static final Logger logger = LoggerFactory.getLogger(VolcanTtsClient.class); + + @Value("${volcan.tts.access-key:}") + private String accessKey; + + @Value("${volcan.tts.secret-key:}") + private String secretKey; + + @Value("${volcan.tts.access-token:}") + private String accessToken; + + @Value("${volcan.tts.app-id:}") + private String appId; + + @Value("${volcan.tts.cluster:volcano_tts}") + private String cluster; + + @Value("${volcan.tts.user-id:default_user}") + private String userId; + + @Value("${volcan.tts.host:openspeech.bytedance.com}") + private String host; + + @Value("${volcan.tts.region:ap-beijing-1}") + private String region; + + @Value("${volcan.tts.service:tts}") + private String service; + + private final WebClient webClient; + + + private final ObjectMapper objectMapper = new ObjectMapper(); + // 支持的语音列表(基于2024年火山引擎最新音色) + private static final String[] SUPPORTED_VOICES = { + // 通用场景音色 + "BV001_streaming", // 通用女声 + "BV002_streaming", // 通用男声 + "BV034_streaming", // 清甜女声 + "BV033_streaming", // 温暖男声 + + // 多情感音色 + "BV700_streaming", // 小萝莉 + "BV701_streaming", // 温柔女声 + "BV702_streaming", // 清脆男声 + + // 角色扮演音色(2024年新增) + "BV158_streaming", // 奶气萌娃 + "BV159_streaming", // 病弱少女 + "BV160_streaming", // 傲娇霸总 + "BV161_streaming", // 温柔学姐 + + // 趣味口音 + "BV119_streaming", // 东北话 + "BV120_streaming", // 四川话 + "BV121_streaming", // 粤语 + + // 英文音色 + "en_female_bella_moon_bigtts", // 英文女声Bella + "en_male_adam_moon_bigtts", // 英文男声Adam + + // 经典音色(兼容) + "zh_female_tianmeixiaotian_moon_bigtts", // 天美小甜 + "zh_female_huanhuan_moon_bigtts", // 欢欢 + "zh_male_wennuan_moon_bigtts", // 温暖 + "zh_female_yangqi_moon_bigtts", // 阳气 + "zh_female_shuangkuai_moon_bigtts" // 爽快 + }; + + public VolcanTtsClient(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.build(); + } + + @Override + public String getProviderName() { + return "火山引擎TTS"; + } + + @Override + public boolean isAvailable() { + // 检查必需配置参数 + boolean hasBasicConfig = appId != null && !appId.isEmpty(); + + // 火山引擎TTS使用Bearer Token认证 + boolean hasTokenAuth = accessToken != null && !accessToken.isEmpty(); + + boolean isConfigured = hasBasicConfig && hasTokenAuth; + + if (!isConfigured) { + logger.warn("火山引擎TTS配置不完整 - appId:{}, hasTokenAuth:{}", + appId != null && !appId.isEmpty(), hasTokenAuth); + logger.warn("请在配置文件中设置: volcan.tts.app-id 和 volcan.tts.access-token"); + } + + return isConfigured; + } + + @Override + public String[] getSupportedVoices() { + return SUPPORTED_VOICES.clone(); + } + + @Override + public double estimateAudioDuration(String text) { + // 根据中文字符数和语速估算时长 + // 假设平均语速为每秒3个汉字 + int chineseChars = text.replaceAll("[^\\u4e00-\\u9fa5]", "").length(); + int otherChars = text.length() - chineseChars; + return (chineseChars / 3.0) + (otherChars / 10.0); + } + + @Override + public Flux streamSynthesize(Flux textStream, TtsConfig config) { + if (!isAvailable()) { + return Flux.error(new RuntimeException("火山引擎TTS服务配置不完整:需要app-id和access-token")); + } + + logger.info("开始火山引擎流式语音合成,语音: {}", config.getVoiceId()); + + return textStream + .buffer(100) // 将文本流缓存为批次 + .concatMap(textList -> { + String combinedText = String.join("", textList); + if (combinedText.trim().isEmpty()) { + return Flux.empty(); + } + return synthesize(combinedText, config) + .map(TtsResult::getAudioData) + .flux(); + }) + .onErrorResume(error -> { + logger.error("火山引擎TTS流式合成失败", error); + return Flux.empty(); + }); + } + + @Override + public Mono synthesize(String text, TtsConfig config) { + if (!isAvailable()) { + return Mono.error(new RuntimeException("火山引擎TTS服务配置不完整:需要app-id和access-token")); + } + + if (text == null || text.trim().isEmpty()) { + return Mono.error(new RuntimeException("合成文本不能为空")); + } + + logger.info("开始火山引擎语音合成,文本长度: {}, 语音: {}", text.length(), config.getVoiceId()); + + try { + return callVolcanTtsApi(text, config) + .map(response -> parseResponse(response, config)) + .onErrorResume(error -> { + logger.error("火山引擎TTS合成失败", error); + TtsResult errorResult = new TtsResult(); + errorResult.setAudioData(new byte[0]); + errorResult.setAudioFormat("error"); + errorResult.setDurationSeconds(0); + + Map metadata = new HashMap<>(); + metadata.put("provider", "VolcanTTS"); + metadata.put("error", error.getMessage()); + errorResult.setMetadata(metadata); + + return Mono.just(errorResult); + }); + + } catch (Exception e) { + return Mono.error(new RuntimeException("构建火山引擎TTS请求失败", e)); + } + } + + /** + * 调用火山引擎TTS API + */ + private Mono> callVolcanTtsApi(String text, TtsConfig config) { + try { + // 构建请求参数 + Map requestBody = buildRequestBody(text, config); + logger.debug("火山引擎TTS请求参数: {}", objectToJson(requestBody)); + + // 构建签名和请求头 + String timestamp = String.valueOf(Instant.now().getEpochSecond()); + Map headers = buildHeaders(requestBody, timestamp); + logger.debug("火山引擎TTS请求头: {}", headers); + + String url = String.format("https://%s/api/v1/tts", host); + + return webClient.post() + .uri(url) + .headers(httpHeaders -> { + headers.forEach(httpHeaders::add); + // 不设置Host头,让WebClient自动处理 + }) + .bodyValue(requestBody) + .retrieve() + .onStatus(status -> status.isError(), response -> { + return response.bodyToMono(String.class) + .map(errorBody -> { + logger.error("火山引擎TTS API错误响应: Status={}, Body={}", response.statusCode(), errorBody); + return new RuntimeException("火山引擎TTS API错误: " + response.statusCode() + ", " + errorBody); + }); + }) + .bodyToMono(new ParameterizedTypeReference>() {}) + .doOnNext(response -> logger.debug("火山引擎TTS API响应: {}", response)) + .doOnError(error -> logger.error("火山引擎TTS API调用失败: {}", error.getMessage())); + + } catch (Exception e) { + return Mono.error(new RuntimeException("构建火山引擎TTS API请求失败", e)); + } + } + + /** + * 构建请求体 - 基于2024年火山引擎TTS API格式 + */ + private Map buildRequestBody(String text, TtsConfig config) { + Map request = new HashMap<>(); + + // 应用信息 + Map appInfo = new HashMap<>(); + appInfo.put("appid", appId); + appInfo.put("cluster", cluster != null ? cluster : "volcano_tts"); + request.put("app", appInfo); + + // 用户信息 + Map user = new HashMap<>(); + user.put("uid", userId != null ? userId : "default_user"); + request.put("user", user); + + // 音频配置 + Map audio = new HashMap<>(); + String mappedVoiceId = getVolcanVoiceId(config.getVoiceId()); + audio.put("voice_type", mappedVoiceId); + audio.put("encoding", mapAudioFormat(config.getAudioFormat())); + audio.put("sample_rate", config.getSampleRate()); + audio.put("speed_ratio", config.getSpeed()); + audio.put("volume_ratio", config.getVolume()); + audio.put("pitch_ratio", config.getPitch()); + + logger.info("使用音色: {} -> {}", config.getVoiceId(), mappedVoiceId); + request.put("audio", audio); + + // 请求内容 + Map reqData = new HashMap<>(); + reqData.put("text", text); + reqData.put("text_type", "plain"); + reqData.put("operation", "query"); // 修改为query + // 添加必需的reqid字段 + reqData.put("reqid", java.util.UUID.randomUUID().toString()); + request.put("request", reqData); + + return request; + } + + /** + * 构建请求头(支持Bearer Token认证) + */ + private Map buildHeaders(Map body, String timestamp) throws Exception { + Map headers = new HashMap<>(); + + // 基本头信息 + headers.put("Content-Type", "application/json"); + // 移除固定的Resource-Id,让服务端自动识别 + + // 优先使用Access Token认证(推荐方式) + if (accessToken != null && !accessToken.isEmpty()) { + headers.put("Authorization", "Bearer; " + accessToken); + logger.debug("使用Bearer Token认证方式"); + } else { + throw new RuntimeException("缺少有效的认证信息:需要access-token"); + } + + return headers; + } + + /** + * 计算火山引擎签名 - 移除Host头避免WebClient限制 + */ + private String calculateSignature(Map body, String timestamp) throws Exception { + // 构建规范化请求 + String method = "POST"; + String uri = "/api/v1/tts"; + String query = ""; + + // 规范化头部(移除Host头,避免WebClient限制) + String canonicalHeaders = String.format("content-type:application/json\nx-date:%s\n", timestamp); + String signedHeaders = "content-type;x-date"; + + // 请求体哈希 + String bodyJson = objectToJson(body); + String hashedPayload = sha256Hex(bodyJson); + + // 构建规范化请求字符串 + String canonicalRequest = String.join("\n", method, uri, query, canonicalHeaders, signedHeaders, hashedPayload); + + // 构建签名字符串 + String credentialScope = String.format("%s/%s/%s/request", + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneId.of("UTC")).format(Instant.ofEpochSecond(Long.parseLong(timestamp))), + region, service); + String stringToSign = String.join("\n", "AWS4-HMAC-SHA256", timestamp, credentialScope, sha256Hex(canonicalRequest)); + + // 计算签名 + byte[] signingKey = getSignatureKey(secretKey, + DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneId.of("UTC")).format(Instant.ofEpochSecond(Long.parseLong(timestamp))), + region, service); + String signature = hmacSha256Hex(stringToSign, signingKey); + + return String.format("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, credentialScope, signedHeaders, signature); + } + + /** + * 映射语音ID - 支持最新的火山引擎音色 + */ + private String getVolcanVoiceId(String voiceId) { + if (voiceId == null || voiceId.isEmpty()) { + return "BV001_streaming"; // 默认通用女声 + } + + // 检查是否是支持的语音 + for (String supportedVoice : SUPPORTED_VOICES) { + if (supportedVoice.equals(voiceId)) { + return voiceId; + } + } + + // 如果输入的是老版本音色ID,映射到新版本 + switch (voiceId) { + case "tianmeixiaotian": + return "zh_female_tianmeixiaotian_moon_bigtts"; + case "huanhuan": + return "zh_female_huanhuan_moon_bigtts"; + case "wennuan": + return "zh_male_wennuan_moon_bigtts"; + case "yangqi": + return "zh_female_yangqi_moon_bigtts"; + case "shuangkuai": + return "zh_female_shuangkuai_moon_bigtts"; + case "voice-en-harry": + return "en_male_adam_moon_bigtts"; // 映射到英文男声 + default: + logger.warn("未识别的音色ID: {}, 使用默认音色", voiceId); + return "BV001_streaming"; // 默认通用女声 + } + } + + /** + * 映射音频格式 + */ + private String mapAudioFormat(String format) { + if (format == null) return "mp3"; + + switch (format.toLowerCase()) { + case "wav": + return "wav"; + case "mp3": + return "mp3"; + case "pcm": + return "pcm"; + default: + return "mp3"; + } + } + + /** + * 解析API响应 + */ + private TtsResult parseResponse(Map response, TtsConfig config) { + TtsResult result = new TtsResult(); + + try { + Integer code = (Integer) response.get("code"); + if (code != null && code == 0) { + // 成功响应 + @SuppressWarnings("unchecked") + Map data = (Map) response.get("data"); + if (data != null) { + String audioBase64 = (String) data.get("data"); + if (audioBase64 != null) { + byte[] audioData = java.util.Base64.getDecoder().decode(audioBase64); + result.setAudioData(audioData); + result.setAudioFormat(config.getAudioFormat()); + result.setSampleRate(config.getSampleRate()); + result.setVoiceId(config.getVoiceId()); + result.setDurationSeconds(estimateAudioDuration(audioData, config.getSampleRate())); + + Map metadata = new HashMap<>(); + metadata.put("provider", "VolcanTTS"); + metadata.put("voice_id", config.getVoiceId()); + metadata.put("language", config.getLanguage()); + result.setMetadata(metadata); + + logger.info("火山引擎TTS合成成功,音频大小: {} bytes", audioData.length); + } else { + throw new RuntimeException("API响应中没有音频数据"); + } + } else { + throw new RuntimeException("API响应数据为空"); + } + } else { + String message = (String) response.get("message"); + throw new RuntimeException("API错误: " + code + " - " + message); + } + } catch (Exception e) { + logger.error("解析火山引擎TTS响应失败", e); + result.setAudioData(new byte[0]); + result.setAudioFormat("error"); + result.setDurationSeconds(0); + } + + return result; + } + + /** + * 估算音频时长(基于音频数据大小) + */ + private double estimateAudioDuration(byte[] audioData, int sampleRate) { + // 简单估算:假设16位单声道 + return (double) audioData.length / (sampleRate * 2); + } + + // 工具方法 + private String objectToJson(Object obj) { + try { + // 使用Jackson进行正确的JSON序列化 + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + logger.error("JSON序列化失败", e); + return "{}"; + } + } + + private String sha256Hex(String data) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } + + private String hmacSha256Hex(String data, byte[] key) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } + + private byte[] hmacSha256(String data, byte[] key) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA256"); + mac.init(secretKeySpec); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } + + private byte[] getSignatureKey(String key, String dateStamp, String regionName, String serviceName) throws Exception { + byte[] kDate = hmacSha256(dateStamp, ("AWS4" + key).getBytes(StandardCharsets.UTF_8)); + byte[] kRegion = hmacSha256(regionName, kDate); + byte[] kService = hmacSha256(serviceName, kRegion); + return hmacSha256("aws4_request", kService); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/tts/impl/XunfeiStreamTtsClient.java b/vocata-server/src/main/java/com/vocata/ai/tts/impl/XunfeiStreamTtsClient.java new file mode 100644 index 0000000..39be9e3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/tts/impl/XunfeiStreamTtsClient.java @@ -0,0 +1,411 @@ +package com.vocata.ai.tts.impl; + +import com.vocata.ai.tts.TtsClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import javax.websocket.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +/** + * 科大讯飞TTS客户端 + * + */ +@Service("xunfeiTtsClient") +public class XunfeiStreamTtsClient implements TtsClient { + + private static final Logger logger = LoggerFactory.getLogger(XunfeiStreamTtsClient.class); + + @Value("${xunfei.tts.app-id:}") + private String appId; + + @Value("${xunfei.tts.api-key:}") + private String apiKey; + + @Value("${xunfei.tts.secret-key:}") + private String secretKey; + + @Value("${xunfei.tts.host:tts-api.xfyun.cn}") + private String host; + + @Value("${xunfei.tts.path:/v2/tts}") + private String path; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 支持的语音列表 + private static final String[] SUPPORTED_VOICES = { + "xiaoyan", // 小燕(女声,推荐) + "xiaoyu", // 小宇(男声) + "xiaoxue", // 小雪(女声) + "xiaofeng", // 小峰(男声) + "xiaoqian", // 小倩(女声) + "xiaolin", // 小琳(女声) + "xiaomeng", // 小萌(女声) + "xiaojing", // 小静(女声) + "xiaokun", // 小坤(男声) + "xiaoqiang", // 小强(男声) + "vixf", // 小峰(粤语) + "vixm", // 小美(粤语) + "catherine", // 英文女声 + "henry", // 英文男声 + "x4_xiaoyan", // 讯飞小燕(普通话) + "x4_yezi", // 讯飞叶子(普通话) + "aisjiuxu", // 讯飞许久(普通话) + "aisjinger", // 讯飞小静(普通话) + "aisbabyxu" // 讯飞许小宝(普通话) + }; + + @Override + public String getProviderName() { + return "科大讯飞TTS WebSocket API"; + } + + @Override + public boolean isAvailable() { + boolean hasConfig = appId != null && !appId.isEmpty() && + apiKey != null && !apiKey.isEmpty() && + secretKey != null && !secretKey.isEmpty(); + + if (!hasConfig) { + logger.warn("科大讯飞TTS配置不完整 - appId:{}, apiKey:{}, secretKey:{}", + appId != null && !appId.isEmpty(), + apiKey != null && !apiKey.isEmpty(), + secretKey != null && !secretKey.isEmpty()); + logger.warn("请在配置文件中设置: xunfei.tts.app-id, xunfei.tts.api-key, xunfei.tts.secret-key"); + } + + return hasConfig; + } + + @Override + public String[] getSupportedVoices() { + return SUPPORTED_VOICES.clone(); + } + + @Override + public double estimateAudioDuration(String text) { + // 根据中文字符数和语速估算时长 + // 假设平均语速为每秒3.5个汉字 + int chineseChars = text.replaceAll("[^\\u4e00-\\u9fa5]", "").length(); + int otherChars = text.length() - chineseChars; + return (chineseChars / 3.5) + (otherChars / 12.0); + } + + @Override + public Flux streamSynthesizeWithText(Flux textStream, TtsConfig config) { + if (!isAvailable()) { + return Flux.error(new RuntimeException("科大讯飞TTS服务配置不完整")); + } + + String actualVoiceId = getXunfeiVoiceId(config.getVoiceId()); + logger.info("开始科大讯飞流式语音合成(包含文字),输入音色: {} -> 实际使用: {}", + config.getVoiceId(), actualVoiceId); + + return textStream + .bufferTimeout(3, java.time.Duration.ofMillis(300)) + .concatMap(textList -> { + String combinedText = String.join("", textList); + if (combinedText.trim().isEmpty()) { + return Flux.empty(); + } + return synthesizeSingleTextWithResult(combinedText, config); + }) + .onErrorResume(error -> { + logger.error("科大讯飞TTS流式合成失败", error); + return Flux.empty(); + }); + } + + @Override + public Flux streamSynthesize(Flux textStream, TtsConfig config) { + return streamSynthesizeWithText(textStream, config) + .map(TtsResult::getAudioData); + } + + @Override + public Mono synthesize(String text, TtsConfig config) { + if (!isAvailable()) { + return Mono.error(new RuntimeException("科大讯飞TTS服务配置不完整")); + } + + if (text == null || text.trim().isEmpty()) { + return Mono.error(new RuntimeException("合成文本不能为空")); + } + + String actualVoiceId = getXunfeiVoiceId(config.getVoiceId()); + logger.info("开始科大讯飞WebSocket TTS合成,文本长度: {}, 输入音色: {} -> 实际使用: {}", + text.length(), config.getVoiceId(), actualVoiceId); + + return Mono.fromCallable(() -> { + try { + return callXunfeiTtsApi(text, actualVoiceId, config); + } catch (Exception e) { + logger.error("科大讯飞WebSocket TTS合成失败", e); + throw new RuntimeException("TTS合成失败: " + e.getMessage(), e); + } + }); + } + + /** + * 流式合成单个文本片段,同时返回文字和音频 + */ + private Flux synthesizeSingleTextWithResult(String text, TtsConfig config) { + logger.info("开始科大讯飞WebSocket流式TTS合成 - 文本: '{}', 长度: {} 字符", + text.length() > 30 ? text.substring(0, 30) + "..." : text, + text.length()); + + return Mono.fromCallable(() -> { + try { + String actualVoiceId = getXunfeiVoiceId(config.getVoiceId()); + TtsResult result = callXunfeiTtsApi(text, actualVoiceId, config); + result.setCorrespondingText(text); + result.setStartTime(System.currentTimeMillis()); + + logger.info("科大讯飞WebSocket流式TTS合成完成 - 文本: '{}', 音频大小: {} bytes", + text.length() > 30 ? text.substring(0, 30) + "..." : text, + result.getAudioData().length); + + return result; + } catch (Exception e) { + logger.error("科大讯飞WebSocket流式TTS合成失败: {}", e.getMessage(), e); + throw new RuntimeException("TTS合成失败: " + e.getMessage(), e); + } + }).flux(); + } + + /** + * 获取科大讯飞音色ID + */ + private String getXunfeiVoiceId(String voiceId) { + if (voiceId == null || voiceId.isEmpty()) { + return "x4_lingxiaoyu_emo"; // 默认小燕 + } + + // 检查是否是科大讯飞支持的音色 + for (String supportedVoice : SUPPORTED_VOICES) { + if (supportedVoice.equals(voiceId)) { + return voiceId; + } + } + + // 如果传入的音色ID不被支持,使用默认音色 + logger.warn("不支持的音色ID: {},使用默认音色: xiaoyan", voiceId); + return "x4_lingxiaoyu_emo"; + } + + /** + * 映射音频格式为科大讯飞API支持的格式 + */ + private String mapAudioFormat(String format) { + if (format == null) return "lame"; + + switch (format.toLowerCase()) { + case "wav": + return "raw"; + case "mp3": + return "lame"; + case "pcm": + return "raw"; + default: + return "lame"; + } + } + + /** + * 调用科大讯飞WebSocket TTS API + */ + private TtsResult callXunfeiTtsApi(String text, String voiceId, TtsConfig config) throws Exception { + String wsUrl = getWebSocketAuthUrl(); + logger.info("连接科大讯飞WebSocket TTS API: {}", wsUrl); + + ByteArrayOutputStream audioStream = new ByteArrayOutputStream(); + AtomicReference errorRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean isComplete = new AtomicBoolean(false); + + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + Session wsSession = container.connectToServer(new Endpoint() { + @Override + public void onOpen(Session session, EndpointConfig endpointConfig) { + logger.info("WebSocket连接已建立"); + session.addMessageHandler(new MessageHandler.Whole() { + @Override + public void onMessage(String message) { + try { + handleTtsResponse(message, audioStream, isComplete, latch); + } catch (Exception e) { + logger.error("处理TTS响应失败", e); + errorRef.set(e); + latch.countDown(); + } + } + }); + + try { + sendTtsRequest(session, text, voiceId, config); + } catch (Exception e) { + logger.error("发送TTS请求失败", e); + errorRef.set(e); + latch.countDown(); + } + } + + @Override + public void onError(Session session, Throwable throwable) { + logger.error("WebSocket连接错误", throwable); + errorRef.set(new Exception(throwable)); + latch.countDown(); + } + }, ClientEndpointConfig.Builder.create().build(), new URI(wsUrl)); + + boolean finished = latch.await(30, TimeUnit.SECONDS); + + if (wsSession.isOpen()) { + wsSession.close(); + } + + if (!finished) { + throw new RuntimeException("TTS合成超时"); + } + + if (errorRef.get() != null) { + throw errorRef.get(); + } + + byte[] audioData = audioStream.toByteArray(); + + if (audioData.length == 0) { + throw new RuntimeException("未收到音频数据"); + } + + TtsResult result = new TtsResult(); + result.setAudioData(audioData); + result.setAudioFormat(config.getAudioFormat()); + result.setSampleRate(config.getSampleRate()); + result.setVoiceId(voiceId); + result.setDurationSeconds(estimateAudioDuration(text)); + + Map metadata = new HashMap<>(); + metadata.put("provider", "XunfeiTTS-WebSocket-API"); + metadata.put("voice_id", voiceId); + metadata.put("language", config.getLanguage()); + metadata.put("audioSize", audioData.length); + result.setMetadata(metadata); + + logger.info("科大讯飞WebSocket TTS合成完成,音频大小: {} bytes", audioData.length); + return result; + } + + /** + * 处理TTS响应消息 + */ + private void handleTtsResponse(String message, ByteArrayOutputStream audioStream, + AtomicBoolean isComplete, CountDownLatch latch) throws Exception { + Map response = objectMapper.readValue(message, Map.class); + + Integer code = (Integer) response.get("code"); + if (code != null && code != 0) { + String errorMsg = (String) response.get("message"); + throw new RuntimeException("TTS API错误: " + code + " - " + errorMsg); + } + + Map data = (Map) response.get("data"); + if (data != null) { + String audioBase64 = (String) data.get("audio"); + if (audioBase64 != null && !audioBase64.isEmpty()) { + byte[] audioChunk = Base64.getDecoder().decode(audioBase64); + audioStream.write(audioChunk); + logger.debug("收到音频数据块: {} bytes", audioChunk.length); + } + + Integer status = (Integer) data.get("status"); + if (status != null && status == 2) { + logger.info("音频数据接收完成"); + isComplete.set(true); + latch.countDown(); + } + } + } + + /** + * 发送TTS请求 + */ + private void sendTtsRequest(Session session, String text, String voiceId, TtsConfig config) throws Exception { + Map request = new HashMap<>(); + + Map common = new HashMap<>(); + common.put("app_id", appId); + request.put("common", common); + + Map business = new HashMap<>(); + business.put("aue", mapAudioFormat(config.getAudioFormat())); + business.put("vcn", voiceId); + business.put("speed", (int)(config.getSpeed() * 50)); + business.put("volume", (int)(config.getVolume() * 100)); + business.put("pitch", (int)(config.getPitch() * 50)); + business.put("tte", "UTF8"); + request.put("business", business); + + Map data = new HashMap<>(); + data.put("status", 2); + data.put("text", Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8))); + request.put("data", data); + + String requestJson = objectMapper.writeValueAsString(request); + session.getBasicRemote().sendText(requestJson); + logger.info("已发送TTS请求,文本长度: {} 字符", text.length()); + } + + /** + * 生成WebSocket认证URL + */ + private String getWebSocketAuthUrl() throws Exception { + URL url = new URL("https://" + host + path); + SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("GMT")); + String date = format.format(new Date()); + + String signatureOrigin = "host: " + host + "\n" + + "date: " + date + "\n" + + "GET " + path + " HTTP/1.1"; + + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec spec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(spec); + byte[] hexDigits = mac.doFinal(signatureOrigin.getBytes(StandardCharsets.UTF_8)); + String signature = Base64.getEncoder().encodeToString(hexDigits); + + String authorization = String.format("api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", + apiKey, "hmac-sha256", "host date request-line", signature); + + String authBase64 = Base64.getEncoder().encodeToString(authorization.getBytes(StandardCharsets.UTF_8)); + + return String.format("wss://%s%s?authorization=%s&date=%s&host=%s", + host, path, + java.net.URLEncoder.encode(authBase64, "UTF-8"), + java.net.URLEncoder.encode(date, "UTF-8"), + host); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/ai/websocket/AiChatWebSocketHandler.java b/vocata-server/src/main/java/com/vocata/ai/websocket/AiChatWebSocketHandler.java new file mode 100644 index 0000000..4b5a48a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/ai/websocket/AiChatWebSocketHandler.java @@ -0,0 +1,613 @@ +package com.vocata.ai.websocket; + +import cn.dev33.satoken.stp.StpUtil; +import com.vocata.ai.service.AiStreamingService; +import com.vocata.conversation.service.ConversationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.*; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; +import reactor.core.publisher.Sinks; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * AI语音对话WebSocket处理器 + * 完整实现 STT -> LLM -> TTS 处理链路 + */ +@Component +public class AiChatWebSocketHandler extends BinaryWebSocketHandler { + + private static final Logger logger = LoggerFactory.getLogger(AiChatWebSocketHandler.class); + + @Autowired + private AiStreamingService aiStreamingService; + + @Autowired + private ConversationService conversationService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 存储每个会话的音频流 + private final Map> audioSinks = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + logger.info("AI语音WebSocket连接建立: {}", session.getId()); + + // 验证用户身份 + String authenticatedUserId = authenticateUser(session); + if (authenticatedUserId == null) { + logger.error("WebSocket连接验证失败,关闭连接: {}", session.getId()); + session.close(CloseStatus.NOT_ACCEPTABLE.withReason("身份验证失败")); + return; + } + + // 将认证的用户ID存储到session中 + session.getAttributes().put("authenticatedUserId", authenticatedUserId); + logger.info("WebSocket用户认证成功: {} - 用户ID: {}", session.getId(), authenticatedUserId); + + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "status", + "message", "WebSocket连接已建立", + "timestamp", System.currentTimeMillis() + )))); + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws IOException { + try { + if (message instanceof BinaryMessage) { + handleBinaryMessage(session, (BinaryMessage) message); + } else if (message instanceof TextMessage) { + handleTextMessage(session, (TextMessage) message); + } + } catch (IOException e) { + logger.error("处理WebSocket消息失败: {}", e.getMessage(), e); + try { + sendErrorMessage(session, "消息处理失败: " + e.getMessage()); + } catch (IOException ex) { + logger.error("发送错误消息失败", ex); + } + } + } + + @Override + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws IOException { + String sessionId = session.getId(); + byte[] audioData = message.getPayload().array(); + + logger.info("🎵 接收音频数据: {} bytes", audioData.length); + + // 将音频数据发送到对应的音频流 + Sinks.Many audioSink = audioSinks.get(sessionId); + if (audioSink != null) { + audioSink.tryEmitNext(audioData); + logger.info("🎵 音频数据已添加到流: {} bytes", audioData.length); + } else { + logger.warn("未找到会话的音频流: {}", sessionId); + } + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + try { + logger.debug("收到文本消息: {}", message.getPayload()); + @SuppressWarnings("unchecked") + Map data = objectMapper.readValue(message.getPayload(), Map.class); + String type = (String) data.get("type"); + String sessionId = session.getId(); + + if ("audio_start".equals(type) || "audio_end".equals(type) || "audio_cancel".equals(type) || "ping".equals(type)) { + logger.debug("收到控制指令: {}, 会话ID: {}", type, sessionId); + } else { + logger.info("解析消息类型: {}, 会话ID: {}", type, sessionId); + } + + switch (type) { + case "audio_start": + handleAudioStart(session); + break; + case "audio_end": + handleAudioEnd(session, data); + break; + case "audio_cancel": + handleAudioCancel(session); + break; + case "text_message": + handleTextInput(session, data); + break; + case "ping": + sendPongMessage(session); + break; + default: + logger.warn("未知消息类型: {}", type); + } + } catch (Exception e) { + logger.error("处理文本消息失败: {}", e.getMessage(), e); + try { + sendErrorMessage(session, "消息处理失败: " + e.getMessage()); + } catch (IOException ex) { + logger.error("发送错误消息失败", ex); + } + } + } + + private void handleAudioStart(WebSocketSession session) throws IOException { + String sessionId = session.getId(); + logger.info("开始音频录制: {}", sessionId); + + // 创建音频数据流 + Sinks.Many audioSink = Sinks.many().unicast().onBackpressureBuffer(); + audioSinks.put(sessionId, audioSink); + + sendStatusMessage(session, "开始接收音频数据"); + } + + private void handleAudioEnd(WebSocketSession session, Map data) throws IOException { + String sessionId = session.getId(); + logger.info("结束音频录制: {}", sessionId); + + Sinks.Many audioSink = audioSinks.remove(sessionId); + if (audioSink != null) { + audioSink.tryEmitComplete(); + + // 从URI中提取对话UUID + String uri = session.getUri().toString(); + String conversationUuid = extractConversationUuid(uri); + + // 使用认证的用户ID,不信任URL参数 + String authenticatedUserId = (String) session.getAttributes().get("authenticatedUserId"); + + if (conversationUuid != null && authenticatedUserId != null) { + logger.info("🎤【完整AI处理】音频录制结束,开始STT->LLM->TTS处理 - 会话: {}, 用户: {}", + conversationUuid, authenticatedUserId); + + // 完整AI处理链路: STT -> LLM -> TTS + aiStreamingService.processVoiceMessage(conversationUuid, authenticatedUserId, audioSink.asFlux()) + .subscribe( + response -> { + try { + String responseType = (String) response.get("type"); + + if ("stt_result".equals(responseType)) { + @SuppressWarnings("unchecked") + Map payload = (Map) response.get("payload"); + if (payload != null) { + sendSttResultFromPayload(session, payload); + } + } else if ("llm_chunk".equals(responseType)) { + @SuppressWarnings("unchecked") + Map payload = (Map) response.get("payload"); + if (payload != null) { + String text = (String) payload.get("text"); + Boolean isFinal = (Boolean) payload.get("is_final"); + sendLlmTextStream(session, text != null ? text : "", + isFinal != null && isFinal); + } + } else if ("audio_chunk".equals(responseType)) { + byte[] audioData = (byte[]) response.get("audio_data"); + if (audioData != null) { + sendTtsAudioStream(session, audioData); + } + } else if ("tts_result".equals(responseType)) { + @SuppressWarnings("unchecked") + Map ttsPayload = (Map) response.get("tts_result"); + if (ttsPayload != null) { + byte[] audioData = (byte[]) ttsPayload.get("audioData"); + String correspondingText = (String) ttsPayload.get("correspondingText"); + Object audioFormatObj = ttsPayload.get("audioFormat"); + String audioFormat = audioFormatObj instanceof String ? + (String) audioFormatObj : "mp3"; + Object sampleRateObj = ttsPayload.get("sampleRate"); + int sampleRate = sampleRateObj instanceof Number ? + ((Number) sampleRateObj).intValue() : 24000; + String voiceId = ttsPayload.get("voiceId") instanceof String ? + (String) ttsPayload.get("voiceId") : null; + + Map ttsResultMessage = new HashMap<>(); + ttsResultMessage.put("type", "tts_result"); + ttsResultMessage.put("text", correspondingText != null ? correspondingText : ""); + ttsResultMessage.put("format", audioFormat); + ttsResultMessage.put("sampleRate", sampleRate); + if (voiceId != null) { + ttsResultMessage.put("voiceId", voiceId); + } + ttsResultMessage.put("timestamp", System.currentTimeMillis()); + + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(ttsResultMessage))); + + if (audioData != null && audioData.length > 0) { + sendTtsAudioStream(session, audioData); + } else { + logger.warn("【TTS阶段】TTS结果缺少音频数据"); + } + } + } else if ("complete".equals(responseType)) { + sendStatusMessage(session, "语音处理完成"); + } + } catch (IOException e) { + logger.error("发送响应失败", e); + } + }, + error -> { + logger.error("处理语音消息失败", error); + try { + sendErrorMessage(session, "语音处理失败: " + error.getMessage()); + } catch (IOException e) { + logger.error("发送错误消息失败", e); + } + }, + () -> { + logger.info("语音消息处理完成: {}", sessionId); + try { + sendStatusMessage(session, "语音处理完成"); + } catch (IOException e) { + logger.error("发送完成消息失败", e); + } + } + ); + } else { + sendErrorMessage(session, "无效的请求URI"); + } + } else { + sendErrorMessage(session, "未找到音频流"); + } + } + + private void handleAudioCancel(WebSocketSession session) throws IOException { + String sessionId = session.getId(); + logger.info("取消音频录制: {}", sessionId); + + Sinks.Many audioSink = audioSinks.remove(sessionId); + if (audioSink != null) { + audioSink.tryEmitComplete(); + } + + if (session.isOpen()) { + sendStatusMessage(session, "录音已取消"); + } + } + + /** + * 发送STT识别结果(从payload中提取) + */ + private void sendSttResultFromPayload(WebSocketSession session, Map payload) { + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "stt_result", + "text", payload.getOrDefault("text", ""), + "isFinal", payload.getOrDefault("is_final", false), + "confidence", payload.getOrDefault("confidence", 0.0), + "timestamp", System.currentTimeMillis() + )))); + } catch (IOException e) { + logger.error("发送STT结果失败", e); + } + } + + /** + * 发送LLM文本流 - 确保流式响应 + */ + private void sendLlmTextStream(WebSocketSession session, String content, boolean isComplete) { + try { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "llm_text_stream", + "text", content, + "characterName", "AI助手", + "isComplete", isComplete, + "timestamp", System.currentTimeMillis() + )))); + } catch (IOException e) { + logger.error("发送LLM文本流失败", e); + } + } + + /** + * 发送TTS音频流 - 使用二进制分片传输避免64KB限制 + */ + private void sendTtsAudioStream(WebSocketSession session, byte[] audioData) { + try { + if (!session.isOpen()) { + logger.warn("会话已关闭,跳过发送TTS音频数据"); + return; + } + logger.info("【TTS输出】发送音频数据到前端 - 大小: {} bytes", audioData.length); + + // 先发送音频元数据(JSON格式) + Map audioMeta = Map.of( + "type", "tts_audio_meta", + "audioSize", audioData.length, + "format", "mp3", + "sampleRate", 24000, + "channels", 1, + "bitDepth", 16, + "timestamp", System.currentTimeMillis() + ); + String audioMetaJson = objectMapper.writeValueAsString(audioMeta); + logger.info("【TTS输出】发送音频元数据: {}", audioMetaJson); + session.sendMessage(new TextMessage(audioMetaJson)); + + // 检查音频数据大小,如果超过32KB则分片传输,避免客户端因单帧过大触发协议错误 + final int MAX_CHUNK_SIZE = 32 * 1024; // 32KB每片,更好的兼容性 + + if (audioData.length <= MAX_CHUNK_SIZE) { + // 小于50KB,直接发送二进制消息 + session.sendMessage(new BinaryMessage(audioData)); + logger.info("【TTS输出】音频数据一次性发送完成 - {} bytes", audioData.length); + } else { + // 大于50KB,分片发送 + int totalChunks = (int) Math.ceil((double) audioData.length / MAX_CHUNK_SIZE); + logger.info("【TTS输出】音频数据过大,分{}片发送 - 总大小: {} bytes", totalChunks, audioData.length); + + for (int i = 0; i < totalChunks; i++) { + int start = i * MAX_CHUNK_SIZE; + int end = Math.min(start + MAX_CHUNK_SIZE, audioData.length); + byte[] chunk = java.util.Arrays.copyOfRange(audioData, start, end); + + // 发送分片数据 + session.sendMessage(new BinaryMessage(chunk)); + logger.info("【TTS输出】发送音频分片 {}/{} - {} bytes", i + 1, totalChunks, chunk.length); + + // 分片间短暂延迟,避免网络拥塞 + Thread.sleep(10); + } + logger.info("【TTS输出】音频分片发送完成 - 共{}片", totalChunks); + } + + } catch (Exception e) { + logger.error("【TTS输出】发送TTS音频流失败", e); + } + } + + /** + * 处理文字输入消息 + * 用户可以发送文字消息,AI将返回双重响应(文字+语音) + */ + private void handleTextInput(WebSocketSession session, Map data) throws IOException { + @SuppressWarnings("unchecked") + Map messageData = (Map) data.get("data"); + + if (messageData == null) { + sendErrorMessage(session, "缺少data字段"); + return; + } + + String text = (String) messageData.get("message"); + if (text == null || text.trim().isEmpty()) { + sendErrorMessage(session, "文字内容不能为空"); + return; + } + + // 从URI中提取对话UUID + String uri = session.getUri().toString(); + String conversationUuidStr = extractConversationUuid(uri); + + if (conversationUuidStr == null) { + sendErrorMessage(session, "无效的请求URI,缺少对话UUID"); + return; + } + + // 使用认证的用户ID,不信任URL参数 + String authenticatedUserId = (String) session.getAttributes().get("authenticatedUserId"); + if (authenticatedUserId == null) { + sendErrorMessage(session, "用户身份验证失败"); + return; + } + + logger.info("【文字输入处理】开始处理 - 会话UUID: {}, 认证用户: {}, 文字内容: '{}'", + conversationUuidStr, authenticatedUserId, text); + + try { + // 完整AI模式: 文本消息 -> LLM -> TTS + aiStreamingService.processTextMessage(conversationUuidStr, authenticatedUserId, text) + .subscribe( + response -> { + try { + String responseType = (String) response.get("type"); + logger.info("【WebSocket响应】收到响应类型: {}", responseType); + + if ("text_chunk".equals(responseType)) { + @SuppressWarnings("unchecked") + Map payload = (Map) response.get("payload"); + if (payload != null) { + String responseText = (String) payload.get("text"); + Boolean isFinal = (Boolean) payload.get("is_final"); + + logger.info("【LLM阶段】流式文本响应 - 内容: '{}', 是否完整: {}", + responseText, isFinal); + + // 发送流式文本响应 + sendLlmTextStream(session, responseText != null ? responseText : "", + isFinal != null && isFinal); + } + + } else if ("audio_chunk".equals(responseType)) { + byte[] audioData = (byte[]) response.get("audio_data"); + if (audioData != null) { + logger.info("【TTS阶段】收到音频块,大小: {} bytes", audioData.length); + + // 发送语音响应 + sendTtsAudioStream(session, audioData); + } + + } else if ("audio_complete".equals(responseType)) { + logger.info("【TTS阶段】音频合成完成"); + // 可以发送音频完成标志 + sendStatusMessage(session, "音频合成完成"); + + } else if ("complete".equals(responseType)) { + logger.info("【处理完成】文字消息处理链路完成"); + sendStatusMessage(session, "处理完成"); + + } else if ("error".equals(responseType)) { + String errorMessage = (String) response.get("error"); + if (errorMessage == null) { + errorMessage = (String) response.get("message"); + } + logger.error("【处理错误】: {}", errorMessage); + sendErrorMessage(session, errorMessage != null ? errorMessage : "处理失败"); + } + + } catch (Exception e) { + logger.error("【响应处理错误】: {}", e.getMessage(), e); + try { + sendErrorMessage(session, "响应处理失败"); + } catch (IOException ex) { + logger.error("发送错误消息失败", ex); + } + } + }, + error -> { + logger.error("【文字消息处理失败】: {}", error.getMessage(), error); + try { + sendErrorMessage(session, "文字消息处理失败: " + error.getMessage()); + } catch (IOException ex) { + logger.error("发送错误消息失败", ex); + } + } + ); + + } catch (Exception e) { + logger.error("【参数错误】UUID或用户ID格式错误: conversationUuid={}, userId={}", + conversationUuidStr, authenticatedUserId); + sendErrorMessage(session, "参数格式错误"); + } + } + + private void sendStatusMessage(WebSocketSession session, String message) throws IOException { + if (!session.isOpen()) { + logger.warn("会话已关闭,无法发送状态消息: {}", message); + return; + } + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "status", + "message", message, + "timestamp", System.currentTimeMillis() + )))); + } + + private void sendErrorMessage(WebSocketSession session, String error) throws IOException { + if (!session.isOpen()) { + logger.warn("会话已关闭,无法发送错误消息: {}", error); + return; + } + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "error", + "error", error, + "timestamp", System.currentTimeMillis() + )))); + } + + private void sendPongMessage(WebSocketSession session) throws IOException { + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(Map.of( + "type", "pong", + "timestamp", System.currentTimeMillis() + )))); + } + + /** + * 验证WebSocket用户身份 + */ + private String authenticateUser(WebSocketSession session) { + try { + // 尝试从URL参数中获取token + String uri = session.getUri().toString(); + logger.info("【认证调试】WebSocket URI: {}", uri); + String token = null; + + if (uri.contains("token=")) { + String query = uri.split("\\?")[1]; + logger.info("【认证调试】查询参数: {}", query); + String[] params = query.split("&"); + for (String param : params) { + logger.info("【认证调试】处理参数: {}", param); + if (param.startsWith("token=")) { + token = param.substring("token=".length()); + // URL解码token + token = java.net.URLDecoder.decode(token, "UTF-8"); + logger.info("【认证调试】从URL参数提取token: {}...", + token.substring(0, Math.min(token.length(), 20))); + break; + } + } + } + + // 如果URL参数中没有token,尝试从handshake headers中获取 + if (token == null) { + logger.info("【认证调试】URL参数中未找到token,尝试从headers获取"); + token = session.getHandshakeHeaders().getFirst("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + logger.info("【认证调试】从Authorization header提取token: {}...", + token.substring(0, Math.min(token.length(), 20))); + } + } + + if (token == null) { + logger.error("【认证失败】WebSocket连接缺少认证token"); + return null; + } + + // 使用Sa-Token验证token + logger.info("【认证调试】开始验证token..."); + Object loginId = StpUtil.getLoginIdByToken(token); + if (loginId == null) { + logger.error("【认证失败】无效的WebSocket认证token: {}...", + token.substring(0, Math.min(token.length(), 20))); + return null; + } + + logger.info("【认证成功】用户ID: {}", loginId); + return loginId.toString(); + } catch (Exception e) { + logger.error("【认证异常】WebSocket用户认证异常", e); + return null; + } + } + + private String extractConversationUuid(String uri) { + // 从URI中提取对话标识符: /ws/chat/{conversation_uuid}?userId=1 + try { + String path = uri.split("\\?")[0]; // 去掉查询参数 + String[] parts = path.split("/"); + if (parts.length >= 3 && "chat".equals(parts[parts.length - 2])) { + return parts[parts.length - 1]; // conversation_uuid + } + } catch (Exception e) { + logger.error("提取对话UUID失败: {}", uri, e); + } + return null; + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws IOException { + String sessionId = session.getId(); + logger.info("AI语音WebSocket连接关闭: {}, 状态: {}", sessionId, status); + + // 清理资源 + Sinks.Many audioSink = audioSinks.remove(sessionId); + if (audioSink != null) { + audioSink.tryEmitComplete(); + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws IOException { + logger.error("WebSocket传输错误: {}", session.getId(), exception); + + // 清理资源 + String sessionId = session.getId(); + Sinks.Many audioSink = audioSinks.remove(sessionId); + if (audioSink != null) { + audioSink.tryEmitComplete(); + } + } +} diff --git a/vocata-server/src/main/java/com/vocata/auth/constants/AuthConstants.java b/vocata-server/src/main/java/com/vocata/auth/constants/AuthConstants.java new file mode 100644 index 0000000..4ea8505 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/constants/AuthConstants.java @@ -0,0 +1,64 @@ +package com.vocata.auth.constants; + +/** + * 认证模块常量类 + * + * @author levon + */ +public class AuthConstants { + + // 验证码相关常量 + public static final String EMAIL_CODE_KEY_PREFIX = "email_code:"; + public static final String EMAIL_CODE_LIMIT_KEY_PREFIX = "email_code_limit:"; + public static final String RESET_PASSWORD_KEY_PREFIX = "reset_password:"; + public static final String LOGIN_FAIL_COUNT_KEY_PREFIX = "login_fail:"; + + // 验证码有效期(分钟) + public static final int EMAIL_CODE_EXPIRE_MINUTES = 5; + + // 验证码长度 + public static final int EMAIL_CODE_LENGTH = 6; + + // 登录失败限制 + public static final int MAX_LOGIN_FAIL_COUNT = 5; + public static final int LOGIN_LOCK_MINUTES = 30; + public static final int ACCOUNT_LOCK_MINUTES = 30; + + // 验证码发送限制 + public static final int EMAIL_CODE_LIMIT_COUNT = 5; + public static final int EMAIL_CODE_LIMIT_MINUTES = 60; + + // 密码重置令牌有效期(分钟) + public static final int RESET_PASSWORD_EXPIRE_MINUTES = 30; + + // 邮件模板类型 + public static final String EMAIL_TEMPLATE_REGISTER = "register"; + public static final String EMAIL_TEMPLATE_LOGIN = "login"; + public static final String EMAIL_TEMPLATE_RESET_PASSWORD = "reset_password"; + public static final String EMAIL_TEMPLATE_CHANGE_EMAIL = "change_email"; + + // 验证码类型 (Integer类型) + public static final int VERIFICATION_CODE_TYPE_REGISTER = 1; + public static final int VERIFICATION_CODE_TYPE_LOGIN = 2; + public static final int VERIFICATION_CODE_TYPE_RESET_PASSWORD = 3; + public static final int VERIFICATION_CODE_TYPE_CHANGE_EMAIL = 4; + + // 密码要求 + public static final int PASSWORD_MIN_LENGTH = 6; + public static final int PASSWORD_MAX_LENGTH = 20; + + // 用户状态 + public static final Integer USER_STATUS_NORMAL = 1; + public static final Integer USER_STATUS_DISABLED = 0; + public static final Integer USER_STATUS_LOCKED = 2; + + // 性别 + public static final Integer GENDER_UNSET = 0; + public static final Integer GENDER_MALE = 1; + public static final Integer GENDER_FEMALE = 2; + + // 私有构造函数防止实例化 + private AuthConstants() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/vocata-server/src/main/java/com/vocata/auth/controller/AuthController.java b/vocata-server/src/main/java/com/vocata/auth/controller/AuthController.java new file mode 100644 index 0000000..d0de095 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/controller/AuthController.java @@ -0,0 +1,104 @@ +package com.vocata.auth.controller; + +import com.vocata.auth.dto.LoginRequest; +import com.vocata.auth.dto.LoginResponse; +import com.vocata.auth.service.AuthService; +import com.vocata.common.result.ApiResponse; +import com.vocata.user.dto.UserRegisterRequest; +import com.vocata.user.dto.UserResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** + * 认证控制器 + */ +@RestController +@RequestMapping("/api/client/auth") +@Validated +public class AuthController { + + @Autowired + private AuthService authService; + + /** + * 用户注册 + */ + @PostMapping("/register") + public ApiResponse register(@Valid @RequestBody UserRegisterRequest request) { + UserResponse response = authService.register(request); + return ApiResponse.success("用户注册成功", response); + } + + /** + * 用户登录 + */ + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request); + return ApiResponse.success("登录成功", response); + } + + /** + * 用户登出 + */ + @PostMapping("/logout") + public ApiResponse logout() { + authService.logout(); + return ApiResponse.success("登出成功"); + } + + /** + * 刷新Token + */ + @PostMapping("/refresh-token") + public ApiResponse refreshToken(@RequestBody String refreshToken) { + LoginResponse response = authService.refreshToken(refreshToken); + return ApiResponse.success("Token刷新成功", response); + } + + /** + * 发送注册验证码 + */ + @PostMapping("/send-register-code") + public ApiResponse sendRegisterCode(@RequestParam @NotBlank @Email String email) { + authService.sendRegisterCode(email); + return ApiResponse.success("验证码发送成功"); + } + + /** + * 发送重置密码验证码 + */ + @PostMapping("/send-reset-code") + public ApiResponse sendResetPasswordCode(@RequestParam @NotBlank @Email String email) { + authService.sendResetPasswordCode(email); + return ApiResponse.success("重置密码验证码发送成功"); + } + + /** + * 重置密码 + */ + @PostMapping("/reset-password") + public ApiResponse resetPassword( + @RequestParam @NotBlank @Email String email, + @RequestParam @NotBlank String newPassword, + @RequestParam @NotBlank String verificationCode) { + authService.resetPassword(email, newPassword, verificationCode); + return ApiResponse.success("密码重置成功"); + } + + /** + * 修改密码 + */ + @PostMapping("/change-password") + public ApiResponse changePassword( + @RequestParam @NotBlank String oldPassword, + @RequestParam @NotBlank String newPassword) { + authService.changePassword(oldPassword, newPassword); + return ApiResponse.success("密码修改成功"); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/dto/LoginRequest.java b/vocata-server/src/main/java/com/vocata/auth/dto/LoginRequest.java new file mode 100644 index 0000000..1aab13e --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/dto/LoginRequest.java @@ -0,0 +1,51 @@ +package com.vocata.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 登录请求 + */ +public class LoginRequest { + + @NotBlank(message = "登录名不能为空") + private String loginName; + + @NotBlank(message = "密码不能为空") + private String password; + + private String verificationCode; + + private Boolean rememberMe; + + public String getLoginName() { + return loginName; + } + + public void setLoginName(String loginName) { + this.loginName = loginName; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getVerificationCode() { + return verificationCode; + } + + public void setVerificationCode(String verificationCode) { + this.verificationCode = verificationCode; + } + + public Boolean getRememberMe() { + return rememberMe; + } + + public void setRememberMe(Boolean rememberMe) { + this.rememberMe = rememberMe; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/dto/LoginResponse.java b/vocata-server/src/main/java/com/vocata/auth/dto/LoginResponse.java new file mode 100644 index 0000000..e7022a4 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/dto/LoginResponse.java @@ -0,0 +1,49 @@ +package com.vocata.auth.dto; + +import com.vocata.user.dto.UserResponse; + +/** + * 登录响应 + */ +public class LoginResponse { + + private String token; + + private String refreshToken; + + private UserResponse user; + + private Long expiresIn; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public UserResponse getUser() { + return user; + } + + public void setUser(UserResponse user) { + this.user = user; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/entity/LoginLog.java b/vocata-server/src/main/java/com/vocata/auth/entity/LoginLog.java new file mode 100644 index 0000000..f7b9b4c --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/entity/LoginLog.java @@ -0,0 +1,166 @@ +package com.vocata.auth.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.vocata.common.entity.BaseEntity; + +import java.time.LocalDateTime; + +/** + * 登录日志实体 + */ +@TableName("vocata_login_log") +public class LoginLog extends BaseEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long userId; + + private String username; + + private String email; + + private Integer loginType; + + private Integer loginResult; + + private String clientIp; + + private String userAgent; + + private String deviceId; + + private String deviceInfo; + + private String loginLocation; + + private LocalDateTime loginTime; + + private LocalDateTime logoutTime; + + private String failReason; + + private String tokenId; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Integer getLoginType() { + return loginType; + } + + public void setLoginType(Integer loginType) { + this.loginType = loginType; + } + + public Integer getLoginResult() { + return loginResult; + } + + public void setLoginResult(Integer loginResult) { + this.loginResult = loginResult; + } + + public String getClientIp() { + return clientIp; + } + + public void setClientIp(String clientIp) { + this.clientIp = clientIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getDeviceInfo() { + return deviceInfo; + } + + public void setDeviceInfo(String deviceInfo) { + this.deviceInfo = deviceInfo; + } + + public String getLoginLocation() { + return loginLocation; + } + + public void setLoginLocation(String loginLocation) { + this.loginLocation = loginLocation; + } + + public LocalDateTime getLoginTime() { + return loginTime; + } + + public void setLoginTime(LocalDateTime loginTime) { + this.loginTime = loginTime; + } + + public LocalDateTime getLogoutTime() { + return logoutTime; + } + + public void setLogoutTime(LocalDateTime logoutTime) { + this.logoutTime = logoutTime; + } + + public String getFailReason() { + return failReason; + } + + public void setFailReason(String failReason) { + this.failReason = failReason; + } + + public String getTokenId() { + return tokenId; + } + + public void setTokenId(String tokenId) { + this.tokenId = tokenId; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/service/AuthService.java b/vocata-server/src/main/java/com/vocata/auth/service/AuthService.java new file mode 100644 index 0000000..6955e20 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/service/AuthService.java @@ -0,0 +1,52 @@ +package com.vocata.auth.service; + +import com.vocata.auth.dto.LoginRequest; +import com.vocata.auth.dto.LoginResponse; +import com.vocata.user.dto.UserRegisterRequest; +import com.vocata.user.dto.UserResponse; + +/** + * 认证服务接口 + */ +public interface AuthService { + + /** + * 用户注册 + */ + UserResponse register(UserRegisterRequest request); + + /** + * 用户登录(用户名/邮箱 + 密码) + */ + LoginResponse login(LoginRequest request); + + /** + * 用户登出 + */ + void logout(); + + /** + * 刷新Token + */ + LoginResponse refreshToken(String refreshToken); + + /** + * 重置密码 + */ + void resetPassword(String email, String newPassword, String verificationCode); + + /** + * 修改密码 + */ + void changePassword(String oldPassword, String newPassword); + + /** + * 发送注册验证码 + */ + void sendRegisterCode(String email); + + /** + * 发送重置密码验证码 + */ + void sendResetPasswordCode(String email); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/service/EmailService.java b/vocata-server/src/main/java/com/vocata/auth/service/EmailService.java new file mode 100644 index 0000000..f25e161 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/service/EmailService.java @@ -0,0 +1,42 @@ +package com.vocata.auth.service; + +/** + * 邮件服务接口 + */ +public interface EmailService { + + /** + * 发送验证码邮件 + */ + void sendVerificationCode(String to, String code, String type); + + /** + * 发送注册验证码 + */ + void sendRegisterVerificationCode(String to, String code); + + /** + * 发送登录验证码 + */ + void sendLoginVerificationCode(String to, String code); + + /** + * 发送重置密码验证码 + */ + void sendResetPasswordCode(String to, String code); + + /** + * 发送修改邮箱验证码 + */ + void sendChangeEmailCode(String to, String code); + + /** + * 发送普通邮件 + */ + void sendSimpleMail(String to, String subject, String content); + + /** + * 发送HTML邮件 + */ + void sendHtmlMail(String to, String subject, String content); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/service/VerificationCodeService.java b/vocata-server/src/main/java/com/vocata/auth/service/VerificationCodeService.java new file mode 100644 index 0000000..4b4f5c3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/service/VerificationCodeService.java @@ -0,0 +1,52 @@ +package com.vocata.auth.service; + +/** + * 验证码服务接口 + */ +public interface VerificationCodeService { + + /** + * 发送注册验证码 + */ + void sendRegisterCode(String email); + + /** + * 发送登录验证码 + */ + void sendLoginCode(String email); + + /** + * 发送重置密码验证码 + */ + void sendResetPasswordCode(String email); + + /** + * 发送修改邮箱验证码 + */ + void sendChangeEmailCode(String email); + + /** + * 验证验证码 + */ + boolean verifyCode(String email, String code, Integer type); + + /** + * 验证并使用验证码 + */ + boolean verifyAndUseCode(String email, String code, Integer type); + + /** + * 仅使用验证码(删除已验证的验证码) + */ + void useCode(String email, Integer type); + + /** + * 生成验证码 + */ + String generateCode(); + + /** + * 检查验证码发送限制 + */ + boolean checkSendLimit(String email); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/service/impl/AuthServiceImpl.java b/vocata-server/src/main/java/com/vocata/auth/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..d168c37 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,501 @@ +package com.vocata.auth.service.impl; + +import cn.dev33.satoken.stp.StpUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.vocata.auth.constants.AuthConstants; +import com.vocata.auth.dto.LoginRequest; +import com.vocata.auth.dto.LoginResponse; +import com.vocata.auth.service.AuthService; +import com.vocata.auth.service.VerificationCodeService; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.utils.IpUtils; +import com.vocata.common.utils.PasswordEncoder; +import com.vocata.user.dto.UserRegisterRequest; +import com.vocata.user.dto.UserResponse; +import com.vocata.user.entity.User; +import com.vocata.user.mapper.UserMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.Random; + +/** + * 认证服务实现 + */ +@Service +public class AuthServiceImpl implements AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthServiceImpl.class); + + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$" + ); + + @Autowired + private UserMapper userMapper; + + @Autowired + private VerificationCodeService verificationCodeService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private HttpServletRequest request; + + @Autowired + private RedisTemplate redisTemplate; + + @Override + @Transactional + public UserResponse register(UserRegisterRequest registerRequest) { + // 1. 验证参数 + validateRegisterRequest(registerRequest); + + // 2. 先验证验证码 + if (!verificationCodeService.verifyCode( + registerRequest.getEmail(), + registerRequest.getVerificationCode(), + AuthConstants.VERIFICATION_CODE_TYPE_REGISTER)) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "验证码错误或已过期"); + } + + // 3. 获取邮箱注册锁,防止并发注册 + String registrationLock = acquireRegistrationLock(registerRequest.getEmail()); + try { + // 4. 再次检查邮箱是否已存在(防止并发创建) + checkUserExists(registerRequest.getEmail()); + + // 5. 创建用户 + User user = createUser(registerRequest); + userMapper.insert(user); + + // 6. 用户创建成功后,删除验证码 + verificationCodeService.useCode(registerRequest.getEmail(), + AuthConstants.VERIFICATION_CODE_TYPE_REGISTER); + + // 7. 返回用户信息 + UserResponse response = new UserResponse(); + BeanUtils.copyProperties(user, response); + response.setId(user.getId().toString()); + + log.info("用户注册成功,用户ID:{},用户名:{},邮箱:{}", user.getId(), user.getUsername(), user.getEmail()); + return response; + } finally { + // 8. 释放注册锁 + releaseRegistrationLock(registrationLock); + } + } + + @Override + public LoginResponse login(LoginRequest loginRequest) { + // 1. 查找用户 + User user = findUserByLoginName(loginRequest.getLoginName()); + if (user == null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户名或密码错误"); + } + + // 2. 验证密码 + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + // 增加登录失败次数 + incrementLoginFailedCount(user.getId()); + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户名或密码错误"); + } + + // 3. 检查用户状态 + checkUserStatus(user); + + // 4. 重置登录失败次数 + resetLoginFailedCount(user.getId()); + + // 5. 更新最后登录信息 + String clientIp = IpUtils.getClientIp(request); + updateLastLoginInfo(user.getId(), clientIp); + + // 6. 生成Token + StpUtil.login(user.getId()); + + // 7. 设置Session信息 + setUserSession(user); + + // 8. 构建响应 + LoginResponse response = new LoginResponse(); + response.setToken(StpUtil.getTokenValue()); + response.setExpiresIn(StpUtil.getTokenTimeout()); + + UserResponse userResponse = new UserResponse(); + BeanUtils.copyProperties(user, userResponse); + userResponse.setId(user.getId().toString()); + response.setUser(userResponse); + + log.info("用户登录成功,用户ID:{},用户名:{},IP:{}", user.getId(), user.getUsername(), clientIp); + return response; + } + + @Override + public void logout() { + if (StpUtil.isLogin()) { + Long userId = StpUtil.getLoginIdAsLong(); + StpUtil.logout(); + log.info("用户登出成功,用户ID:{}", userId); + } + } + + @Override + public LoginResponse refreshToken(String refreshToken) { + // Sa-Token会自动处理Token刷新 + if (!StpUtil.isLogin()) { + throw new BizException(ApiCode.UNAUTHORIZED.getCode(), "请重新登录"); + } + + Long userId = StpUtil.getLoginIdAsLong(); + User user = getUserById(userId); + if (user == null) { + throw new BizException(ApiCode.UNAUTHORIZED.getCode(), "用户不存在"); + } + + // 检查用户状态 + checkUserStatus(user); + + // 刷新Token + StpUtil.renewTimeout(StpUtil.getTokenTimeout()); + + LoginResponse response = new LoginResponse(); + response.setToken(StpUtil.getTokenValue()); + response.setExpiresIn(StpUtil.getTokenTimeout()); + + UserResponse userResponse = new UserResponse(); + BeanUtils.copyProperties(user, userResponse); + userResponse.setId(user.getId().toString()); + response.setUser(userResponse); + + return response; + } + + @Override + @Transactional + public void resetPassword(String email, String newPassword, String verificationCode) { + // 1. 验证验证码 + if (!verificationCodeService.verifyAndUseCode( + email, verificationCode, AuthConstants.VERIFICATION_CODE_TYPE_RESET_PASSWORD)) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "验证码错误或已过期"); + } + + // 2. 查找用户 + User user = getUserByEmail(email); + if (user == null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "用户不存在"); + } + + // 3. 更新密码 + String encodedPassword = passwordEncoder.encode(newPassword); + updatePassword(user.getId(), encodedPassword); + + // 4. 强制登出所有设备 + StpUtil.kickout(user.getId()); + + log.info("密码重置成功,用户ID:{},邮箱:{}", user.getId(), email); + } + + @Override + @Transactional + public void changePassword(String oldPassword, String newPassword) { + Long userId = StpUtil.getLoginIdAsLong(); + User user = getUserById(userId); + + if (user == null) { + throw new BizException(ApiCode.UNAUTHORIZED.getCode(), "用户不存在"); + } + + // 验证旧密码 + if (!passwordEncoder.matches(oldPassword, user.getPassword())) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "原密码错误"); + } + + // 更新密码 + String encodedPassword = passwordEncoder.encode(newPassword); + updatePassword(userId, encodedPassword); + + log.info("密码修改成功,用户ID:{}", userId); + } + + @Override + public void sendRegisterCode(String email) { + // 检查邮箱是否已被注册 + if (getUserByEmail(email) != null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "邮箱已被注册"); + } + + verificationCodeService.sendRegisterCode(email); + } + + @Override + public void sendResetPasswordCode(String email) { + // 检查用户是否存在 + if (getUserByEmail(email) == null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "邮箱未注册"); + } + + verificationCodeService.sendResetPasswordCode(email); + } + + /** + * 验证注册请求参数 + */ + private void validateRegisterRequest(UserRegisterRequest request) { + // 验证密码确认 + if (!request.getPassword().equals(request.getConfirmPassword())) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "两次输入的密码不一致"); + } + + // 验证邮箱格式 + if (!EMAIL_PATTERN.matcher(request.getEmail()).matches()) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "邮箱格式不正确"); + } + + // 验证密码强度 + if (request.getPassword().length() < AuthConstants.PASSWORD_MIN_LENGTH) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), + "密码长度不能少于" + AuthConstants.PASSWORD_MIN_LENGTH + "位"); + } + } + + /** + * 检查用户是否已存在 + */ + private void checkUserExists(String email) { + if (getUserByEmail(email) != null) { + throw new BizException(ApiCode.BAD_REQUEST.getCode(), "邮箱已被注册"); + } + } + + /** + * 生成唯一的用户名 + */ + private String generateUniqueUsername() { + String username; + Random random = new Random(); + int maxAttempts = 10; + int attempts = 0; + + do { + // 生成8位随机数字 + String randomNumber = String.format("%08d", random.nextInt(100000000)); + username = "vocata-" + randomNumber; + attempts++; + + if (attempts > maxAttempts) { + // 如果尝试次数过多,使用时间戳确保唯一性 + username = "vocata-" + System.currentTimeMillis(); + break; + } + } while (getUserByUsername(username) != null); + + return username; + } + + /** + * 创建用户 + */ + private User createUser(UserRegisterRequest request) { + User user = new User(); + user.setUsername(generateUniqueUsername()); + user.setEmail(request.getEmail()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setNickname(StringUtils.hasText(request.getNickname()) ? + request.getNickname() : "VocaTa用户"); + user.setGender(request.getGender() != null ? request.getGender() : AuthConstants.GENDER_UNSET); + user.setStatus(AuthConstants.USER_STATUS_NORMAL); + user.setIsAdmin(false); + user.setLoginFailCount(0); + + return user; + } + + /** + * 根据登录名查找用户 + */ + private User findUserByLoginName(String loginName) { + // 判断是邮箱还是用户名 + if (EMAIL_PATTERN.matcher(loginName).matches()) { + return getUserByEmail(loginName); + } else { + return getUserByUsername(loginName); + } + } + + /** + * 检查用户状态 + */ + private void checkUserStatus(User user) { + if (user.getStatus() == AuthConstants.USER_STATUS_DISABLED) { + throw new BizException(ApiCode.FORBIDDEN.getCode(), "账号已被禁用"); + } + + if (user.getStatus() == AuthConstants.USER_STATUS_LOCKED) { + // 检查锁定时间是否已过期 + if (user.getLockTime() != null && + user.getLockTime().plusMinutes(AuthConstants.ACCOUNT_LOCK_MINUTES).isAfter(LocalDateTime.now())) { + throw new BizException(ApiCode.FORBIDDEN.getCode(), "账号已被锁定,请稍后再试"); + } else { + // 锁定时间已过,自动解锁 + unlockUserAccount(user.getId()); + } + } + } + + /** + * 设置用户Session信息 + */ + private void setUserSession(User user) { + StpUtil.getSession().set("username", user.getUsername()); + StpUtil.getSession().set("email", user.getEmail()); + StpUtil.getSession().set("isAdmin", user.getIsAdmin()); + StpUtil.getSession().set("loginTime", System.currentTimeMillis()); + StpUtil.getSession().set("loginIp", IpUtils.getClientIp(request)); + } + + // ============ 用户数据访问辅助方法 ============ + + /** + * 根据邮箱查找用户 + */ + private User getUserByEmail(String email) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getEmail, email); + return userMapper.selectOne(wrapper); + } + + /** + * 根据用户名查找用户 + */ + private User getUserByUsername(String username) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getUsername, username); + return userMapper.selectOne(wrapper); + } + + /** + * 根据ID查找用户 + */ + private User getUserById(Long userId) { + return userMapper.selectById(userId); + } + + /** + * 更新最后登录信息 + */ + private void updateLastLoginInfo(Long userId, String clientIp) { + User user = new User(); + user.setId(userId); + user.setLastLoginTime(LocalDateTime.now()); + user.setLastLoginIp(clientIp); + // 手动设置审计字段,因为此时用户还未完全登录,UserContext可能为空 + user.setUpdateId(userId); + user.setUpdateDate(LocalDateTime.now()); + userMapper.updateById(user); + } + + /** + * 增加登录失败次数 + */ + private void incrementLoginFailedCount(Long userId) { + User user = getUserById(userId); + if (user != null) { + user.setLoginFailCount(user.getLoginFailCount() + 1); + // 手动设置审计字段,因为此时用户还未完全登录,UserContext可能为空 + user.setUpdateId(userId); + user.setUpdateDate(LocalDateTime.now()); + userMapper.updateById(user); + } + } + + /** + * 重置登录失败次数 + */ + private void resetLoginFailedCount(Long userId) { + User user = new User(); + user.setId(userId); + user.setLoginFailCount(0); + // 手动设置审计字段,因为此时用户还未完全登录,UserContext为空 + user.setUpdateId(userId); + user.setUpdateDate(LocalDateTime.now()); + userMapper.updateById(user); + } + + /** + * 更新密码 + */ + private void updatePassword(Long userId, String encodedPassword) { + User user = new User(); + user.setId(userId); + user.setPassword(encodedPassword); + // 手动设置审计字段 + user.setUpdateId(userId); + user.setUpdateDate(LocalDateTime.now()); + userMapper.updateById(user); + } + + /** + * 解锁用户账户 + */ + private void unlockUserAccount(Long userId) { + User user = new User(); + user.setId(userId); + user.setStatus(AuthConstants.USER_STATUS_NORMAL); + user.setLoginFailCount(0); + user.setLockTime(null); + // 手动设置审计字段 + user.setUpdateId(userId); + user.setUpdateDate(LocalDateTime.now()); + userMapper.updateById(user); + } + + // ============ 注册锁相关方法 ============ + + private static final String REGISTRATION_LOCK_PREFIX = "auth:register_lock:"; + private static final int REGISTRATION_LOCK_SECONDS = 30; // 注册锁30秒超时 + + /** + * 获取邮箱注册锁 + */ + private String acquireRegistrationLock(String email) { + String lockKey = REGISTRATION_LOCK_PREFIX + email; + String lockValue = String.valueOf(System.currentTimeMillis()); + + // 尝试获取锁,30秒超时 + Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, + REGISTRATION_LOCK_SECONDS, TimeUnit.SECONDS); + + if (!acquired) { + throw new BizException(ApiCode.TOO_MANY_REQUESTS.getCode(), + "该邮箱正在注册中,请稍后再试"); + } + + log.debug("获取注册锁成功,邮箱:{},锁值:{}", email, lockValue); + return lockKey; + } + + /** + * 释放邮箱注册锁 + */ + private void releaseRegistrationLock(String lockKey) { + try { + redisTemplate.delete(lockKey); + log.debug("释放注册锁成功,锁键:{}", lockKey); + } catch (Exception e) { + log.warn("释放注册锁失败,锁键:{}", lockKey, e); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/service/impl/EmailServiceImpl.java b/vocata-server/src/main/java/com/vocata/auth/service/impl/EmailServiceImpl.java new file mode 100644 index 0000000..daeb8ec --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/service/impl/EmailServiceImpl.java @@ -0,0 +1,187 @@ +package com.vocata.auth.service.impl; + +import com.vocata.auth.service.EmailService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +/** + * 邮件服务实现 + */ +@Service +public class EmailServiceImpl implements EmailService { + + private static final Logger log = LoggerFactory.getLogger(EmailServiceImpl.class); + + @Autowired + private JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String from; + + @Override + public void sendVerificationCode(String to, String code, String type) { + String subject = getSubjectByType(type); + String content = buildVerificationCodeContent(code, type); + sendHtmlMail(to, subject, content); + } + + @Override + public void sendRegisterVerificationCode(String to, String code) { + sendVerificationCode(to, code, "register"); + } + + @Override + public void sendLoginVerificationCode(String to, String code) { + sendVerificationCode(to, code, "login"); + } + + @Override + public void sendResetPasswordCode(String to, String code) { + sendVerificationCode(to, code, "reset"); + } + + @Override + public void sendChangeEmailCode(String to, String code) { + sendVerificationCode(to, code, "change"); + } + + @Override + public void sendSimpleMail(String to, String subject, String content) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(content); + + mailSender.send(message); + log.info("普通邮件发送成功,收件人:{},主题:{}", to, subject); + } catch (Exception e) { + log.error("普通邮件发送失败,收件人:{},主题:{}", to, subject, e); + throw new RuntimeException("邮件发送失败", e); + } + } + + @Override + public void sendHtmlMail(String to, String subject, String content) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); + + mailSender.send(message); + log.info("HTML邮件发送成功,收件人:{},主题:{}", to, subject); + } catch (MessagingException e) { + log.error("HTML邮件发送失败,收件人:{},主题:{}", to, subject, e); + throw new RuntimeException("邮件发送失败", e); + } + } + + /** + * 根据类型获取邮件主题 + */ + private String getSubjectByType(String type) { + switch (type) { + case "register": + return "【VocaTa】注册验证码"; + case "login": + return "【VocaTa】登录验证码"; + case "reset": + return "【VocaTa】重置密码验证码"; + case "change": + return "【VocaTa】修改邮箱验证码"; + default: + return "【VocaTa】验证码"; + } + } + + /** + * 构建验证码邮件内容 + */ + private String buildVerificationCodeContent(String code, String type) { + String operation = getOperationByType(type); + + return String.format(""" + + + + + VocaTa验证码 + + + +
+
+

🎭 VocaTa AI角色平台

+
+
+

您好!

+

您正在进行%s操作,请使用以下验证码完成验证:

+ +
+
%s
+
请在5分钟内使用此验证码
+
+ +
+

安全提示:

+
    +
  • 验证码5分钟内有效,请及时使用
  • +
  • 请勿将验证码告诉他人
  • +
  • 如果您没有进行此操作,请忽略此邮件
  • +
+
+ +

⚠️ 此邮件为系统自动发送,请勿回复

+
+ +
+ + + """, operation, code); + } + + /** + * 根据类型获取操作名称 + */ + private String getOperationByType(String type) { + switch (type) { + case "register": + return "账号注册"; + case "login": + return "账号登录"; + case "reset": + return "重置密码"; + case "change": + return "修改邮箱"; + default: + return "身份验证"; + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/auth/service/impl/VerificationCodeServiceImpl.java b/vocata-server/src/main/java/com/vocata/auth/service/impl/VerificationCodeServiceImpl.java new file mode 100644 index 0000000..f3ec0dc --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/auth/service/impl/VerificationCodeServiceImpl.java @@ -0,0 +1,356 @@ +package com.vocata.auth.service.impl; + +import com.vocata.auth.constants.AuthConstants; +import com.vocata.auth.service.EmailService; +import com.vocata.auth.service.VerificationCodeService; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.utils.IpUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.TimeUnit; + +/** + * 验证码服务实现 - 基于Redis缓存 + */ +@Service +public class VerificationCodeServiceImpl implements VerificationCodeService { + + private static final Logger log = LoggerFactory.getLogger(VerificationCodeServiceImpl.class); + + // Redis Key 前缀 + private static final String CODE_KEY_PREFIX = "auth:code:"; + private static final String SEND_LIMIT_KEY_PREFIX = "auth:send_limit:"; + private static final String IP_LIMIT_KEY_PREFIX = "auth:ip_limit:"; + private static final String EMAIL_DAILY_LIMIT_KEY_PREFIX = "auth:daily_limit:"; + private static final String ATTEMPT_KEY_PREFIX = "auth:attempt:"; + + // 限流配置 + private static final int CODE_EXPIRE_MINUTES = 5; // 验证码过期时间(分钟) + private static final int SEND_INTERVAL_SECONDS = 60; // 发送间隔(秒) + private static final int MAX_ATTEMPTS = 3; // 最大尝试次数 + private static final int EMAIL_DAILY_LIMIT = 10; // 邮箱每日限制 + private static final int IP_HOURLY_LIMIT = 20; // IP每小时限制 + private static final int CODE_LENGTH = 6; // 验证码长度 + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private EmailService emailService; + + @Autowired + private HttpServletRequest request; + + private final SecureRandom random = new SecureRandom(); + + @Override + public void sendRegisterCode(String email) { + sendCode(email, AuthConstants.VERIFICATION_CODE_TYPE_REGISTER); + } + + @Override + public void sendLoginCode(String email) { + sendCode(email, AuthConstants.VERIFICATION_CODE_TYPE_LOGIN); + } + + @Override + public void sendResetPasswordCode(String email) { + sendCode(email, AuthConstants.VERIFICATION_CODE_TYPE_RESET_PASSWORD); + } + + @Override + public void sendChangeEmailCode(String email) { + sendCode(email, AuthConstants.VERIFICATION_CODE_TYPE_CHANGE_EMAIL); + } + + @Override + public boolean verifyCode(String email, String code, Integer type) { + String cacheKey = buildCodeKey(email, type); + String storedCode = redisTemplate.opsForValue().get(cacheKey); + + if (storedCode == null) { + log.warn("验证码不存在或已过期,邮箱:{},类型:{}", email, type); + return false; + } + + // 增加尝试次数 + incrementAttemptCount(email, type); + + boolean isValid = storedCode.equals(code); + if (isValid) { + log.info("验证码验证成功,邮箱:{},类型:{}", email, type); + } else { + log.warn("验证码错误,邮箱:{},类型:{},输入:{}", email, type, code); + } + + return isValid; + } + + @Override + public boolean verifyAndUseCode(String email, String code, Integer type) { + if (!verifyCode(email, code, type)) { + return false; + } + + // 验证成功后立即删除验证码 + useCode(email, type); + + log.info("验证码验证并使用成功,邮箱:{},类型:{}", email, type); + return true; + } + + @Override + public void useCode(String email, Integer type) { + String cacheKey = buildCodeKey(email, type); + redisTemplate.delete(cacheKey); + + // 清除尝试次数 + String attemptKey = buildAttemptKey(email, type); + redisTemplate.delete(attemptKey); + + log.info("验证码使用成功,邮箱:{},类型:{}", email, type); + } + + @Override + public String generateCode() { + StringBuilder code = new StringBuilder(); + for (int i = 0; i < CODE_LENGTH; i++) { + code.append(random.nextInt(10)); + } + return code.toString(); + } + + @Override + public boolean checkSendLimit(String email) { + String sendLimitKey = buildSendLimitKey(email); + String lastSendTime = redisTemplate.opsForValue().get(sendLimitKey); + return lastSendTime == null; + } + + /** + * 发送验证码 - 核心方法 + */ + private void sendCode(String email, Integer type) { + String clientIp = IpUtils.getClientIp(request); + + // 1. 多重限流检查 + checkAllLimits(email, clientIp); + + // 2. 生成验证码 + String code = generateCode(); + + // 3. 存储到Redis + storeCodeToRedis(email, type, code); + + // 4. 发送邮件 + sendEmailByType(email, code, type); + + // 5. 更新限流计数 + updateLimitCounters(email, clientIp); + + log.info("验证码发送成功,邮箱:{},类型:{},IP:{},验证码:{}", + email, type, clientIp, code); + } + + /** + * 多重限流检查 + */ + private void checkAllLimits(String email, String clientIp) { + // 1. 检查发送间隔 + checkSendInterval(email); + + // 2. 检查邮箱每日限制 + checkEmailDailyLimit(email); + + // 3. 检查IP每小时限制 + checkIpHourlyLimit(clientIp); + + // 4. 检查尝试次数限制 + checkAttemptLimit(email); + } + + /** + * 检查发送间隔 + */ + private void checkSendInterval(String email) { + String sendLimitKey = buildSendLimitKey(email); + String lastSendTime = redisTemplate.opsForValue().get(sendLimitKey); + + if (lastSendTime != null) { + long remainingSeconds = redisTemplate.getExpire(sendLimitKey, TimeUnit.SECONDS); + throw new BizException(ApiCode.TOO_MANY_REQUESTS.getCode(), + String.format("验证码发送过于频繁,请%d秒后再试", remainingSeconds)); + } + } + + /** + * 检查邮箱每日限制 + */ + private void checkEmailDailyLimit(String email) { + String dailyLimitKey = buildEmailDailyLimitKey(email); + String countStr = redisTemplate.opsForValue().get(dailyLimitKey); + int count = countStr != null ? Integer.parseInt(countStr) : 0; + + if (count >= EMAIL_DAILY_LIMIT) { + throw new BizException(ApiCode.TOO_MANY_REQUESTS.getCode(), + "今日验证码发送次数已达上限,请明天再试"); + } + } + + /** + * 检查IP每小时限制 + */ + private void checkIpHourlyLimit(String clientIp) { + String ipLimitKey = buildIpLimitKey(clientIp); + String countStr = redisTemplate.opsForValue().get(ipLimitKey); + int count = countStr != null ? Integer.parseInt(countStr) : 0; + + if (count >= IP_HOURLY_LIMIT) { + throw new BizException(ApiCode.TOO_MANY_REQUESTS.getCode(), + "当前IP验证码请求过于频繁,请稍后再试"); + } + } + + /** + * 检查尝试次数限制 + */ + private void checkAttemptLimit(String email) { + // 检查是否有过多的验证码类型在尝试 + int[] types = { + AuthConstants.VERIFICATION_CODE_TYPE_REGISTER, + AuthConstants.VERIFICATION_CODE_TYPE_LOGIN, + AuthConstants.VERIFICATION_CODE_TYPE_RESET_PASSWORD, + AuthConstants.VERIFICATION_CODE_TYPE_CHANGE_EMAIL + }; + + for (int type : types) { + String attemptKey = buildAttemptKey(email, type); + String attemptCountStr = redisTemplate.opsForValue().get(attemptKey); + int attemptCount = attemptCountStr != null ? Integer.parseInt(attemptCountStr) : 0; + + if (attemptCount >= MAX_ATTEMPTS) { + long remainingSeconds = redisTemplate.getExpire(attemptKey, TimeUnit.SECONDS); + throw new BizException(ApiCode.TOO_MANY_REQUESTS.getCode(), + String.format("验证次数过多,请%d分钟后再试", remainingSeconds / 60 + 1)); + } + } + } + + /** + * 存储验证码到Redis + */ + private void storeCodeToRedis(String email, Integer type, String code) { + String cacheKey = buildCodeKey(email, type); + redisTemplate.opsForValue().set(cacheKey, code, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES); + + log.debug("验证码已存储到Redis,key:{},过期时间:{}分钟", cacheKey, CODE_EXPIRE_MINUTES); + } + + /** + * 更新限流计数器 + */ + private void updateLimitCounters(String email, String clientIp) { + // 更新发送间隔计数 + String sendLimitKey = buildSendLimitKey(email); + redisTemplate.opsForValue().set(sendLimitKey, "1", SEND_INTERVAL_SECONDS, TimeUnit.SECONDS); + + // 更新邮箱每日计数 + String dailyLimitKey = buildEmailDailyLimitKey(email); + redisTemplate.opsForValue().increment(dailyLimitKey); + redisTemplate.expire(dailyLimitKey, 24, TimeUnit.HOURS); + + // 更新IP每小时计数 + String ipLimitKey = buildIpLimitKey(clientIp); + redisTemplate.opsForValue().increment(ipLimitKey); + redisTemplate.expire(ipLimitKey, 1, TimeUnit.HOURS); + } + + /** + * 增加尝试次数 + */ + private void incrementAttemptCount(String email, Integer type) { + String attemptKey = buildAttemptKey(email, type); + redisTemplate.opsForValue().increment(attemptKey); + redisTemplate.expire(attemptKey, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES); + } + + /** + * 根据类型发送邮件 + */ + private void sendEmailByType(String email, String code, Integer type) { + try { + switch (type) { + case AuthConstants.VERIFICATION_CODE_TYPE_REGISTER: + emailService.sendRegisterVerificationCode(email, code); + break; + case AuthConstants.VERIFICATION_CODE_TYPE_LOGIN: + emailService.sendLoginVerificationCode(email, code); + break; + case AuthConstants.VERIFICATION_CODE_TYPE_RESET_PASSWORD: + emailService.sendResetPasswordCode(email, code); + break; + case AuthConstants.VERIFICATION_CODE_TYPE_CHANGE_EMAIL: + emailService.sendChangeEmailCode(email, code); + break; + default: + emailService.sendVerificationCode(email, code, "其他"); + break; + } + } catch (Exception e) { + log.error("发送验证码邮件失败,邮箱:{},类型:{}", email, type, e); + // 邮件发送失败时,删除已存储的验证码 + String cacheKey = buildCodeKey(email, type); + redisTemplate.delete(cacheKey); + throw new BizException(ApiCode.INTERNAL_SERVER_ERROR.getCode(), "邮件发送失败,请稍后重试"); + } + } + + // ============ Redis Key 构建方法 ============ + + private String buildCodeKey(String email, Integer type) { + return CODE_KEY_PREFIX + email + ":" + type; + } + + private String buildSendLimitKey(String email) { + return SEND_LIMIT_KEY_PREFIX + email; + } + + private String buildEmailDailyLimitKey(String email) { + String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + return EMAIL_DAILY_LIMIT_KEY_PREFIX + email + ":" + today; + } + + private String buildIpLimitKey(String clientIp) { + String currentHour = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHH")); + return IP_LIMIT_KEY_PREFIX + clientIp + ":" + currentHour; + } + + private String buildAttemptKey(String email, Integer type) { + return ATTEMPT_KEY_PREFIX + email + ":" + type; + } + + /** + * 获取验证码剩余有效时间(秒) + */ + public Long getCodeRemainingTime(String email, Integer type) { + String cacheKey = buildCodeKey(email, type); + return redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS); + } + + /** + * 获取发送间隔剩余时间(秒) + */ + public Long getSendRemainingTime(String email) { + String sendLimitKey = buildSendLimitKey(email); + return redisTemplate.getExpire(sendLimitKey, TimeUnit.SECONDS); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/constants/CharacterConstants.java b/vocata-server/src/main/java/com/vocata/character/constants/CharacterConstants.java new file mode 100644 index 0000000..1ea0396 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/constants/CharacterConstants.java @@ -0,0 +1,127 @@ +package com.vocata.character.constants; + +import java.math.BigDecimal; + +/** + * 角色管理常量类 + */ +public class CharacterConstants { + + /** + * 默认配置值 + */ + public static final int DEFAULT_CONTEXT_WINDOW = 10; + public static final BigDecimal DEFAULT_TEMPERATURE = new BigDecimal("0.7"); + public static final String DEFAULT_LANGUAGE = "zh-CN"; + public static final int DEFAULT_SORT_WEIGHT = 0; + + /** + * 角色编码正则表达式 + */ + public static final String CHARACTER_CODE_PATTERN = "^[a-zA-Z0-9_-]{3,50}$"; + + /** + * 字段长度限制 + */ + public static final int MAX_NAME_LENGTH = 100; + public static final int MAX_DESCRIPTION_LENGTH = 500; + public static final int MAX_GREETING_LENGTH = 1000; + public static final int MAX_PERSONA_LENGTH = 5000; + public static final int MAX_SPEAKING_STYLE_LENGTH = 1000; + public static final int MAX_SEARCH_KEYWORDS_LENGTH = 500; + + /** + * JSON字段长度限制 + */ + public static final int MAX_PERSONALITY_TRAITS_LENGTH = 2000; + public static final int MAX_TAGS_LENGTH = 1000; + public static final int MAX_TAG_WEIGHTS_LENGTH = 2000; + public static final int MAX_EXAMPLE_DIALOGUES_LENGTH = 10000; + + /** + * 参数范围 + */ + public static final BigDecimal MIN_TEMPERATURE = new BigDecimal("0.0"); + public static final BigDecimal MAX_TEMPERATURE = new BigDecimal("2.0"); + public static final int MIN_CONTEXT_WINDOW = 1; + public static final int MAX_CONTEXT_WINDOW = 100; + + /** + * 热门角色相关 + */ + public static final int DEFAULT_TRENDING_LIMIT = 10; + public static final int MAX_TRENDING_LIMIT = 50; + public static final int DEFAULT_FEATURED_LIMIT = 10; + public static final int MAX_FEATURED_LIMIT = 50; + + /** + * 布尔值常量(数据库存储) + */ + public static final int BOOLEAN_FALSE = 0; + public static final int BOOLEAN_TRUE = 1; + + /** + * 支持的语言列表 + */ + public static final String[] SUPPORTED_LANGUAGES = { + "zh-CN", // 简体中文 + "en-US", // 英语 + "ja-JP", // 日语 + "ko-KR" // 韩语 + }; + + /** + * 默认分页参数 + */ + public static final int DEFAULT_PAGE_SIZE = 20; + public static final int MAX_PAGE_SIZE = 100; + + /** + * 批量操作限制 + */ + public static final int MAX_BATCH_SIZE = 100; + + private CharacterConstants() { + // 工具类,禁止实例化 + } + + /** + * 检查语言是否受支持 + */ + public static boolean isSupportedLanguage(String language) { + if (language == null) { + return false; + } + for (String supportedLang : SUPPORTED_LANGUAGES) { + if (supportedLang.equals(language)) { + return true; + } + } + return false; + } + + /** + * 检查温度参数是否有效 + */ + public static boolean isValidTemperature(BigDecimal temperature) { + return temperature != null && + temperature.compareTo(MIN_TEMPERATURE) >= 0 && + temperature.compareTo(MAX_TEMPERATURE) <= 0; + } + + /** + * 检查上下文窗口是否有效 + */ + public static boolean isValidContextWindow(Integer contextWindow) { + return contextWindow != null && + contextWindow >= MIN_CONTEXT_WINDOW && + contextWindow <= MAX_CONTEXT_WINDOW; + } + + /** + * 检查角色编码格式是否有效 + */ + public static boolean isValidCharacterCode(String characterCode) { + return characterCode != null && characterCode.matches(CHARACTER_CODE_PATTERN); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/constants/ChatCountCacheConstants.java b/vocata-server/src/main/java/com/vocata/character/constants/ChatCountCacheConstants.java new file mode 100644 index 0000000..5805eae --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/constants/ChatCountCacheConstants.java @@ -0,0 +1,83 @@ +package com.vocata.character.constants; + +/** + * 角色聊天计数缓存常量配置 + */ +public class ChatCountCacheConstants { + + /** + * Redis键前缀 + */ + public static final String CHAT_COUNT_PREFIX = "vocata:character:chat_count:"; + public static final String CHAT_COUNT_TODAY_PREFIX = "vocata:character:chat_count_today:"; + public static final String CHAT_COUNT_LOCK_PREFIX = "vocata:character:chat_count_lock:"; + public static final String CHAT_COUNT_NULL_PREFIX = "vocata:character:chat_count_null:"; + + /** + * 缓存过期时间(秒) + */ + public static final long TOTAL_EXPIRE_SECONDS = 86400L; // 24小时 + public static final long TODAY_EXPIRE_SECONDS = 3600L; // 1小时 + public static final long LOCK_EXPIRE_SECONDS = 10L; // 10秒 + public static final long NULL_CACHE_EXPIRE_SECONDS = 300L; // 5分钟 + public static final long RANDOM_EXPIRE_RANGE = 1800L; // 30分钟随机范围 + + /** + * 开发环境配置 + */ + public static class Development { + public static final long TOTAL_EXPIRE_SECONDS = 86400L; + public static final long TODAY_EXPIRE_SECONDS = 3600L; + public static final long LOCK_EXPIRE_SECONDS = 10L; + public static final long NULL_CACHE_EXPIRE_SECONDS = 300L; + public static final long RANDOM_EXPIRE_RANGE = 1800L; + public static final boolean SYNC_ENABLED = true; + public static final boolean WARMUP_ENABLED = true; + public static final boolean CLEANUP_ENABLED = true; + public static final boolean DETAILED_LOGGING = true; + public static final boolean PERFORMANCE_MONITORING = true; + } + + /** + * 测试环境配置 + */ + public static class Test { + public static final long TOTAL_EXPIRE_SECONDS = 43200L; // 12小时 + public static final long TODAY_EXPIRE_SECONDS = 1800L; // 30分钟 + public static final long LOCK_EXPIRE_SECONDS = 5L; + public static final long NULL_CACHE_EXPIRE_SECONDS = 180L; // 3分钟 + public static final long RANDOM_EXPIRE_RANGE = 900L; // 15分钟 + public static final boolean SYNC_ENABLED = true; + public static final boolean WARMUP_ENABLED = true; + public static final boolean CLEANUP_ENABLED = true; + public static final boolean DETAILED_LOGGING = false; + public static final boolean PERFORMANCE_MONITORING = true; + } + + /** + * 生产环境配置 + */ + public static class Production { + public static final long TOTAL_EXPIRE_SECONDS = 172800L; // 48小时 + public static final long TODAY_EXPIRE_SECONDS = 7200L; // 2小时 + public static final long LOCK_EXPIRE_SECONDS = 15L; + public static final long NULL_CACHE_EXPIRE_SECONDS = 600L; // 10分钟 + public static final long RANDOM_EXPIRE_RANGE = 3600L; // 1小时 + public static final boolean SYNC_ENABLED = true; + public static final boolean WARMUP_ENABLED = true; + public static final boolean CLEANUP_ENABLED = true; + public static final boolean DETAILED_LOGGING = false; + public static final boolean PERFORMANCE_MONITORING = false; + } + + /** + * 定时任务cron表达式 + */ + public static final String SYNC_CRON = "0 0 2 * * ?"; // 每天凌晨2点 + public static final String CLEANUP_CRON = "0 0 * * * ?"; // 每小时 + public static final String WARMUP_CRON = "0 0 3 * * ?"; // 每天凌晨3点 + + private ChatCountCacheConstants() { + // 私有构造函数,防止实例化 + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/controller/CharacterAdminController.java b/vocata-server/src/main/java/com/vocata/character/controller/CharacterAdminController.java new file mode 100644 index 0000000..9ceba84 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/controller/CharacterAdminController.java @@ -0,0 +1,329 @@ +package com.vocata.character.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.character.dto.request.CharacterSearchRequest; +import com.vocata.character.dto.request.CharacterAdminUpdateRequest; +import com.vocata.character.dto.response.CharacterDetailResponse; +import com.vocata.character.dto.response.CharacterResponse; +import com.vocata.character.entity.Character; +import com.vocata.character.service.CharacterService; +import com.vocata.common.constant.CharacterStatus; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.result.PageResult; +import com.vocata.common.utils.UserContext; +import jakarta.validation.Valid; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 角色管理接口 - 管理端API + * 路径前缀: /api/admin/character + * 需要管理员权限 + */ +@RestController +@RequestMapping("/api/admin/character") +public class CharacterAdminController { + + @Autowired + private CharacterService characterService; + + /** + * 获取所有角色列表(包括私有角色) + * GET /api/admin/character + */ + @GetMapping + public ApiResponse> getAllCharacters(CharacterSearchRequest request) { + // 防止空指针异常,设置默认值 + int pageNum = request.getPageNum() != null ? request.getPageNum() : 1; + int pageSize = request.getPageSize() != null ? request.getPageSize() : 10; + + Page page = new Page<>(pageNum, pageSize); + + IPage result = characterService.getPublicCharacters( + page, + request.getStatus(), + request.getIsFeatured(), + request.getTags(), + request.getOrderBy(), + request.getOrderDirection() + ); + + List responseList = result.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 根据ID获取角色详情 + * GET /api/admin/character/{id} + */ + @GetMapping("/{id}") + public ApiResponse getCharacterById(@PathVariable Long id) { + Character character = characterService.getById(id); + if (character == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "角色不存在"); + } + + CharacterDetailResponse response = convertToDetailResponse(character); + return ApiResponse.success(response); + } + + /** + * 更新角色状态 + * PUT /api/admin/character/{id}/status + */ + @PutMapping("/{id}/status") + public ApiResponse updateCharacterStatus( + @PathVariable Long id, + @RequestParam Integer status) { + if (!CharacterStatus.isValidStatus(status)) { + throw new BizException(ApiCode.PARAM_ERROR, "无效的状态值"); + } + + characterService.updateStatus(id, status); + String statusName = CharacterStatus.getStatusName(status); + return ApiResponse.success("角色状态已更新为:" + statusName); + } + + /** + * 批量更新角色状态 + * PUT /api/admin/character/batch/status + */ + @PutMapping("/batch/status") + public ApiResponse batchUpdateStatus( + @RequestBody List ids, + @RequestParam Integer status) { + + if (!CharacterStatus.isValidStatus(status)) { + throw new BizException(ApiCode.PARAM_ERROR, "无效的状态值"); + } + + int successCount = 0; + for (Long id : ids) { + if (characterService.updateStatus(id, status)) { + successCount++; + } + } + + String statusName = CharacterStatus.getStatusName(status); + return ApiResponse.success(String.format("成功将 %d/%d 个角色状态更新为:%s", successCount, ids.size(), statusName)); + } + + /** + * 设置角色为精选 + * PUT /api/admin/character/{id}/featured + */ + @PutMapping("/{id}/featured") + public ApiResponse setFeatured( + @PathVariable Long id, + @RequestParam Integer isFeatured) { + + // 如果要设置为精选,需要检查角色是否已发布 + if (isFeatured == 1) { + Character currentCharacter = characterService.getById(id); + if (currentCharacter == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "角色不存在"); + } + if (currentCharacter.getStatus() != CharacterStatus.PUBLISHED) { + throw new BizException(ApiCode.PARAM_ERROR, "只有已发布的角色才能设置为精选"); + } + } + + Character character = new Character(); + character.setId(id); + character.setIsFeatured(isFeatured); + + Character updated = characterService.update(character); + if (updated != null) { + String message = isFeatured == 1 ? "已设置为精选角色" : "已取消精选"; + return ApiResponse.success(message); + } else { + return ApiResponse.error("操作失败"); + } + } + + /** + * 设置角色排序权重 + * PUT /api/admin/character/{id}/sort-weight + */ + @PutMapping("/{id}/sort-weight") + public ApiResponse setSortWeight( + @PathVariable Long id, + @RequestParam Integer sortWeight) { + + Character character = new Character(); + character.setId(id); + character.setSortWeight(sortWeight); + + Character updated = characterService.update(character); + if (updated != null) { + return ApiResponse.success("排序权重已更新"); + } else { + return ApiResponse.error("操作失败"); + } + } + + /** + * 获取审核中的角色列表 + * GET /api/admin/character/pending-review + */ + @GetMapping("/pending-review") + public ApiResponse> getPendingReviewCharacters( + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + + Page page = new Page<>(pageNum, pageSize); + + IPage result = characterService.getPublicCharacters( + page, + CharacterStatus.UNDER_REVIEW, + null, + null, + "created_at", + "desc" + ); + + List responseList = result.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 搜索角色(管理员可搜索所有状态) + * GET /api/admin/character/search + */ + @GetMapping("/search") + public ApiResponse> searchCharacters(CharacterSearchRequest request) { + // 防止空指针异常,设置默认值 + int pageNum = request.getPageNum() != null ? request.getPageNum() : 1; + int pageSize = request.getPageSize() != null ? request.getPageSize() : 10; + + Page page = new Page<>(pageNum, pageSize); + + IPage result = characterService.searchCharacters( + page, + request.getKeyword(), + request.getStatus() // 管理员可以搜索任何状态的角色 + ); + + List responseList = result.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 根据创建者查询角色 + * GET /api/admin/character/creator/{createId} + */ + @GetMapping("/creator/{createId}") + public ApiResponse> getCharactersByCreator( + @PathVariable Long createId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) Integer status) { + + Page page = new Page<>(pageNum, pageSize); + + IPage result = characterService.getCharactersByCreator(page, createId, status); + + List responseList = result.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 管理员编辑角色 + * PUT /api/admin/character/{id} + */ + @PutMapping("/{id}") + public ApiResponse updateCharacter( + @PathVariable Long id, + @Valid @RequestBody CharacterAdminUpdateRequest request) { + + Character character = new Character(); + BeanUtils.copyProperties(request, character); + character.setId(id); + + Character updated = characterService.update(character); + CharacterDetailResponse response = convertToDetailResponse(updated); + + return ApiResponse.success("角色更新成功", response); + } + + /** + * 管理员删除角色 + * DELETE /api/admin/character/{id} + */ + @DeleteMapping("/{id}") + public ApiResponse deleteCharacter(@PathVariable Long id) { + characterService.delete(id); + return ApiResponse.success("角色删除成功"); + } + + /** + * 将Character实体转换为CharacterResponse + */ + private CharacterResponse convertToResponse(Character character) { + CharacterResponse response = new CharacterResponse(); + BeanUtils.copyProperties(character, response); + response.setStatusName(CharacterStatus.getStatusName(character.getStatus())); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } + + /** + * 将Character实体转换为CharacterDetailResponse + */ + private CharacterDetailResponse convertToDetailResponse(Character character) { + CharacterDetailResponse response = new CharacterDetailResponse(); + BeanUtils.copyProperties(character, response); + response.setStatusName(CharacterStatus.getStatusName(character.getStatus())); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/controller/CharacterChatCountController.java b/vocata-server/src/main/java/com/vocata/character/controller/CharacterChatCountController.java new file mode 100644 index 0000000..66e5390 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/controller/CharacterChatCountController.java @@ -0,0 +1,111 @@ +package com.vocata.character.controller; + +import com.vocata.character.service.CharacterChatCountService; +import com.vocata.common.result.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 角色聊天计数API控制器 + */ +@RestController +@RequestMapping("/api/admin/character/chat-count") +public class CharacterChatCountController { + + @Autowired + private CharacterChatCountService characterChatCountService; + + /** + * 获取角色聊天计数 + * + * @param characterId 角色ID + * @return 聊天计数信息 + */ + @GetMapping("/{characterId}") + public ApiResponse> getChatCount(@PathVariable Long characterId) { + Long totalCount = characterChatCountService.getChatCount(characterId); + Long todayCount = characterChatCountService.getTodayChatCount(characterId); + + Map result = new HashMap<>(); + result.put("characterId", characterId.toString()); + result.put("totalChatCount", totalCount); + result.put("todayChatCount", todayCount); + result.put("timestamp", System.currentTimeMillis()); + + return ApiResponse.success(result); + } + + /** + * 手动增加角色聊天计数(测试用) + * + * @param characterId 角色ID + * @return 更新后的计数 + */ + @PostMapping("/{characterId}/increment") + public ApiResponse> incrementChatCount(@PathVariable Long characterId) { + Long newCount = characterChatCountService.incrementChatCount(characterId); + Long todayCount = characterChatCountService.getTodayChatCount(characterId); + + Map result = new HashMap<>(); + result.put("characterId", characterId.toString()); + result.put("newTotalCount", newCount); + result.put("todayChatCount", todayCount); + result.put("timestamp", System.currentTimeMillis()); + + return ApiResponse.success(result); + } + + /** + * 手动同步缓存到数据库 + * + * @param characterId 角色ID(可选,为空则同步全部) + * @return 同步结果 + */ + @PostMapping("/sync") + public ApiResponse> syncCacheToDatabase(@RequestParam(required = false) Long characterId) { + characterChatCountService.syncCacheToDatabase(characterId); + + Map result = new HashMap<>(); + result.put("message", characterId != null ? + "角色" + characterId + "的聊天计数已同步到数据库" : + "所有角色的聊天计数已同步到数据库"); + result.put("timestamp", System.currentTimeMillis()); + + return ApiResponse.success(result); + } + + /** + * 预热缓存 + * + * @return 预热结果 + */ + @PostMapping("/warm-up") + public ApiResponse> warmUpCache() { + characterChatCountService.warmUpCache(); + + Map result = new HashMap<>(); + result.put("message", "缓存预热已完成"); + result.put("timestamp", System.currentTimeMillis()); + + return ApiResponse.success(result); + } + + /** + * 清理过期缓存 + * + * @return 清理结果 + */ + @PostMapping("/cleanup") + public ApiResponse> cleanupExpiredCache() { + characterChatCountService.cleanupExpiredTodayCache(); + + Map result = new HashMap<>(); + result.put("message", "过期缓存清理已完成"); + result.put("timestamp", System.currentTimeMillis()); + + return ApiResponse.success(result); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/controller/CharacterController.java b/vocata-server/src/main/java/com/vocata/character/controller/CharacterController.java new file mode 100644 index 0000000..0a12d57 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/controller/CharacterController.java @@ -0,0 +1,198 @@ +package com.vocata.character.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.character.dto.request.CharacterAiGenerateRequest; +import com.vocata.character.dto.request.CharacterCreateRequest; +import com.vocata.character.dto.request.CharacterCreateWithAiRequest; +import com.vocata.character.dto.request.CharacterSearchRequest; +import com.vocata.character.dto.request.CharacterUpdateRequest; +import com.vocata.character.dto.response.CharacterAiGenerateResponse; +import com.vocata.character.dto.response.CharacterCreateWithAiResponse; +import com.vocata.character.dto.response.CharacterDetailResponse; +import com.vocata.character.dto.response.CharacterResponse; +import com.vocata.character.entity.Character; +import com.vocata.character.service.CharacterAiGenerateService; +import com.vocata.character.service.CharacterService; +import com.vocata.common.constant.CharacterStatus; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.result.PageResult; +import com.vocata.common.utils.UserContext; +import com.vocata.file.dto.FileUploadResponse; +import com.vocata.file.service.FileService; +import jakarta.validation.Valid; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 角色管理接口 - 客户端API(需要认证) + * 路径前缀: /api/client/character + */ +@RestController +@RequestMapping("/api/client/character") +public class CharacterController { + + @Autowired + private CharacterService characterService; + + @Autowired + private CharacterAiGenerateService characterAiGenerateService; + + @Autowired + private FileService fileService; + + /** + * 上传角色头像(需要登录) + * POST /api/client/character/upload-avatar + */ + @PostMapping("/upload-avatar") + @SaCheckLogin + public ApiResponse uploadAvatar(@RequestParam("file") MultipartFile file) { + FileUploadResponse response = fileService.uploadFile(file, "character-avatar"); + return ApiResponse.success("头像上传成功", response); + } + + /** + * AI生成角色设定(需要登录) + * POST /api/client/character/ai-generate + */ + @PostMapping("/ai-generate") + @SaCheckLogin + public ApiResponse generateCharacter(@Valid @RequestBody CharacterAiGenerateRequest request) { + CharacterAiGenerateResponse response = characterAiGenerateService.generateCharacter(request); + return ApiResponse.success("AI角色生成成功", response); + } + + /** + * 创建角色并自动生成AI设定(需要登录) + * POST /api/client/character/create-with-ai + * + * 此接口会: + * 1. 立即创建角色基础记录并返回 + * 2. 异步调用AI生成详细设定并更新数据库 + */ + @PostMapping("/create-with-ai") + @SaCheckLogin + public ApiResponse createCharacterWithAi( + @Valid @RequestBody CharacterCreateWithAiRequest request) { + CharacterCreateWithAiResponse response = characterService.createWithAi(request); + return ApiResponse.success("角色创建成功,AI生成的详细设定正在后台处理", response); + } + + /** + * 创建角色(需要登录) + * POST /api/client/character + */ + @PostMapping + @SaCheckLogin + public ApiResponse createCharacter(@Valid @RequestBody CharacterCreateRequest request) { + Character character = new Character(); + BeanUtils.copyProperties(request, character); + + // 设置默认值 + if (character.getTemperature() == null) { + character.setTemperature(new BigDecimal("0.7")); + } + + Character created = characterService.create(character); + CharacterDetailResponse response = convertToDetailResponse(created); + + return ApiResponse.success("角色创建成功", response); + } + + /** + * 更新角色(需要登录且有权限) + * PUT /api/client/character/{id} + */ + @PutMapping("/{id}") + @SaCheckLogin + public ApiResponse updateCharacter( + @PathVariable Long id, + @Valid @RequestBody CharacterUpdateRequest request) { + + Character character = new Character(); + BeanUtils.copyProperties(request, character); + character.setId(id); + + Character updated = characterService.update(character); + CharacterDetailResponse response = convertToDetailResponse(updated); + + return ApiResponse.success("角色更新成功", response); + } + + /** + * 删除角色(需要登录且有权限) + * DELETE /api/client/character/{id} + */ + @DeleteMapping("/{id}") + @SaCheckLogin + public ApiResponse deleteCharacter(@PathVariable Long id) { + characterService.delete(id); + return ApiResponse.success("角色删除成功"); + } + + /** + * 获取我创建的角色列表(需要登录) + * GET /api/client/character/my + */ + @GetMapping("/my") + @SaCheckLogin + public ApiResponse> getMyCharacters(CharacterSearchRequest request) { + Long currentUserId = UserContext.getUserId(); + // 防止空指针异常,设置默认值 + int pageNum = request.getPageNum() != null ? request.getPageNum() : 1; + int pageSize = request.getPageSize() != null ? request.getPageSize() : 10; + + Page page = new Page<>(pageNum, pageSize); + + IPage result = characterService.getCharactersByCreator( + page, + currentUserId, + request.getStatus() + ); + + List responseList = result.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 将Character实体转换为CharacterResponse + */ + private CharacterResponse convertToResponse(Character character) { + CharacterResponse response = new CharacterResponse(); + BeanUtils.copyProperties(character, response); + response.setStatusName(CharacterStatus.getStatusName(character.getStatus())); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } + + /** + * 将Character实体转换为CharacterDetailResponse + */ + private CharacterDetailResponse convertToDetailResponse(Character character) { + CharacterDetailResponse response = new CharacterDetailResponse(); + BeanUtils.copyProperties(character, response); + response.setStatusName(CharacterStatus.getStatusName(character.getStatus())); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/controller/CharacterOpenController.java b/vocata-server/src/main/java/com/vocata/character/controller/CharacterOpenController.java new file mode 100644 index 0000000..1cd5843 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/controller/CharacterOpenController.java @@ -0,0 +1,258 @@ +package com.vocata.character.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.character.dto.request.CharacterSearchRequest; +import com.vocata.character.dto.response.CharacterDetailResponse; +import com.vocata.character.dto.response.CharacterResponse; +import com.vocata.character.entity.Character; +import com.vocata.character.service.CharacterService; +import com.vocata.common.constant.CharacterStatus; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.result.PageResult; +import com.vocata.user.service.UserFavoriteService; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 角色公开接口 - 无需认证的公开API + * 路径前缀: /api/open/character + */ +@RestController +@RequestMapping("/api/open/character") +public class CharacterOpenController { + + @Autowired + private CharacterService characterService; + + @Autowired + private UserFavoriteService userFavoriteService; + + /** + * 获取公开角色列表 + * GET /api/open/character/list 或 /api/open/character/public + */ + @GetMapping({"/list", "/public"}) + public ApiResponse> getPublicCharacters(CharacterSearchRequest request) { + // 防止空指针异常,设置默认值 + int pageNum = request.getPageNum() != null ? request.getPageNum() : 1; + int pageSize = request.getPageSize() != null ? request.getPageSize() : 15; // 默认每页15个 + + Page page = new Page<>(pageNum, pageSize); + + // 使用带创建者名称的查询方法 + IPage> result = characterService.getPublicCharactersWithCreator( + page, + CharacterStatus.PUBLISHED, // 只查询已发布的角色 + request.getIsFeatured(), + request.getTags(), + request.getOrderBy(), + request.getOrderDirection() + ); + + List responseList = result.getRecords().stream() + .map(this::convertMapToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 搜索角色 + * GET /api/open/character/search?keyword=xxx + */ + @GetMapping("/search") + public ApiResponse> searchCharacters(CharacterSearchRequest request) { + // 防止空指针异常,设置默认值 + int pageNum = request.getPageNum() != null ? request.getPageNum() : 1; + int pageSize = request.getPageSize() != null ? request.getPageSize() : 15; // 默认每页15个 + + Page page = new Page<>(pageNum, pageSize); + + IPage result = characterService.searchCharacters( + page, + request.getKeyword(), + CharacterStatus.PUBLISHED // 只搜索已发布的 + ); + + List responseList = result.getRecords().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + PageResult pageResult = PageResult.of( + pageNum, + pageSize, + result.getTotal(), + responseList + ); + + return ApiResponse.success(pageResult); + } + + /** + * 获取热门角色列表 + * GET /api/open/character/trending + */ + @GetMapping("/trending") + public ApiResponse> getTrendingCharacters(@RequestParam(defaultValue = "10") int limit) { + List characters = characterService.getTrendingCharacters(limit); + List responses = characters.stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + + return ApiResponse.success(responses); + } + + /** + * 获取精选角色列表 + * GET /api/open/character/featured + */ + @GetMapping("/featured") + public ApiResponse> getFeaturedCharacters(@RequestParam(defaultValue = "10") int limit) { + List> charactersWithCreator = characterService.getFeaturedCharactersWithCreator(limit); + List responses = charactersWithCreator.stream() + .map(this::convertMapToResponse) + .collect(Collectors.toList()); + + return ApiResponse.success(responses); + } + + /** + * 获取角色收藏数排行榜(公开接口) + * GET /api/open/character/favorite-ranking + */ + @GetMapping("/favorite-ranking") + public ApiResponse>> getFavoriteRanking( + @RequestParam(value = "limit", defaultValue = "10") Integer limit) { + List> result = userFavoriteService.getFavoriteRanking(limit); + return ApiResponse.success(result); + } + + /** + * 根据角色编码或ID获取角色详情 + * GET /api/open/character/{characterCodeOrId} + * 支持传入角色编码(string)或角色ID(数字) + * 注意:此映射必须放在最后,避免拦截其他具体路径 + */ + @GetMapping("/{characterCodeOrId}") + public ApiResponse getCharacterByCodeOrId(@PathVariable String characterCodeOrId) { + Character character = null; + + // 首先尝试按ID查找(如果传入的是数字) + try { + Long id = Long.parseLong(characterCodeOrId); + character = characterService.getById(id); + // 确保角色已发布 + if (character != null && character.getStatus() != CharacterStatus.PUBLISHED) { + character = null; + } + } catch (NumberFormatException e) { + // 不是数字,忽略异常继续按编码查找 + } + + // 如果按ID未找到,尝试按角色编码查找 + if (character == null) { + character = characterService.getByCharacterCode(characterCodeOrId); + } + + if (character == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "角色不存在"); + } + + CharacterDetailResponse response = convertToDetailResponse(character); + return ApiResponse.success(response); + } + + /** + * 将Character实体转换为CharacterResponse + */ + private CharacterResponse convertToResponse(Character character) { + CharacterResponse response = new CharacterResponse(); + BeanUtils.copyProperties(character, response); + response.setStatusName(CharacterStatus.getStatusName(character.getStatus())); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } + + /** + * 将Map结果(包含创建者名称)转换为CharacterResponse + */ + private CharacterResponse convertMapToResponse(Map characterMap) { + CharacterResponse response = new CharacterResponse(); + + // 复制角色基本信息,处理null值 + response.setId(characterMap.get("id") != null ? Long.valueOf(characterMap.get("id").toString()) : null); + response.setCharacterCode((String) characterMap.get("character_code")); + response.setName((String) characterMap.get("name")); + response.setDescription((String) characterMap.get("description")); + response.setGreeting((String) characterMap.get("greeting")); + response.setAvatarUrl((String) characterMap.get("avatar_url")); + response.setTags((String) characterMap.get("tags")); + response.setLanguage((String) characterMap.get("language")); + + Integer status = (Integer) characterMap.get("status"); + response.setStatus(status); + response.setStatusName(CharacterStatus.getStatusName(status)); + + response.setIsOfficial((Integer) characterMap.get("is_official")); + response.setIsFeatured((Integer) characterMap.get("is_featured")); + response.setIsTrending((Integer) characterMap.get("is_trending")); + response.setTrendingScore((Integer) characterMap.get("trending_score")); + response.setChatCount((Long) characterMap.get("chat_count")); + response.setUserCount((Integer) characterMap.get("user_count")); + response.setIsPrivate((Boolean) characterMap.get("is_private")); + + // 处理create_id可能为null的情况 + Object createIdObj = characterMap.get("create_id"); + if (createIdObj != null) { + response.setCreateId(Long.valueOf(createIdObj.toString())); + } + + // 处理时间类型转换 + Object createdAt = characterMap.get("created_at"); + if (createdAt instanceof java.sql.Timestamp) { + response.setCreatedAt(((java.sql.Timestamp) createdAt).toLocalDateTime()); + } else if (createdAt instanceof java.time.LocalDateTime) { + response.setCreatedAt((java.time.LocalDateTime) createdAt); + } + + Object updatedAt = characterMap.get("updated_at"); + if (updatedAt instanceof java.sql.Timestamp) { + response.setUpdatedAt(((java.sql.Timestamp) updatedAt).toLocalDateTime()); + } else if (updatedAt instanceof java.time.LocalDateTime) { + response.setUpdatedAt((java.time.LocalDateTime) updatedAt); + } + + // 设置创建者名称 + response.setCreatorName((String) characterMap.get("creator_name")); + + return response; + } + + /** + * 将Character实体转换为CharacterDetailResponse + */ + private CharacterDetailResponse convertToDetailResponse(Character character) { + CharacterDetailResponse response = new CharacterDetailResponse(); + BeanUtils.copyProperties(character, response); + response.setStatusName(CharacterStatus.getStatusName(character.getStatus())); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterAdminUpdateRequest.java b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterAdminUpdateRequest.java new file mode 100644 index 0000000..2ee7a00 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterAdminUpdateRequest.java @@ -0,0 +1,276 @@ +package com.vocata.character.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.vocata.common.config.BigDecimalDeserializer; +import com.vocata.common.config.JsonArrayDeserializer; +import com.vocata.common.config.LongDeserializer; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +/** + * 管理员更新角色请求DTO + * 管理员拥有更多修改权限 + */ +public class CharacterAdminUpdateRequest { + + /** + * 角色名称 + */ + @Size(max = 100, message = "角色名称不能超过100个字符") + private String name; + + /** + * 一句话简介 + */ + @Size(max = 500, message = "简介不能超过500个字符") + private String description; + + /** + * 开场白 + */ + private String greeting; + + /** + * 人设prompt(给LLM的核心指令) + */ + private String persona; + + /** + * 性格特征标签JSON数组:["温柔","智慧","幽默"] + */ + @JsonDeserialize(using = JsonArrayDeserializer.class) + private String personalityTraits; + + /** + * 说话风格描述 + */ + private String speakingStyle; + + /** + * 示例对话JSON + */ + private String exampleDialogues; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 语音ID(TTS服务) + */ + private String voiceId; + + /** + * 标签数组JSON:["动漫","治愈","女友"] + */ + @JsonDeserialize(using = JsonArrayDeserializer.class) + private String tags; + + /** + * 搜索关键词,用于提升搜索准确度 + */ + private String searchKeywords; + + /** + * 主要语言 + */ + @Pattern(regexp = "^(zh-CN|en-US|ja-JP|ko-KR)$", message = "不支持的语言") + private String language; + + /** + * 默认模型ID + */ + @JsonDeserialize(using = LongDeserializer.class) + private Long defaultModelId; + + /** + * 温度参数 + */ + @DecimalMin(value = "0.0", message = "温度参数最小为0.0") + @DecimalMax(value = "2.0", message = "温度参数最大为2.0") + @JsonDeserialize(using = BigDecimalDeserializer.class) + private BigDecimal temperature; + + /** + * 上下文轮数 + */ + private Integer contextWindow; + + /** + * 是否私有:false=公开 true=私有 + */ + private Boolean isPrivate; + + /** + * 是否官方角色:0=否 1=是(管理员专有权限) + */ + private Integer isOfficial; + + /** + * 是否精选推荐:0=否 1=是(管理员专有权限) + */ + private Integer isFeatured; + + /** + * 排序权重(管理员专有权限) + */ + private Integer sortWeight; + + // Getters and Setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getPersona() { + return persona; + } + + public void setPersona(String persona) { + this.persona = persona; + } + + public String getPersonalityTraits() { + return personalityTraits; + } + + public void setPersonalityTraits(String personalityTraits) { + this.personalityTraits = personalityTraits; + } + + public String getSpeakingStyle() { + return speakingStyle; + } + + public void setSpeakingStyle(String speakingStyle) { + this.speakingStyle = speakingStyle; + } + + public String getExampleDialogues() { + return exampleDialogues; + } + + public void setExampleDialogues(String exampleDialogues) { + this.exampleDialogues = exampleDialogues; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getSearchKeywords() { + return searchKeywords; + } + + public void setSearchKeywords(String searchKeywords) { + this.searchKeywords = searchKeywords; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Long getDefaultModelId() { + return defaultModelId; + } + + public void setDefaultModelId(Long defaultModelId) { + this.defaultModelId = defaultModelId; + } + + public BigDecimal getTemperature() { + return temperature; + } + + public void setTemperature(BigDecimal temperature) { + this.temperature = temperature; + } + + public Integer getContextWindow() { + return contextWindow; + } + + public void setContextWindow(Integer contextWindow) { + this.contextWindow = contextWindow; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public Integer getIsOfficial() { + return isOfficial; + } + + public void setIsOfficial(Integer isOfficial) { + this.isOfficial = isOfficial; + } + + public Integer getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Integer isFeatured) { + this.isFeatured = isFeatured; + } + + public Integer getSortWeight() { + return sortWeight; + } + + public void setSortWeight(Integer sortWeight) { + this.sortWeight = sortWeight; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterAiGenerateRequest.java b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterAiGenerateRequest.java new file mode 100644 index 0000000..273f1c4 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterAiGenerateRequest.java @@ -0,0 +1,66 @@ +package com.vocata.character.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * AI角色生成请求DTO + * 接收用户输入的基本角色信息,用于AI生成详细角色设定 + */ +public class CharacterAiGenerateRequest { + + /** + * 角色名称(必填) + */ + @NotBlank(message = "角色名称不能为空") + @Size(max = 100, message = "角色名称不能超过100个字符") + private String name; + + /** + * 角色简短描述(必填) + */ + @NotBlank(message = "角色描述不能为空") + @Size(max = 500, message = "角色描述不能超过500个字符") + private String description; + + /** + * 角色打招呼语(必填) + */ + @NotBlank(message = "角色打招呼语不能为空") + @Size(max = 200, message = "角色打招呼语不能超过200个字符") + private String greeting; + + // Getters and Setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + @Override + public String toString() { + return "CharacterAiGenerateRequest{" + + "name='" + name + '\'' + + ", description='" + description + '\'' + + ", greeting='" + greeting + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterCreateRequest.java b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterCreateRequest.java new file mode 100644 index 0000000..71e297a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterCreateRequest.java @@ -0,0 +1,299 @@ +package com.vocata.character.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.vocata.common.config.BigDecimalDeserializer; +import com.vocata.common.config.JsonArrayDeserializer; +import com.vocata.common.config.LongDeserializer; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +/** + * 创建角色请求DTO + */ +public class CharacterCreateRequest { + + /** + * 角色唯一编码,用于URL等 + */ + @NotBlank(message = "角色编码不能为空") + @Pattern(regexp = "^[a-zA-Z0-9_-]{3,50}$", message = "角色编码只能包含字母、数字、下划线、短横线,长度3-50位") + private String characterCode; + + /** + * 角色名称 + */ + @NotBlank(message = "角色名称不能为空") + @Size(max = 100, message = "角色名称不能超过100个字符") + private String name; + + /** + * 一句话简介 + */ + @Size(max = 500, message = "简介不能超过500个字符") + private String description; + + /** + * 开场白 + */ + private String greeting; + + /** + * 人设prompt(给LLM的核心指令) + */ + @NotBlank(message = "人设不能为空") + private String persona; + + /** + * 性格特征标签JSON数组:["温柔","智慧","幽默"] + */ + @JsonDeserialize(using = JsonArrayDeserializer.class) + private String personalityTraits; + + /** + * 说话风格描述 + */ + private String speakingStyle; + + /** + * 示例对话JSON + */ + private String exampleDialogues; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 语音ID(TTS服务) + */ + private String voiceId; + + /** + * 标签数组JSON:["动漫","治愈","女友"] + */ + @JsonDeserialize(using = JsonArrayDeserializer.class) + private String tags; + + /** + * 搜索关键词,用于提升搜索准确度 + */ + private String searchKeywords; + + /** + * 主要语言 + */ + @Pattern(regexp = "^(zh-CN|en-US|ja-JP|ko-KR)$", message = "不支持的语言") + private String language = "zh-CN"; + + /** + * 默认模型ID + */ + @JsonDeserialize(using = LongDeserializer.class) + private Long defaultModelId; + + /** + * 温度参数 + */ + @DecimalMin(value = "0.0", message = "温度参数最小为0.0") + @DecimalMax(value = "2.0", message = "温度参数最大为2.0") + @JsonDeserialize(using = BigDecimalDeserializer.class) + private BigDecimal temperature; + + /** + * 上下文轮数 + */ + @NotNull(message = "上下文轮数不能为空") + private Integer contextWindow = 10; + + /** + * 是否私有:false=公开 true=私有 + */ + private Boolean isPrivate = true; + + // ========== 新增标签相关字段(可选) ========== + + /** + * 标签ID数组(新增字段,可选) + */ + private Long[] tagIds; + + /** + * 标签名称数组(新增字段,可选) + */ + private String[] tagNames; + + /** + * 主要标签ID数组(新增字段,可选) + */ + private Long[] primaryTagIds; + + // Getters and Setters + public String getCharacterCode() { + return characterCode; + } + + public void setCharacterCode(String characterCode) { + this.characterCode = characterCode; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getPersona() { + return persona; + } + + public void setPersona(String persona) { + this.persona = persona; + } + + public String getPersonalityTraits() { + return personalityTraits; + } + + public void setPersonalityTraits(String personalityTraits) { + this.personalityTraits = personalityTraits; + } + + public String getSpeakingStyle() { + return speakingStyle; + } + + public void setSpeakingStyle(String speakingStyle) { + this.speakingStyle = speakingStyle; + } + + public String getExampleDialogues() { + return exampleDialogues; + } + + public void setExampleDialogues(String exampleDialogues) { + this.exampleDialogues = exampleDialogues; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getSearchKeywords() { + return searchKeywords; + } + + public void setSearchKeywords(String searchKeywords) { + this.searchKeywords = searchKeywords; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Long getDefaultModelId() { + return defaultModelId; + } + + public void setDefaultModelId(Long defaultModelId) { + this.defaultModelId = defaultModelId; + } + + public BigDecimal getTemperature() { + return temperature; + } + + public void setTemperature(BigDecimal temperature) { + this.temperature = temperature; + } + + public Integer getContextWindow() { + return contextWindow; + } + + public void setContextWindow(Integer contextWindow) { + this.contextWindow = contextWindow; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + // ========== 新增字段的getter和setter方法 ========== + + public Long[] getTagIds() { + return tagIds; + } + + public void setTagIds(Long[] tagIds) { + this.tagIds = tagIds; + } + + public String[] getTagNames() { + return tagNames; + } + + public void setTagNames(String[] tagNames) { + this.tagNames = tagNames; + } + + public Long[] getPrimaryTagIds() { + return primaryTagIds; + } + + public void setPrimaryTagIds(Long[] primaryTagIds) { + this.primaryTagIds = primaryTagIds; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterCreateWithAiRequest.java b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterCreateWithAiRequest.java new file mode 100644 index 0000000..c3fddfe --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterCreateWithAiRequest.java @@ -0,0 +1,94 @@ +package com.vocata.character.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 带AI生成的角色创建请求DTO + * 用于创建角色时自动生成AI设定并异步更新到数据库 + */ +public class CharacterCreateWithAiRequest { + + /** + * 角色名称(必填) + */ + @NotBlank(message = "角色名称不能为空") + @Size(max = 100, message = "角色名称不能超过100个字符") + private String name; + + /** + * 角色简短描述(必填) + */ + @NotBlank(message = "角色描述不能为空") + @Size(max = 500, message = "角色描述不能超过500个字符") + private String description; + + /** + * 角色打招呼语(必填) + */ + @NotBlank(message = "角色打招呼语不能为空") + @Size(max = 200, message = "角色打招呼语不能超过200个字符") + private String greeting; + + /** + * 角色头像URL(可选) + */ + private String avatarUrl; + + /** + * 是否私有(可选,默认false) + */ + private Boolean isPrivate = false; + + // Getters and Setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + @Override + public String toString() { + return "CharacterCreateWithAiRequest{" + + "name='" + name + '\'' + + ", description='" + description + '\'' + + ", greeting='" + greeting + '\'' + + ", avatarUrl='" + avatarUrl + '\'' + + ", isPrivate=" + isPrivate + + '}'; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterSearchRequest.java b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterSearchRequest.java new file mode 100644 index 0000000..51e333a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterSearchRequest.java @@ -0,0 +1,162 @@ +package com.vocata.character.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.vocata.common.config.LongDeserializer; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.util.List; + +/** + * 角色搜索请求DTO + */ +public class CharacterSearchRequest { + + /** + * 搜索关键词 + */ + private String keyword; + + /** + * 角色状态:1=已发布 2=审核中 3=已下架 + */ + private Integer status; + + /** + * 是否精选:0=否 1=是 + */ + private Integer isFeatured; + + /** + * 是否热门:0=否 1=是 + */ + private Integer isTrending; + + /** + * 标签列表 + */ + private List tags; + + /** + * 语言 + */ + private String language; + + /** + * 创建者ID(仅管理员和用户查看自己的角色时使用) + */ + @JsonDeserialize(using = LongDeserializer.class) + private Long createId; + + /** + * 页码 + */ + @Min(value = 1, message = "页码最小为1") + private Integer pageNum = 1; + + /** + * 每页数量 + */ + @Min(value = 1, message = "每页数量最小为1") + @Max(value = 100, message = "每页数量最大为100") + private Integer pageSize = 10; + + /** + * 排序字段:created_at, updated_at, chat_count, trending_score, sort_weight + */ + private String orderBy = "chat_count"; + + /** + * 排序方向:asc, desc + */ + private String orderDirection = "desc"; + + // Getters and Setters + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = keyword; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Integer getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Integer isFeatured) { + this.isFeatured = isFeatured; + } + + public Integer getIsTrending() { + return isTrending; + } + + public void setIsTrending(Integer isTrending) { + this.isTrending = isTrending; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Long getCreateId() { + return createId; + } + + public void setCreateId(Long createId) { + this.createId = createId; + } + + public Integer getPageNum() { + return pageNum; + } + + public void setPageNum(Integer pageNum) { + this.pageNum = pageNum; + } + + public Integer getPageSize() { + return pageSize; + } + + public void setPageSize(Integer pageSize) { + this.pageSize = pageSize; + } + + public String getOrderBy() { + return orderBy; + } + + public void setOrderBy(String orderBy) { + this.orderBy = orderBy; + } + + public String getOrderDirection() { + return orderDirection; + } + + public void setOrderDirection(String orderDirection) { + this.orderDirection = orderDirection; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterUpdateRequest.java b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterUpdateRequest.java new file mode 100644 index 0000000..cb40c2b --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/request/CharacterUpdateRequest.java @@ -0,0 +1,295 @@ +package com.vocata.character.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.vocata.common.config.BigDecimalDeserializer; +import com.vocata.common.config.JsonArrayDeserializer; +import com.vocata.common.config.LongDeserializer; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +/** + * 更新角色请求DTO + */ +public class CharacterUpdateRequest { + + /** + * 角色ID + */ + @NotNull(message = "角色ID不能为空") + @JsonDeserialize(using = LongDeserializer.class) + private Long id; + + /** + * 角色名称 + */ + @Size(max = 100, message = "角色名称不能超过100个字符") + private String name; + + /** + * 一句话简介 + */ + @Size(max = 500, message = "简介不能超过500个字符") + private String description; + + /** + * 开场白 + */ + private String greeting; + + /** + * 人设prompt(给LLM的核心指令) + */ + private String persona; + + /** + * 性格特征标签JSON数组:["温柔","智慧","幽默"] + */ + @JsonDeserialize(using = JsonArrayDeserializer.class) + private String personalityTraits; + + /** + * 说话风格描述 + */ + private String speakingStyle; + + /** + * 示例对话JSON + */ + private String exampleDialogues; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 语音ID(TTS服务) + */ + private String voiceId; + + /** + * 标签数组JSON:["动漫","治愈","女友"] + */ + @JsonDeserialize(using = JsonArrayDeserializer.class) + private String tags; + + /** + * 搜索关键词,用于提升搜索准确度 + */ + private String searchKeywords; + + /** + * 主要语言 + */ + @Pattern(regexp = "^(zh-CN|en-US|ja-JP|ko-KR)$", message = "不支持的语言") + private String language; + + /** + * 默认模型ID + */ + @JsonDeserialize(using = LongDeserializer.class) + private Long defaultModelId; + + /** + * 温度参数 + */ + @DecimalMin(value = "0.0", message = "温度参数最小为0.0") + @DecimalMax(value = "2.0", message = "温度参数最大为2.0") + @JsonDeserialize(using = BigDecimalDeserializer.class) + private BigDecimal temperature; + + /** + * 上下文轮数 + */ + private Integer contextWindow; + + /** + * 是否私有:false=公开 true=私有 + */ + private Boolean isPrivate; + + // ========== 新增标签相关字段(可选) ========== + + /** + * 标签ID数组(新增字段,可选) + */ + private Long[] tagIds; + + /** + * 标签名称数组(新增字段,可选) + */ + private String[] tagNames; + + /** + * 主要标签ID数组(新增字段,可选) + */ + private Long[] primaryTagIds; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getPersona() { + return persona; + } + + public void setPersona(String persona) { + this.persona = persona; + } + + public String getPersonalityTraits() { + return personalityTraits; + } + + public void setPersonalityTraits(String personalityTraits) { + this.personalityTraits = personalityTraits; + } + + public String getSpeakingStyle() { + return speakingStyle; + } + + public void setSpeakingStyle(String speakingStyle) { + this.speakingStyle = speakingStyle; + } + + public String getExampleDialogues() { + return exampleDialogues; + } + + public void setExampleDialogues(String exampleDialogues) { + this.exampleDialogues = exampleDialogues; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getSearchKeywords() { + return searchKeywords; + } + + public void setSearchKeywords(String searchKeywords) { + this.searchKeywords = searchKeywords; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Long getDefaultModelId() { + return defaultModelId; + } + + public void setDefaultModelId(Long defaultModelId) { + this.defaultModelId = defaultModelId; + } + + public BigDecimal getTemperature() { + return temperature; + } + + public void setTemperature(BigDecimal temperature) { + this.temperature = temperature; + } + + public Integer getContextWindow() { + return contextWindow; + } + + public void setContextWindow(Integer contextWindow) { + this.contextWindow = contextWindow; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + // ========== 新增字段的getter和setter方法 ========== + + public Long[] getTagIds() { + return tagIds; + } + + public void setTagIds(Long[] tagIds) { + this.tagIds = tagIds; + } + + public String[] getTagNames() { + return tagNames; + } + + public void setTagNames(String[] tagNames) { + this.tagNames = tagNames; + } + + public Long[] getPrimaryTagIds() { + return primaryTagIds; + } + + public void setPrimaryTagIds(Long[] primaryTagIds) { + this.primaryTagIds = primaryTagIds; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterAiGenerateResponse.java b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterAiGenerateResponse.java new file mode 100644 index 0000000..0ec434d --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterAiGenerateResponse.java @@ -0,0 +1,216 @@ +package com.vocata.character.dto.response; + +import java.util.List; + +/** + * AI角色生成响应DTO + * 返回AI生成的详细角色设定内容 + */ +public class CharacterAiGenerateResponse { + + /** + * 原始输入的角色名称 + */ + private String name; + + /** + * 原始输入的角色描述 + */ + private String description; + + /** + * 原始输入的角色打招呼语 + */ + private String greeting; + + /** + * AI生成的完整角色设定内容 + */ + private String generatedContent; + + /** + * 提取出的人设prompt(给LLM的核心指令) + */ + private String persona; + + /** + * AI生成的性格特征标签(最多两个) + */ + private List personalityTraits; + + /** + * AI生成的说话风格描述 + */ + private String speakingStyle; + + /** + * AI生成的示例对话(5个以内) + */ + private List exampleDialogues; + + /** + * AI生成的标签(最多两个) + */ + private List tags; + + /** + * AI生成的搜索关键字 + */ + private String searchKeywords; + + /** + * AI生成的耗时(毫秒) + */ + private Long generationTime; + + /** + * 使用的AI模型信息 + */ + private String modelUsed; + + /** + * 示例对话内部类 + */ + public static class DialogueExample { + private String user; + private String assistant; + + public DialogueExample() {} + + public DialogueExample(String user, String assistant) { + this.user = user; + this.assistant = assistant; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getAssistant() { + return assistant; + } + + public void setAssistant(String assistant) { + this.assistant = assistant; + } + } + + // Getters and Setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getGeneratedContent() { + return generatedContent; + } + + public void setGeneratedContent(String generatedContent) { + this.generatedContent = generatedContent; + } + + public String getPersona() { + return persona; + } + + public void setPersona(String persona) { + this.persona = persona; + } + + public List getPersonalityTraits() { + return personalityTraits; + } + + public void setPersonalityTraits(List personalityTraits) { + this.personalityTraits = personalityTraits; + } + + public String getSpeakingStyle() { + return speakingStyle; + } + + public void setSpeakingStyle(String speakingStyle) { + this.speakingStyle = speakingStyle; + } + + public List getExampleDialogues() { + return exampleDialogues; + } + + public void setExampleDialogues(List exampleDialogues) { + this.exampleDialogues = exampleDialogues; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public String getSearchKeywords() { + return searchKeywords; + } + + public void setSearchKeywords(String searchKeywords) { + this.searchKeywords = searchKeywords; + } + + public Long getGenerationTime() { + return generationTime; + } + + public void setGenerationTime(Long generationTime) { + this.generationTime = generationTime; + } + + public String getModelUsed() { + return modelUsed; + } + + public void setModelUsed(String modelUsed) { + this.modelUsed = modelUsed; + } + + @Override + public String toString() { + return "CharacterAiGenerateResponse{" + + "name='" + name + '\'' + + ", description='" + description + '\'' + + ", greeting='" + greeting + '\'' + + ", generatedContent='" + (generatedContent != null ? generatedContent.substring(0, Math.min(100, generatedContent.length())) + "..." : null) + '\'' + + ", persona='" + (persona != null ? persona.substring(0, Math.min(100, persona.length())) + "..." : null) + '\'' + + ", personalityTraits=" + personalityTraits + + ", speakingStyle='" + speakingStyle + '\'' + + ", exampleDialogues=" + (exampleDialogues != null ? exampleDialogues.size() : 0) + " dialogues" + + ", tags=" + tags + + ", searchKeywords='" + searchKeywords + '\'' + + ", generationTime=" + generationTime + + ", modelUsed='" + modelUsed + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterCreateWithAiResponse.java b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterCreateWithAiResponse.java new file mode 100644 index 0000000..11cea7d --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterCreateWithAiResponse.java @@ -0,0 +1,127 @@ +package com.vocata.character.dto.response; + +/** + * 带AI生成的角色创建响应DTO + * 返回新创建的角色基本信息,AI生成的详细信息会异步更新 + */ +public class CharacterCreateWithAiResponse { + + /** + * 新创建的角色ID + */ + private String characterId; + + /** + * 角色名称 + */ + private String name; + + /** + * 角色描述 + */ + private String description; + + /** + * 角色问候语 + */ + private String greeting; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 是否私有 + */ + private Boolean isPrivate; + + /** + * 角色状态(1=已发布 2=审核中 3=已下架) + */ + private Integer status; + + /** + * AI生成任务状态说明 + */ + private String aiGenerationStatus; + + // Getters and Setters + public String getCharacterId() { + return characterId; + } + + public void setCharacterId(String characterId) { + this.characterId = characterId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getAiGenerationStatus() { + return aiGenerationStatus; + } + + public void setAiGenerationStatus(String aiGenerationStatus) { + this.aiGenerationStatus = aiGenerationStatus; + } + + @Override + public String toString() { + return "CharacterCreateWithAiResponse{" + + "characterId='" + characterId + '\'' + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", greeting='" + greeting + '\'' + + ", avatarUrl='" + avatarUrl + '\'' + + ", isPrivate=" + isPrivate + + ", status=" + status + + ", aiGenerationStatus='" + aiGenerationStatus + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterDetailResponse.java b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterDetailResponse.java new file mode 100644 index 0000000..24944d4 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterDetailResponse.java @@ -0,0 +1,509 @@ +package com.vocata.character.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 角色详情响应DTO + * 包含完整的角色信息,用于编辑和详情页面 + */ +public class CharacterDetailResponse { + + /** + * 角色ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 角色唯一编码 + */ + private String characterCode; + + /** + * 角色名称 + */ + private String name; + + /** + * 一句话简介 + */ + private String description; + + /** + * 开场白 + */ + private String greeting; + + /** + * 人设prompt(给LLM的核心指令) + */ + private String persona; + + /** + * 性格特征标签JSON数组 + */ + private String personalityTraits; + + /** + * 说话风格描述 + */ + private String speakingStyle; + + /** + * 示例对话JSON + */ + private String exampleDialogues; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 语音ID(TTS服务) + */ + private String voiceId; + + /** + * 标签数组JSON + */ + private String tags; + + /** + * 标签权重JSON + */ + private String tagWeights; + + /** + * 搜索关键词 + */ + private String searchKeywords; + + /** + * 主要语言 + */ + private String language; + + /** + * 默认模型ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long defaultModelId; + + /** + * 温度参数 + */ + private BigDecimal temperature; + + /** + * 上下文轮数 + */ + private Integer contextWindow; + + /** + * 状态:1=已发布 2=审核中 3=已下架 + */ + private Integer status; + + /** + * 状态名称 + */ + private String statusName; + + /** + * 是否官方角色 + */ + private Integer isOfficial; + + /** + * 是否精选推荐 + */ + private Integer isFeatured; + + /** + * 是否热门 + */ + private Integer isTrending; + + /** + * 热度分数 + */ + private Integer trendingScore; + + /** + * 总对话次数 + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long chatCount; + + /** + * 今日对话次数 + */ + private Integer chatCountToday; + + /** + * 本周对话次数 + */ + private Integer chatCountWeek; + + /** + * 使用用户数 + */ + private Integer userCount; + + /** + * 排序权重 + */ + private Integer sortWeight; + + /** + * 是否私有 + */ + private Boolean isPrivate; + + // ========== 新增标签相关字段 ========== + + /** + * 标签ID数组(新增字段) + */ + @JsonSerialize(contentUsing = ToStringSerializer.class) + private Long[] tagIds; + + /** + * 标签名称数组(新增字段) + */ + private String[] tagNames; + + /** + * 主要标签ID数组(新增字段) + */ + @JsonSerialize(contentUsing = ToStringSerializer.class) + private Long[] primaryTagIds; + + /** + * 标签摘要(新增字段) + */ + private String tagSummary; + + /** + * 创建者用户ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long createId; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedAt; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCharacterCode() { + return characterCode; + } + + public void setCharacterCode(String characterCode) { + this.characterCode = characterCode; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getPersona() { + return persona; + } + + public void setPersona(String persona) { + this.persona = persona; + } + + public String getPersonalityTraits() { + return personalityTraits; + } + + public void setPersonalityTraits(String personalityTraits) { + this.personalityTraits = personalityTraits; + } + + public String getSpeakingStyle() { + return speakingStyle; + } + + public void setSpeakingStyle(String speakingStyle) { + this.speakingStyle = speakingStyle; + } + + public String getExampleDialogues() { + return exampleDialogues; + } + + public void setExampleDialogues(String exampleDialogues) { + this.exampleDialogues = exampleDialogues; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getTagWeights() { + return tagWeights; + } + + public void setTagWeights(String tagWeights) { + this.tagWeights = tagWeights; + } + + public String getSearchKeywords() { + return searchKeywords; + } + + public void setSearchKeywords(String searchKeywords) { + this.searchKeywords = searchKeywords; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Long getDefaultModelId() { + return defaultModelId; + } + + public void setDefaultModelId(Long defaultModelId) { + this.defaultModelId = defaultModelId; + } + + public BigDecimal getTemperature() { + return temperature; + } + + public void setTemperature(BigDecimal temperature) { + this.temperature = temperature; + } + + public Integer getContextWindow() { + return contextWindow; + } + + public void setContextWindow(Integer contextWindow) { + this.contextWindow = contextWindow; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getStatusName() { + return statusName; + } + + public void setStatusName(String statusName) { + this.statusName = statusName; + } + + public Integer getIsOfficial() { + return isOfficial; + } + + public void setIsOfficial(Integer isOfficial) { + this.isOfficial = isOfficial; + } + + public Integer getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Integer isFeatured) { + this.isFeatured = isFeatured; + } + + public Integer getIsTrending() { + return isTrending; + } + + public void setIsTrending(Integer isTrending) { + this.isTrending = isTrending; + } + + public Integer getTrendingScore() { + return trendingScore; + } + + public void setTrendingScore(Integer trendingScore) { + this.trendingScore = trendingScore; + } + + public Long getChatCount() { + return chatCount; + } + + public void setChatCount(Long chatCount) { + this.chatCount = chatCount; + } + + public Integer getChatCountToday() { + return chatCountToday; + } + + public void setChatCountToday(Integer chatCountToday) { + this.chatCountToday = chatCountToday; + } + + public Integer getChatCountWeek() { + return chatCountWeek; + } + + public void setChatCountWeek(Integer chatCountWeek) { + this.chatCountWeek = chatCountWeek; + } + + public Integer getUserCount() { + return userCount; + } + + public void setUserCount(Integer userCount) { + this.userCount = userCount; + } + + public Integer getSortWeight() { + return sortWeight; + } + + public void setSortWeight(Integer sortWeight) { + this.sortWeight = sortWeight; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public Long getCreateId() { + return createId; + } + + public void setCreateId(Long createId) { + this.createId = createId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + // ========== 新增字段的getter和setter方法 ========== + + public Long[] getTagIds() { + return tagIds; + } + + public void setTagIds(Long[] tagIds) { + this.tagIds = tagIds; + } + + public String[] getTagNames() { + return tagNames; + } + + public void setTagNames(String[] tagNames) { + this.tagNames = tagNames; + } + + public Long[] getPrimaryTagIds() { + return primaryTagIds; + } + + public void setPrimaryTagIds(Long[] primaryTagIds) { + this.primaryTagIds = primaryTagIds; + } + + public String getTagSummary() { + return tagSummary; + } + + public void setTagSummary(String tagSummary) { + this.tagSummary = tagSummary; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterResponse.java b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterResponse.java new file mode 100644 index 0000000..fca67c9 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/dto/response/CharacterResponse.java @@ -0,0 +1,350 @@ +package com.vocata.character.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 角色响应DTO + */ +public class CharacterResponse { + + /** + * 角色ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 角色唯一编码 + */ + private String characterCode; + + /** + * 角色名称 + */ + private String name; + + /** + * 一句话简介 + */ + private String description; + + /** + * 开场白 + */ + private String greeting; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 标签数组JSON:["动漫","治愈","女友"] + */ + private String tags; + + /** + * 主要语言 + */ + private String language; + + /** + * 状态:1=已发布 2=审核中 3=已下架 + */ + private Integer status; + + /** + * 状态名称 + */ + private String statusName; + + /** + * 是否官方角色:0=否 1=是 + */ + private Integer isOfficial; + + /** + * 是否精选推荐:0=否 1=是 + */ + private Integer isFeatured; + + /** + * 是否热门:0=否 1=是 + */ + private Integer isTrending; + + /** + * 热度分数 + */ + private Integer trendingScore; + + /** + * 总对话次数 + */ + private Long chatCount; + + /** + * 使用用户数 + */ + private Integer userCount; + + /** + * 是否私有:false=公开 true=私有 + */ + private Boolean isPrivate; + + // ========== 新增标签相关字段 ========== + + /** + * 标签ID数组(新增字段) + */ + @JsonSerialize(contentUsing = ToStringSerializer.class) + private Long[] tagIds; + + /** + * 标签名称数组(新增字段) + */ + private String[] tagNames; + + /** + * 主要标签ID数组(新增字段) + */ + @JsonSerialize(contentUsing = ToStringSerializer.class) + private Long[] primaryTagIds; + + /** + * 标签摘要(新增字段) + */ + private String tagSummary; + + /** + * 创建者用户ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long createId; + + /** + * 创建者用户名 + */ + private String creatorName; + + /** + * 创建时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updatedAt; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCharacterCode() { + return characterCode; + } + + public void setCharacterCode(String characterCode) { + this.characterCode = characterCode; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public String getStatusName() { + return statusName; + } + + public void setStatusName(String statusName) { + this.statusName = statusName; + } + + public Integer getIsOfficial() { + return isOfficial; + } + + public void setIsOfficial(Integer isOfficial) { + this.isOfficial = isOfficial; + } + + public Integer getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Integer isFeatured) { + this.isFeatured = isFeatured; + } + + public Integer getIsTrending() { + return isTrending; + } + + public void setIsTrending(Integer isTrending) { + this.isTrending = isTrending; + } + + public Integer getTrendingScore() { + return trendingScore; + } + + public void setTrendingScore(Integer trendingScore) { + this.trendingScore = trendingScore; + } + + public Long getChatCount() { + return chatCount; + } + + public void setChatCount(Long chatCount) { + this.chatCount = chatCount; + } + + public Integer getUserCount() { + return userCount; + } + + public void setUserCount(Integer userCount) { + this.userCount = userCount; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public Long getCreateId() { + return createId; + } + + public void setCreateId(Long createId) { + this.createId = createId; + } + + public String getCreatorName() { + return creatorName; + } + + public void setCreatorName(String creatorName) { + this.creatorName = creatorName; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + // ========== 新增字段的getter和setter方法 ========== + + public Long[] getTagIds() { + return tagIds; + } + + public void setTagIds(Long[] tagIds) { + this.tagIds = tagIds; + } + + public String[] getTagNames() { + return tagNames; + } + + public void setTagNames(String[] tagNames) { + this.tagNames = tagNames; + } + + public Long[] getPrimaryTagIds() { + return primaryTagIds; + } + + public void setPrimaryTagIds(Long[] primaryTagIds) { + this.primaryTagIds = primaryTagIds; + } + + public String getTagSummary() { + return tagSummary; + } + + public void setTagSummary(String tagSummary) { + this.tagSummary = tagSummary; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/entity/Character.java b/vocata-server/src/main/java/com/vocata/character/entity/Character.java new file mode 100644 index 0000000..c2cb244 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/entity/Character.java @@ -0,0 +1,510 @@ +package com.vocata.character.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 角色实体类 + * 对应数据库表:vocata_character + * 注意:不继承BaseEntity,因为字段映射规则不同 + */ +@TableName("vocata_character") +public class Character { + + /** + * 角色ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 审计字段 - 映射到实际数据库字段 + */ + @TableField("create_id") + private Long createId; + + @TableField("created_at") + private LocalDateTime createDate; + + @TableField("updated_at") + private LocalDateTime updateDate; + + @TableLogic + @TableField("is_delete") + private Integer isDelete; + + /** + * 角色唯一编码,用于URL等 + */ + private String characterCode; + + /** + * 角色名称 + */ + private String name; + + /** + * 一句话简介 + */ + private String description; + + /** + * 开场白 + */ + private String greeting; + + /** + * 人设prompt(给LLM的核心指令) + */ + private String persona; + + /** + * 性格特征标签JSON数组:["温柔","智慧","幽默"] + */ + private String personalityTraits; + + /** + * 说话风格描述 + */ + private String speakingStyle; + + /** + * 示例对话JSON + */ + private String exampleDialogues; + + /** + * 角色头像URL + */ + private String avatarUrl; + + /** + * 语音ID(TTS服务) + */ + private String voiceId; + + /** + * 标签数组JSON:["动漫","治愈","女友"] + */ + private String tags; + + /** + * 标签权重JSON:{"动漫":10,"治愈":8} + */ + private String tagWeights; + + /** + * 搜索关键词,用于提升搜索准确度 + */ + private String searchKeywords; + + /** + * 主要语言 + */ + private String language; + + /** + * 默认模型ID(关联vocata_llm_model) + */ + private Long defaultModelId; + + /** + * 温度参数 + */ + private BigDecimal temperature; + + /** + * 上下文轮数 + */ + private Integer contextWindow; + + /** + * 状态:1=已发布 2=审核中 3=已下架 + */ + private Integer status; + + /** + * 是否官方角色:0=否 1=是 + */ + private Integer isOfficial; + + /** + * 是否精选推荐:0=否 1=是 + */ + private Integer isFeatured; + + /** + * 是否热门(自动计算):0=否 1=是 + */ + private Integer isTrending; + + /** + * 热度分数(每日更新) + */ + private Integer trendingScore; + + /** + * 总对话次数 + */ + private Long chatCount; + + /** + * 今日对话次数 + */ + private Integer chatCountToday; + + /** + * 本周对话次数 + */ + private Integer chatCountWeek; + + /** + * 使用用户数 + */ + private Integer userCount; + + /** + * 排序权重 + */ + private Integer sortWeight; + + /** + * 创建者用户ID,官方默认NULL + */ +// private Long createId; + + /** + * 是否私有:false=公开 true=私有 + */ + private Boolean isPrivate; + + /** + * 标签ID数组(新字段,支持数组查询) + */ + @TableField("tag_ids") + private Long[] tagIds; + + /** + * 标签名称数组(冗余字段,提升查询性能) + */ + @TableField("tag_names") + private String[] tagNames; + + /** + * 主要标签ID数组(核心标签,用于推荐算法) + */ + @TableField("primary_tag_ids") + private Long[] primaryTagIds; + + /** + * 标签摘要(自动生成,用于搜索优化) + */ + @TableField("tag_summary") + private String tagSummary; + + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCharacterCode() { + return characterCode; + } + + public void setCharacterCode(String characterCode) { + this.characterCode = characterCode; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getPersona() { + return persona; + } + + public void setPersona(String persona) { + this.persona = persona; + } + + public String getPersonalityTraits() { + return personalityTraits; + } + + public void setPersonalityTraits(String personalityTraits) { + this.personalityTraits = personalityTraits; + } + + public String getSpeakingStyle() { + return speakingStyle; + } + + public void setSpeakingStyle(String speakingStyle) { + this.speakingStyle = speakingStyle; + } + + public String getExampleDialogues() { + return exampleDialogues; + } + + public void setExampleDialogues(String exampleDialogues) { + this.exampleDialogues = exampleDialogues; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getTagWeights() { + return tagWeights; + } + + public void setTagWeights(String tagWeights) { + this.tagWeights = tagWeights; + } + + public String getSearchKeywords() { + return searchKeywords; + } + + public void setSearchKeywords(String searchKeywords) { + this.searchKeywords = searchKeywords; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public Long getDefaultModelId() { + return defaultModelId; + } + + public void setDefaultModelId(Long defaultModelId) { + this.defaultModelId = defaultModelId; + } + + public BigDecimal getTemperature() { + return temperature; + } + + public void setTemperature(BigDecimal temperature) { + this.temperature = temperature; + } + + public Integer getContextWindow() { + return contextWindow; + } + + public void setContextWindow(Integer contextWindow) { + this.contextWindow = contextWindow; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Integer getIsOfficial() { + return isOfficial; + } + + public void setIsOfficial(Integer isOfficial) { + this.isOfficial = isOfficial; + } + + public Integer getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Integer isFeatured) { + this.isFeatured = isFeatured; + } + + public Integer getIsTrending() { + return isTrending; + } + + public void setIsTrending(Integer isTrending) { + this.isTrending = isTrending; + } + + public Integer getTrendingScore() { + return trendingScore; + } + + public void setTrendingScore(Integer trendingScore) { + this.trendingScore = trendingScore; + } + + public Long getChatCount() { + return chatCount; + } + + public void setChatCount(Long chatCount) { + this.chatCount = chatCount; + } + + public Integer getChatCountToday() { + return chatCountToday; + } + + public void setChatCountToday(Integer chatCountToday) { + this.chatCountToday = chatCountToday; + } + + public Integer getChatCountWeek() { + return chatCountWeek; + } + + public void setChatCountWeek(Integer chatCountWeek) { + this.chatCountWeek = chatCountWeek; + } + + public Integer getUserCount() { + return userCount; + } + + public void setUserCount(Integer userCount) { + this.userCount = userCount; + } + + public Integer getSortWeight() { + return sortWeight; + } + + public void setSortWeight(Integer sortWeight) { + this.sortWeight = sortWeight; + } + + public Long getCreateId() { + return createId; + } + + public void setCreateId(Long createId) { + this.createId = createId; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + // 审计字段的getter和setter方法 + public LocalDateTime getCreateDate() { + return createDate; + } + + public void setCreateDate(LocalDateTime createDate) { + this.createDate = createDate; + } + + public LocalDateTime getUpdateDate() { + return updateDate; + } + + public void setUpdateDate(LocalDateTime updateDate) { + this.updateDate = updateDate; + } + + public Integer getIsDelete() { + return isDelete; + } + + public void setIsDelete(Integer isDelete) { + this.isDelete = isDelete; + } + + // 新增字段的getter和setter方法 + public Long[] getTagIds() { + return tagIds; + } + + public void setTagIds(Long[] tagIds) { + this.tagIds = tagIds; + } + + public String[] getTagNames() { + return tagNames; + } + + public void setTagNames(String[] tagNames) { + this.tagNames = tagNames; + } + + public Long[] getPrimaryTagIds() { + return primaryTagIds; + } + + public void setPrimaryTagIds(Long[] primaryTagIds) { + this.primaryTagIds = primaryTagIds; + } + + public String getTagSummary() { + return tagSummary; + } + + public void setTagSummary(String tagSummary) { + this.tagSummary = tagSummary; + } + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/entity/CharacterTag.java b/vocata-server/src/main/java/com/vocata/character/entity/CharacterTag.java new file mode 100644 index 0000000..b1b3764 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/entity/CharacterTag.java @@ -0,0 +1,198 @@ +package com.vocata.character.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 角色标签关联实体类 + * 对应数据库表:vocata_character_tag + */ +@TableName("vocata_character_tag") +public class CharacterTag { + + /** + * 关联ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 角色ID + */ + @TableField("character_id") + private Long characterId; + + /** + * 标签ID + */ + @TableField("tag_id") + private Long tagId; + + /** + * 标签权重:1.0-10.0,影响推荐算法 + */ + @TableField("tag_weight") + private BigDecimal tagWeight; + + /** + * 是否主要标签:0=否 1=是(最多3-5个) + */ + @TableField("is_primary") + private Integer isPrimary; + + /** + * 是否已验证:0=未验证 1=已验证(管理员审核) + */ + @TableField("is_verified") + private Integer isVerified; + + /** + * 标签来源:manual=手动添加, auto=自动生成, import=导入 + */ + private String source; + + /** + * 排序顺序 + */ + @TableField("sort_order") + private Integer sortOrder; + + /** + * 添加者用户ID + */ + @TableField("added_by") + private Long addedBy; + + /** + * 添加者类型:user=用户, admin=管理员, system=系统 + */ + @TableField("added_type") + private String addedType; + + /** + * 备注信息 + */ + private String notes; + + /** + * 创建时间 + */ + @TableField("created_at") + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField("updated_at") + private LocalDateTime updatedAt; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCharacterId() { + return characterId; + } + + public void setCharacterId(Long characterId) { + this.characterId = characterId; + } + + public Long getTagId() { + return tagId; + } + + public void setTagId(Long tagId) { + this.tagId = tagId; + } + + public BigDecimal getTagWeight() { + return tagWeight; + } + + public void setTagWeight(BigDecimal tagWeight) { + this.tagWeight = tagWeight; + } + + public Integer getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Integer isPrimary) { + this.isPrimary = isPrimary; + } + + public Integer getIsVerified() { + return isVerified; + } + + public void setIsVerified(Integer isVerified) { + this.isVerified = isVerified; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Long getAddedBy() { + return addedBy; + } + + public void setAddedBy(Long addedBy) { + this.addedBy = addedBy; + } + + public String getAddedType() { + return addedType; + } + + public void setAddedType(String addedType) { + this.addedType = addedType; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/entity/Tag.java b/vocata-server/src/main/java/com/vocata/character/entity/Tag.java new file mode 100644 index 0000000..fe25b44 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/entity/Tag.java @@ -0,0 +1,212 @@ +package com.vocata.character.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +/** + * 标签实体类 + * 对应数据库表:vocata_tag + */ +@TableName("vocata_tag") +public class Tag { + + /** + * 标签ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 标签名称 + */ + @TableField("tag_name") + private String tagName; + + /** + * 标签类型:category、emotion、character、style等 + */ + @TableField("tag_type") + private String tagType; + + /** + * 标签分类:动漫、游戏、现实等 + */ + @TableField("tag_category") + private String tagCategory; + + /** + * 标签描述 + */ + private String description; + + /** + * 颜色代码:#FF5722 + */ + @TableField("color_code") + private String colorCode; + + /** + * 图标名称 + */ + @TableField("icon_name") + private String iconName; + + /** + * 父标签ID(支持层级关系) + */ + @TableField("parent_id") + private Long parentId; + + /** + * 是否系统标签:0=用户创建 1=系统内置 + */ + @TableField("is_system") + private Integer isSystem; + + /** + * 是否启用:0=禁用 1=启用 + */ + @TableField("is_active") + private Integer isActive; + + /** + * 排序权重 + */ + @TableField("sort_order") + private Integer sortOrder; + + /** + * 创建者用户ID + */ + @TableField("created_by") + private Long createdBy; + + /** + * 创建时间 + */ + @TableField("created_at") + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField("updated_at") + private LocalDateTime updatedAt; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTagName() { + return tagName; + } + + public void setTagName(String tagName) { + this.tagName = tagName; + } + + public String getTagType() { + return tagType; + } + + public void setTagType(String tagType) { + this.tagType = tagType; + } + + public String getTagCategory() { + return tagCategory; + } + + public void setTagCategory(String tagCategory) { + this.tagCategory = tagCategory; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getColorCode() { + return colorCode; + } + + public void setColorCode(String colorCode) { + this.colorCode = colorCode; + } + + public String getIconName() { + return iconName; + } + + public void setIconName(String iconName) { + this.iconName = iconName; + } + + public Long getParentId() { + return parentId; + } + + public void setParentId(Long parentId) { + this.parentId = parentId; + } + + public Integer getIsSystem() { + return isSystem; + } + + public void setIsSystem(Integer isSystem) { + this.isSystem = isSystem; + } + + public Integer getIsActive() { + return isActive; + } + + public void setIsActive(Integer isActive) { + this.isActive = isActive; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Long getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(Long createdBy) { + this.createdBy = createdBy; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/entity/TagStats.java b/vocata-server/src/main/java/com/vocata/character/entity/TagStats.java new file mode 100644 index 0000000..3031b3e --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/entity/TagStats.java @@ -0,0 +1,411 @@ +package com.vocata.character.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 标签统计实体类 + * 对应数据库表:vocata_tag_stats + */ +@TableName("vocata_tag_stats") +public class TagStats { + + /** + * 统计记录ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 标签ID + */ + @TableField("tag_id") + private Long tagId; + + /** + * 统计日期 + */ + @TableField("stat_date") + private LocalDate statDate; + + /** + * 关联角色数量 + */ + @TableField("character_count") + private Integer characterCount; + + /** + * 新增角色数量(当日) + */ + @TableField("new_character_count") + private Integer newCharacterCount; + + /** + * 移除角色数量(当日) + */ + @TableField("removed_character_count") + private Integer removedCharacterCount; + + /** + * 搜索次数 + */ + @TableField("search_count") + private Long searchCount; + + /** + * 点击次数 + */ + @TableField("click_count") + private Long clickCount; + + /** + * 筛选次数 + */ + @TableField("filter_count") + private Long filterCount; + + /** + * 浏览次数 + */ + @TableField("view_count") + private Long viewCount; + + /** + * 对话开始次数 + */ + @TableField("chat_start_count") + private Long chatStartCount; + + /** + * 收藏次数 + */ + @TableField("favorite_count") + private Integer favoriteCount; + + /** + * 分享次数 + */ + @TableField("share_count") + private Integer shareCount; + + /** + * 日热度分数 + */ + @TableField("daily_score") + private BigDecimal dailyScore; + + /** + * 周热度分数 + */ + @TableField("weekly_score") + private BigDecimal weeklyScore; + + /** + * 月热度分数 + */ + @TableField("monthly_score") + private BigDecimal monthlyScore; + + /** + * 趋势分数(综合算法) + */ + @TableField("trending_score") + private BigDecimal trendingScore; + + /** + * 日增长率(%) + */ + @TableField("daily_growth_rate") + private BigDecimal dailyGrowthRate; + + /** + * 周增长率(%) + */ + @TableField("weekly_growth_rate") + private BigDecimal weeklyGrowthRate; + + /** + * 平均角色评分 + */ + @TableField("avg_character_rating") + private BigDecimal avgCharacterRating; + + /** + * 平均会话时长(分钟) + */ + @TableField("avg_session_duration") + private Integer avgSessionDuration; + + /** + * 平均消息数量 + */ + @TableField("avg_message_count") + private Integer avgMessageCount; + + /** + * 用户偏好分数 + */ + @TableField("user_preference_score") + private BigDecimal userPreferenceScore; + + /** + * 男性用户占比 + */ + @TableField("male_user_ratio") + private BigDecimal maleUserRatio; + + /** + * 女性用户占比 + */ + @TableField("female_user_ratio") + private BigDecimal femaleUserRatio; + + /** + * 年龄分布统计(JSON格式) + */ + @TableField("age_distribution") + private String ageDistribution; + + /** + * 创建时间 + */ + @TableField("created_at") + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField("updated_at") + private LocalDateTime updatedAt; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getTagId() { + return tagId; + } + + public void setTagId(Long tagId) { + this.tagId = tagId; + } + + public LocalDate getStatDate() { + return statDate; + } + + public void setStatDate(LocalDate statDate) { + this.statDate = statDate; + } + + public Integer getCharacterCount() { + return characterCount; + } + + public void setCharacterCount(Integer characterCount) { + this.characterCount = characterCount; + } + + public Integer getNewCharacterCount() { + return newCharacterCount; + } + + public void setNewCharacterCount(Integer newCharacterCount) { + this.newCharacterCount = newCharacterCount; + } + + public Integer getRemovedCharacterCount() { + return removedCharacterCount; + } + + public void setRemovedCharacterCount(Integer removedCharacterCount) { + this.removedCharacterCount = removedCharacterCount; + } + + public Long getSearchCount() { + return searchCount; + } + + public void setSearchCount(Long searchCount) { + this.searchCount = searchCount; + } + + public Long getClickCount() { + return clickCount; + } + + public void setClickCount(Long clickCount) { + this.clickCount = clickCount; + } + + public Long getFilterCount() { + return filterCount; + } + + public void setFilterCount(Long filterCount) { + this.filterCount = filterCount; + } + + public Long getViewCount() { + return viewCount; + } + + public void setViewCount(Long viewCount) { + this.viewCount = viewCount; + } + + public Long getChatStartCount() { + return chatStartCount; + } + + public void setChatStartCount(Long chatStartCount) { + this.chatStartCount = chatStartCount; + } + + public Integer getFavoriteCount() { + return favoriteCount; + } + + public void setFavoriteCount(Integer favoriteCount) { + this.favoriteCount = favoriteCount; + } + + public Integer getShareCount() { + return shareCount; + } + + public void setShareCount(Integer shareCount) { + this.shareCount = shareCount; + } + + public BigDecimal getDailyScore() { + return dailyScore; + } + + public void setDailyScore(BigDecimal dailyScore) { + this.dailyScore = dailyScore; + } + + public BigDecimal getWeeklyScore() { + return weeklyScore; + } + + public void setWeeklyScore(BigDecimal weeklyScore) { + this.weeklyScore = weeklyScore; + } + + public BigDecimal getMonthlyScore() { + return monthlyScore; + } + + public void setMonthlyScore(BigDecimal monthlyScore) { + this.monthlyScore = monthlyScore; + } + + public BigDecimal getTrendingScore() { + return trendingScore; + } + + public void setTrendingScore(BigDecimal trendingScore) { + this.trendingScore = trendingScore; + } + + public BigDecimal getDailyGrowthRate() { + return dailyGrowthRate; + } + + public void setDailyGrowthRate(BigDecimal dailyGrowthRate) { + this.dailyGrowthRate = dailyGrowthRate; + } + + public BigDecimal getWeeklyGrowthRate() { + return weeklyGrowthRate; + } + + public void setWeeklyGrowthRate(BigDecimal weeklyGrowthRate) { + this.weeklyGrowthRate = weeklyGrowthRate; + } + + public BigDecimal getAvgCharacterRating() { + return avgCharacterRating; + } + + public void setAvgCharacterRating(BigDecimal avgCharacterRating) { + this.avgCharacterRating = avgCharacterRating; + } + + public Integer getAvgSessionDuration() { + return avgSessionDuration; + } + + public void setAvgSessionDuration(Integer avgSessionDuration) { + this.avgSessionDuration = avgSessionDuration; + } + + public Integer getAvgMessageCount() { + return avgMessageCount; + } + + public void setAvgMessageCount(Integer avgMessageCount) { + this.avgMessageCount = avgMessageCount; + } + + public BigDecimal getUserPreferenceScore() { + return userPreferenceScore; + } + + public void setUserPreferenceScore(BigDecimal userPreferenceScore) { + this.userPreferenceScore = userPreferenceScore; + } + + public BigDecimal getMaleUserRatio() { + return maleUserRatio; + } + + public void setMaleUserRatio(BigDecimal maleUserRatio) { + this.maleUserRatio = maleUserRatio; + } + + public BigDecimal getFemaleUserRatio() { + return femaleUserRatio; + } + + public void setFemaleUserRatio(BigDecimal femaleUserRatio) { + this.femaleUserRatio = femaleUserRatio; + } + + public String getAgeDistribution() { + return ageDistribution; + } + + public void setAgeDistribution(String ageDistribution) { + this.ageDistribution = ageDistribution; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/mapper/CharacterMapper.java b/vocata-server/src/main/java/com/vocata/character/mapper/CharacterMapper.java new file mode 100644 index 0000000..6e69814 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/mapper/CharacterMapper.java @@ -0,0 +1,161 @@ +package com.vocata.character.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.character.entity.Character; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +/** + * 角色数据访问层接口 + * 继承MyBatis-Plus的BaseMapper,提供基础CRUD操作 + * 复杂查询通过Service层使用QueryWrapper实现 + */ +@Mapper +public interface CharacterMapper extends BaseMapper { + + /** + * 原子性地增加角色对话计数 + * @param characterId 角色ID + * @param increment 增加的数量 + * @return 影响的行数 + */ + @Update("UPDATE vocata_character SET " + + "chat_count = chat_count + #{increment}, " + + "chat_count_today = chat_count_today + #{increment}, " + + "chat_count_week = chat_count_week + #{increment}, " + + "updated_at = NOW() " + + "WHERE id = #{characterId}") + int incrementChatCount(@Param("characterId") Long characterId, @Param("increment") int increment); + + /** + * 更新角色标签信息(原子操作) + * 使用PostgreSQL数组类型转换 + * @param characterId 角色ID + * @param tagIds 标签ID数组(JSON格式) + * @param tagNames 标签名称数组(JSON格式) + * @param primaryTagIds 主要标签ID数组(JSON格式) + * @param tagSummary 标签摘要 + * @return 影响的行数 + */ + @Update("UPDATE vocata_character SET " + + "tag_ids = #{tagIds}::bigint[], " + + "tag_names = #{tagNames}::text[], " + + "primary_tag_ids = #{primaryTagIds}::bigint[], " + + "tag_summary = #{tagSummary}, " + + "updated_at = NOW() " + + "WHERE id = #{characterId}") + int updateCharacterTags(@Param("characterId") Long characterId, + @Param("tagIds") String tagIds, + @Param("tagNames") String tagNames, + @Param("primaryTagIds") String primaryTagIds, + @Param("tagSummary") String tagSummary); + + /** + * 获取公开角色列表(包含创建者名称) + * @param page 分页参数 + * @param status 角色状态 + * @param isFeatured 是否精选 + * @param orderBy 排序字段 + * @param orderDirection 排序方向 + * @return 角色列表(包含创建者名称) + */ + @Select("") + IPage> selectPublicCharactersWithCreator(Page page, + @Param("status") Integer status, + @Param("isFeatured") Integer isFeatured, + @Param("orderBy") String orderBy, + @Param("orderDirection") String orderDirection); + + /** + * 获取精选角色列表(包含创建者名称) + * @param limit 限制数量 + * @return 精选角色列表(包含创建者名称) + */ + @Select("SELECT c.*, " + + "CASE " + + " WHEN c.is_official = 1 THEN '官方' " + + " WHEN c.create_id IS NULL THEN '官方' " + + " ELSE COALESCE(u.nickname, u.username, '未知用户') " + + "END as creator_name " + + "FROM vocata_character c " + + "LEFT JOIN vocata_user u ON c.create_id = u.id " + + "WHERE c.is_private = false " + + "AND c.status = 1 " + + "AND c.is_featured = 1 " + + "AND c.is_delete = 0 " + + "ORDER BY c.sort_weight DESC, c.created_at DESC " + + "LIMIT #{limit}") + List> selectFeaturedCharactersWithCreator(@Param("limit") int limit); + + /** + * 获取热门角色列表(包含创建者名称) + * @param limit 限制数量 + * @return 热门角色列表(包含创建者名称) + */ + @Select("SELECT c.*, " + + "CASE " + + " WHEN c.is_official = 1 THEN '官方' " + + " WHEN c.create_id IS NULL THEN '官方' " + + " ELSE COALESCE(u.nickname, u.username, '未知用户') " + + "END as creator_name " + + "FROM vocata_character c " + + "LEFT JOIN vocata_user u ON c.create_id = u.id " + + "WHERE c.is_private = false " + + "AND c.status = 1 " + + "AND c.is_trending = 1 " + + "AND c.is_delete = 0 " + + "ORDER BY c.trending_score DESC " + + "LIMIT #{limit}") + List> selectTrendingCharactersWithCreator(@Param("limit") int limit); + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/mapper/CharacterTagMapper.java b/vocata-server/src/main/java/com/vocata/character/mapper/CharacterTagMapper.java new file mode 100644 index 0000000..459e9e3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/mapper/CharacterTagMapper.java @@ -0,0 +1,15 @@ +package com.vocata.character.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.character.entity.CharacterTag; +import org.apache.ibatis.annotations.Mapper; + +/** + * 角色标签关联数据访问层接口 + * 继承MyBatis-Plus的BaseMapper,提供基础CRUD操作 + * 复杂查询通过Service层使用QueryWrapper实现 + */ +@Mapper +public interface CharacterTagMapper extends BaseMapper { + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/mapper/TagMapper.java b/vocata-server/src/main/java/com/vocata/character/mapper/TagMapper.java new file mode 100644 index 0000000..8850fab --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/mapper/TagMapper.java @@ -0,0 +1,15 @@ +package com.vocata.character.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.character.entity.Tag; +import org.apache.ibatis.annotations.Mapper; + +/** + * 标签数据访问层接口 + * 继承MyBatis-Plus的BaseMapper,提供基础CRUD操作 + * 复杂查询通过Service层使用QueryWrapper实现 + */ +@Mapper +public interface TagMapper extends BaseMapper { + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/mapper/TagStatsMapper.java b/vocata-server/src/main/java/com/vocata/character/mapper/TagStatsMapper.java new file mode 100644 index 0000000..834f6dc --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/mapper/TagStatsMapper.java @@ -0,0 +1,15 @@ +package com.vocata.character.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.character.entity.TagStats; +import org.apache.ibatis.annotations.Mapper; + +/** + * 标签统计数据访问层接口 + * 继承MyBatis-Plus的BaseMapper,提供基础CRUD操作 + * 复杂查询通过Service层使用QueryWrapper实现 + */ +@Mapper +public interface TagStatsMapper extends BaseMapper { + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/service/CharacterAiGenerateService.java b/vocata-server/src/main/java/com/vocata/character/service/CharacterAiGenerateService.java new file mode 100644 index 0000000..e28be26 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/service/CharacterAiGenerateService.java @@ -0,0 +1,18 @@ +package com.vocata.character.service; + +import com.vocata.character.dto.request.CharacterAiGenerateRequest; +import com.vocata.character.dto.response.CharacterAiGenerateResponse; + +/** + * AI角色生成服务接口 + */ +public interface CharacterAiGenerateService { + + /** + * 使用AI生成角色详细设定 + * + * @param request 角色生成请求,包含基本角色信息 + * @return 生成的角色详细设定响应 + */ + CharacterAiGenerateResponse generateCharacter(CharacterAiGenerateRequest request); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/service/CharacterChatCountService.java b/vocata-server/src/main/java/com/vocata/character/service/CharacterChatCountService.java new file mode 100644 index 0000000..2e9f688 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/service/CharacterChatCountService.java @@ -0,0 +1,498 @@ +package com.vocata.character.service; + +import cn.hutool.core.util.StrUtil; +import com.vocata.character.entity.Character; +import com.vocata.character.mapper.CharacterMapper; +import com.vocata.character.constants.ChatCountCacheConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 角色聊天计数缓存服务 + * + * 实现功能: + * 1. Redis缓存聊天计数,实时更新 + * 2. 定时同步数据库(每天2点) + * 3. 项目启动时预热缓存 + * 4. 防缓存穿透、击穿、雪崩 + */ +@Service +public class CharacterChatCountService { + + private static final Logger logger = LoggerFactory.getLogger(CharacterChatCountService.class); + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private CharacterMapper characterMapper; + + @Autowired + private Environment environment; + + // 根据环境获取配置 + private long getTotalExpireSeconds() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.TOTAL_EXPIRE_SECONDS; + case "prod": + return ChatCountCacheConstants.Production.TOTAL_EXPIRE_SECONDS; + default: + return ChatCountCacheConstants.Development.TOTAL_EXPIRE_SECONDS; + } + } + return ChatCountCacheConstants.Development.TOTAL_EXPIRE_SECONDS; + } + + private long getTodayExpireSeconds() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.TODAY_EXPIRE_SECONDS; + case "prod": + return ChatCountCacheConstants.Production.TODAY_EXPIRE_SECONDS; + default: + return ChatCountCacheConstants.Development.TODAY_EXPIRE_SECONDS; + } + } + return ChatCountCacheConstants.Development.TODAY_EXPIRE_SECONDS; + } + + private long getLockExpireSeconds() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.LOCK_EXPIRE_SECONDS; + case "prod": + return ChatCountCacheConstants.Production.LOCK_EXPIRE_SECONDS; + default: + return ChatCountCacheConstants.Development.LOCK_EXPIRE_SECONDS; + } + } + return ChatCountCacheConstants.Development.LOCK_EXPIRE_SECONDS; + } + + private long getNullCacheExpireSeconds() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.NULL_CACHE_EXPIRE_SECONDS; + case "prod": + return ChatCountCacheConstants.Production.NULL_CACHE_EXPIRE_SECONDS; + default: + return ChatCountCacheConstants.Development.NULL_CACHE_EXPIRE_SECONDS; + } + } + return ChatCountCacheConstants.Development.NULL_CACHE_EXPIRE_SECONDS; + } + + private long getRandomExpireRange() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.RANDOM_EXPIRE_RANGE; + case "prod": + return ChatCountCacheConstants.Production.RANDOM_EXPIRE_RANGE; + default: + return ChatCountCacheConstants.Development.RANDOM_EXPIRE_RANGE; + } + } + return ChatCountCacheConstants.Development.RANDOM_EXPIRE_RANGE; + } + + private boolean isDetailedLoggingEnabled() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.DETAILED_LOGGING; + case "prod": + return ChatCountCacheConstants.Production.DETAILED_LOGGING; + default: + return ChatCountCacheConstants.Development.DETAILED_LOGGING; + } + } + return ChatCountCacheConstants.Development.DETAILED_LOGGING; + } + + private boolean isWarmupEnabled() { + String[] activeProfiles = environment.getActiveProfiles(); + if (activeProfiles.length > 0) { + String profile = activeProfiles[0]; + switch (profile) { + case "test": + return ChatCountCacheConstants.Test.WARMUP_ENABLED; + case "prod": + return ChatCountCacheConstants.Production.WARMUP_ENABLED; + default: + return ChatCountCacheConstants.Development.WARMUP_ENABLED; + } + } + return ChatCountCacheConstants.Development.WARMUP_ENABLED; + } + + /** + * 项目启动时预热缓存 + */ + @PostConstruct + public void warmUpCache() { + if (!isWarmupEnabled()) { + logger.info("缓存预热已禁用,跳过预热"); + return; + } + + logger.info("开始预热角色聊天计数缓存..."); + try { + List characters = characterMapper.selectList(null); + int loadCount = 0; + int existCount = 0; + + logger.info("从数据库查询到{}个角色", characters.size()); + + for (Character character : characters) { + if (character.getId() != null) { + String key = ChatCountCacheConstants.CHAT_COUNT_PREFIX + character.getId(); + + if (!redisTemplate.hasKey(key)) { + Long chatCount = character.getChatCount() != null ? character.getChatCount() : 0L; + + long expireTime = getTotalExpireSeconds() + (long) (Math.random() * getRandomExpireRange()); + redisTemplate.opsForValue().set(key, chatCount, expireTime, TimeUnit.SECONDS); + loadCount++; + logger.debug("为角色{}预热缓存,计数: {}", character.getId(), chatCount); + } else { + existCount++; + } + } + } + + logger.info("缓存预热完成,共加载{}个角色的聊天计数,{}个已存在", loadCount, existCount); + } catch (Exception e) { + logger.error("缓存预热失败", e); + } + } + + /** + * 增加角色聊天计数 + * + * @param characterId 角色ID + * @return 增加后的计数 + */ + public Long incrementChatCount(Long characterId) { + if (characterId == null) { + logger.warn("角色ID为空,无法增加聊天计数"); + return 0L; + } + + logger.info("开始增加角色{}的聊天计数", characterId); + + try { + String key = ChatCountCacheConstants.CHAT_COUNT_PREFIX + characterId; + String todayKey = ChatCountCacheConstants.CHAT_COUNT_TODAY_PREFIX + characterId + ":" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + logger.debug("Redis keys: total={}, today={}", key, todayKey); + + // 先检查Redis连接 + try { + redisTemplate.hasKey(key); + logger.debug("Redis连接正常"); + } catch (Exception e) { + logger.error("Redis连接失败", e); + throw e; + } + + // 使用Redis管道操作提高性能 + List results = redisTemplate.executePipelined((RedisCallback) connection -> { + // 增加总计数 + connection.incr(key.getBytes()); + // 增加今日计数 + connection.incr(todayKey.getBytes()); + return null; + }); + + logger.debug("Pipeline执行结果: {}", results); + + // 设置今日计数过期时间(次日凌晨过期) + redisTemplate.expire(todayKey, Duration.ofHours(25)); + + Long newCount = (Long) results.get(0); + logger.info("角色{}聊天计数增加成功,当前总计数: {}", characterId, newCount); + + // 检查缓存是否存在,如果不存在则从数据库加载 + if (newCount == 1) { + logger.debug("首次访问角色{},从数据库加载初始计数", characterId); + Long dbCount = getChatCountFromDatabase(characterId); + if (dbCount != null && dbCount > 0) { + logger.debug("数据库中角色{}的计数为: {}", characterId, dbCount); + redisTemplate.opsForValue().set(key, dbCount + 1, + getTotalExpireSeconds() + (long) (Math.random() * getRandomExpireRange()), TimeUnit.SECONDS); + newCount = dbCount + 1; + logger.info("角色{}计数已更新为: {}", characterId, newCount); + } + } + + return newCount; + + } catch (Exception e) { + logger.error("增加角色{}聊天计数失败", characterId, e); + // 降级:异步写入数据库 + asyncIncrementDatabaseCount(characterId); + return null; + } + } + + /** + * 获取角色聊天计数(带缓存穿透保护) + * + * @param characterId 角色ID + * @return 聊天计数 + */ + public Long getChatCount(Long characterId) { + if (characterId == null) { + return 0L; + } + + String key = ChatCountCacheConstants.CHAT_COUNT_PREFIX + characterId; + String lockKey = ChatCountCacheConstants.CHAT_COUNT_LOCK_PREFIX + characterId; + String nullKey = ChatCountCacheConstants.CHAT_COUNT_NULL_PREFIX + characterId; + + try { + // 1. 先检查缓存 + Object cached = redisTemplate.opsForValue().get(key); + if (cached != null) { + return (Long) cached; + } + + // 2. 检查空值缓存(防穿透) + if (redisTemplate.hasKey(nullKey)) { + if (isDetailedLoggingEnabled()) { + logger.debug("角色{}命中空值缓存", characterId); + } + return 0L; + } + + // 3. 获取分布式锁(防击穿) + String lockValue = UUID.randomUUID().toString(); + Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, getLockExpireSeconds(), TimeUnit.SECONDS); + + if (Boolean.TRUE.equals(lockAcquired)) { + try { + // 双重检查 + cached = redisTemplate.opsForValue().get(key); + if (cached != null) { + return (Long) cached; + } + + // 从数据库获取 + Long dbCount = getChatCountFromDatabase(characterId); + + if (dbCount != null) { + // 设置缓存,添加随机过期时间 + long expireTime = getTotalExpireSeconds() + (long) (Math.random() * getRandomExpireRange()); + redisTemplate.opsForValue().set(key, dbCount, expireTime, TimeUnit.SECONDS); + return dbCount; + } else { + // 设置空值缓存 + redisTemplate.opsForValue().set(nullKey, "null", getNullCacheExpireSeconds(), TimeUnit.SECONDS); + return 0L; + } + } finally { + // 释放锁 + releaseLock(lockKey, lockValue); + } + } else { + // 未获取到锁,等待并重试 + Thread.sleep(50); + return getChatCount(characterId); + } + + } catch (Exception e) { + logger.error("获取角色{}聊天计数失败", characterId, e); + // 降级:直接从数据库获取 + Long dbCount = getChatCountFromDatabase(characterId); + return dbCount != null ? dbCount : 0L; + } + } + + /** + * 获取今日聊天计数 + * + * @param characterId 角色ID + * @return 今日聊天计数 + */ + public Long getTodayChatCount(Long characterId) { + if (characterId == null) { + return 0L; + } + + String todayKey = ChatCountCacheConstants.CHAT_COUNT_TODAY_PREFIX + characterId + ":" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + try { + Object cached = redisTemplate.opsForValue().get(todayKey); + return cached != null ? (Long) cached : 0L; + } catch (Exception e) { + logger.error("获取角色{}今日聊天计数失败", characterId, e); + return 0L; + } + } + + /** + * 同步缓存数据到数据库 + * + * @param characterId 角色ID(为空则同步所有) + */ + public void syncCacheToDatabase(Long characterId) { + try { + if (characterId != null) { + syncSingleCharacter(characterId); + } else { + syncAllCharacters(); + } + } catch (Exception e) { + logger.error("同步缓存到数据库失败", e); + } + } + + /** + * 从数据库获取聊天计数 + */ + private Long getChatCountFromDatabase(Long characterId) { + try { + Character character = characterMapper.selectById(characterId); + return character != null ? (character.getChatCount() != null ? character.getChatCount() : 0L) : null; + } catch (Exception e) { + logger.error("从数据库获取角色{}聊天计数失败", characterId, e); + return null; + } + } + + /** + * 异步增加数据库计数(降级方案) + */ + @Async + public void asyncIncrementDatabaseCount(Long characterId) { + try { + Character character = characterMapper.selectById(characterId); + if (character != null) { + Long currentCount = character.getChatCount() != null ? character.getChatCount() : 0L; + character.setChatCount(currentCount + 1); + characterMapper.updateById(character); + logger.info("异步更新角色{}数据库聊天计数成功", characterId); + } + } catch (Exception e) { + logger.error("异步更新角色{}数据库聊天计数失败", characterId, e); + } + } + + /** + * 同步单个角色 + */ + private void syncSingleCharacter(Long characterId) { + String key = ChatCountCacheConstants.CHAT_COUNT_PREFIX + characterId; + Object cached = redisTemplate.opsForValue().get(key); + + if (cached != null) { + Long cacheCount = (Long) cached; + Character character = characterMapper.selectById(characterId); + + if (character != null) { + character.setChatCount(cacheCount); + characterMapper.updateById(character); + logger.debug("同步角色{}聊天计数到数据库: {}", characterId, cacheCount); + } + } + } + + /** + * 同步所有角色 + */ + private void syncAllCharacters() { + Set keys = redisTemplate.keys(ChatCountCacheConstants.CHAT_COUNT_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + logger.info("没有需要同步的聊天计数缓存"); + return; + } + + int syncCount = 0; + for (String key : keys) { + try { + String characterIdStr = key.replace(ChatCountCacheConstants.CHAT_COUNT_PREFIX, ""); + if (StrUtil.isNumeric(characterIdStr)) { + Long characterId = Long.parseLong(characterIdStr); + syncSingleCharacter(characterId); + syncCount++; + } + } catch (Exception e) { + logger.error("同步聊天计数失败,key: {}", key, e); + } + } + + logger.info("同步聊天计数到数据库完成,共同步{}个角色", syncCount); + } + + /** + * 释放分布式锁 + */ + private void releaseLock(String lockKey, String lockValue) { + String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; + DefaultRedisScript redisScript = new DefaultRedisScript<>(script, Long.class); + redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue); + } + + /** + * 清理过期的今日计数缓存 + */ + public void cleanupExpiredTodayCache() { + try { + String pattern = ChatCountCacheConstants.CHAT_COUNT_TODAY_PREFIX + "*"; + Set keys = redisTemplate.keys(pattern); + + if (keys == null || keys.isEmpty()) { + return; + } + + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + List expiredKeys = new ArrayList<>(); + + for (String key : keys) { + if (!key.endsWith(":" + today)) { + expiredKeys.add(key); + } + } + + if (!expiredKeys.isEmpty()) { + redisTemplate.delete(expiredKeys); + logger.info("清理过期今日计数缓存,共清理{}个key", expiredKeys.size()); + } + + } catch (Exception e) { + logger.error("清理过期今日计数缓存失败", e); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/service/CharacterService.java b/vocata-server/src/main/java/com/vocata/character/service/CharacterService.java new file mode 100644 index 0000000..6fad04d --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/service/CharacterService.java @@ -0,0 +1,205 @@ +package com.vocata.character.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.character.dto.request.CharacterCreateWithAiRequest; +import com.vocata.character.dto.response.CharacterAiGenerateResponse; +import com.vocata.character.dto.response.CharacterCreateWithAiResponse; +import com.vocata.character.entity.Character; + +import java.util.List; + +/** + * 角色服务接口 + */ +public interface CharacterService { + + /** + * 根据ID获取角色详情 + * @param id 角色ID + * @return 角色信息 + */ + Character getById(Long id); + + /** + * 根据角色编码获取角色详情 + * @param characterCode 角色编码 + * @return 角色信息 + */ + Character getByCharacterCode(String characterCode); + + /** + * 创建角色 + * @param character 角色信息 + * @return 创建的角色 + */ + Character create(Character character); + + /** + * 更新角色 + * @param character 角色信息 + * @return 更新后的角色 + */ + Character update(Character character); + + /** + * 删除角色(软删除) + * @param id 角色ID + * @return 是否删除成功 + */ + boolean delete(Long id); + + /** + * 分页查询公开角色列表 + * @param page 分页参数 + * @param status 角色状态,null表示不过滤 + * @param isFeatured 是否精选,null表示不过滤 + * @param tags 标签列表,null表示不过滤 + * @param orderBy 排序字段 + * @param orderDirection 排序方向 + * @return 角色分页列表 + */ + IPage getPublicCharacters(Page page, Integer status, Integer isFeatured, + List tags, String orderBy, String orderDirection); + + /** + * 分页查询用户创建的角色列表 + * @param page 分页参数 + * @param createId 创建者ID + * @param status 角色状态,null表示不过滤 + * @return 角色分页列表 + */ + IPage getCharactersByCreator(Page page, Long createId, Integer status); + + /** + * 全文搜索角色 + * @param page 分页参数 + * @param keyword 搜索关键词 + * @param status 角色状态,null表示不过滤 + * @return 角色分页列表 + */ + IPage searchCharacters(Page page, String keyword, Integer status); + + /** + * 获取热门角色列表 + * @param limit 限制数量 + * @return 热门角色列表 + */ + List getTrendingCharacters(int limit); + + /** + * 获取热门角色列表(包含创建者名称) + * @param limit 限制数量 + * @return 热门角色列表(包含创建者名称) + */ + List> getTrendingCharactersWithCreator(int limit); + + /** + * 获取精选角色列表 + * @param limit 限制数量 + * @return 精选角色列表 + */ + List getFeaturedCharacters(int limit); + + /** + * 获取精选角色列表(包含创建者名称) + * @param limit 限制数量 + * @return 精选角色列表(包含创建者名称) + */ + List> getFeaturedCharactersWithCreator(int limit); + + /** + * 分页查询公开角色列表(包含创建者名称) + * @param page 分页参数 + * @param status 角色状态,null表示不过滤 + * @param isFeatured 是否精选,null表示不过滤 + * @param tags 标签列表,null表示不过滤 + * @param orderBy 排序字段 + * @param orderDirection 排序方向 + * @return 角色分页列表(包含创建者名称) + */ + com.baomidou.mybatisplus.core.metadata.IPage> getPublicCharactersWithCreator( + com.baomidou.mybatisplus.extension.plugins.pagination.Page page, + Integer status, Integer isFeatured, + List tags, String orderBy, String orderDirection); + + /** + * 更新角色状态 + * @param id 角色ID + * @param status 新状态 + * @return 是否更新成功 + */ + boolean updateStatus(Long id, Integer status); + + /** + * 增加角色对话次数 + * @param characterId 角色ID + * @param increment 增量 + * @return 是否更新成功 + */ + boolean incrementChatCount(Long characterId, int increment); + + /** + * 检查用户是否有权限操作角色 + * @param characterId 角色ID + * @param userId 用户ID + * @return 是否有权限 + */ + boolean hasPermission(Long characterId, Long userId); + + // ========== 标签管理相关方法 ========== + + /** + * 同步角色标签信息 + * 将JSON格式标签转换为数组字段,保持数据一致性 + * @param characterId 角色ID + * @return 是否同步成功 + */ + boolean syncCharacterTags(Long characterId); + + /** + * 根据标签ID查询角色列表 + * @param page 分页参数 + * @param tagIds 标签ID数组 + * @param status 角色状态,null表示不过滤 + * @return 角色分页列表 + */ + IPage getCharactersByTagIds(Page page, Long[] tagIds, Integer status); + + /** + * 根据主要标签查询推荐角色 + * @param primaryTagIds 主要标签ID数组 + * @param limit 限制数量 + * @param excludeCharacterId 排除的角色ID + * @return 推荐角色列表 + */ + List getRecommendedCharacters(Long[] primaryTagIds, int limit, Long excludeCharacterId); + + /** + * 更新角色标签数组字段 + * @param characterId 角色ID + * @param tagIds 标签ID数组 + * @param tagNames 标签名称数组 + * @param primaryTagIds 主要标签ID数组 + * @param tagSummary 标签摘要 + * @return 是否更新成功 + */ + boolean updateCharacterTagFields(Long characterId, Long[] tagIds, String[] tagNames, + Long[] primaryTagIds, String tagSummary); + + /** + * 创建角色并异步生成AI设定 + * @param request 创建请求 + * @return 创建响应 + */ + CharacterCreateWithAiResponse createWithAi(CharacterCreateWithAiRequest request); + + /** + * 异步更新角色的AI生成字段 + * @param characterId 角色ID + * @param aiResponse AI生成的响应数据 + * @return 是否更新成功 + */ + boolean updateAiGeneratedFields(Long characterId, CharacterAiGenerateResponse aiResponse); + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/service/impl/CharacterAiGenerateServiceImpl.java b/vocata-server/src/main/java/com/vocata/character/service/impl/CharacterAiGenerateServiceImpl.java new file mode 100644 index 0000000..2ccd5be --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/service/impl/CharacterAiGenerateServiceImpl.java @@ -0,0 +1,310 @@ +package com.vocata.character.service.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.impl.SiliconFlowLlmProvider; +import com.vocata.character.dto.request.CharacterAiGenerateRequest; +import com.vocata.character.dto.response.CharacterAiGenerateResponse; +import com.vocata.character.service.CharacterAiGenerateService; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * AI角色生成服务实现类 + */ +@Service +public class CharacterAiGenerateServiceImpl implements CharacterAiGenerateService { + + private static final Logger logger = LoggerFactory.getLogger(CharacterAiGenerateServiceImpl.class); + + @Autowired + private SiliconFlowLlmProvider siliconFlowLlmProvider; + + @Autowired + private ObjectMapper objectMapper; + + @Value("${siliconflow.ai.default-model:Qwen/Qwen3-8B}") + private String defaultModel; + + /** + * AI角色生成提示词模板(结构化JSON输出) + */ + private static final String CHARACTER_GENERATION_PROMPT = """ + 你是一个专业的角色设计师,需要根据基本信息生成详细的角色设定。请严格按照JSON格式输出,不要添加任何格式说明或其他文字。 + + 角色基本信息: + - 名称:{name} + - 描述:{description} + - 问候语:{greeting} + + 请输出以下JSON格式,参考李白示例的风格: + + { + "personalityTraits": ["特征1", "特征2"], + "speakingStyle": "详细的说话风格描述,包括用词习惯、语气特点、称呼方式等", + "exampleDialogues": [ + {"user": "用户问题1", "assistant": "角色回答1"}, + {"user": "用户问题2", "assistant": "角色回答2"}, + {"user": "用户问题3", "assistant": "角色回答3"}, + {"user": "用户问题4", "assistant": "角色回答4"}, + {"user": "用户问题5", "assistant": "角色回答5"} + ], + "tags": ["标签1", "标签2"], + "searchKeywords": "用空格分隔的搜索关键词" + } + + 要求: + 1. personalityTraits:选择最突出的2个性格特征 + 2. speakingStyle:描述角色的说话风格和语言特点,80-150字 + 3. exampleDialogues:5个高质量的对话示例,体现角色特色 + 4. tags:2个最相关的分类标签 + 5. searchKeywords:提升搜索的关键词,用空格分隔 + + 只输出JSON,不要任何其他内容。 + """; + + @Override + public CharacterAiGenerateResponse generateCharacter(CharacterAiGenerateRequest request) { + logger.info("开始AI角色生成,角色名称: {}", request.getName()); + + long startTime = System.currentTimeMillis(); + + try { + // 检查硅基流动AI是否可用 + if (!siliconFlowLlmProvider.isAvailable()) { + throw new BizException(ApiCode.AI_SERVICE_ERROR, "硅基流动AI服务不可用"); + } + + // 构建AI请求 + UnifiedAiRequest aiRequest = buildAiRequest(request); + + // 验证模型配置 + if (!siliconFlowLlmProvider.validateModelConfig(aiRequest.getModelConfig())) { + throw new BizException(ApiCode.PARAM_ERROR, "AI模型配置无效"); + } + + // 调用AI生成内容(增强内容清洗) + String generatedContent = siliconFlowLlmProvider.streamChat(aiRequest) + .map(UnifiedAiStreamChunk::getContent) + .filter(Objects::nonNull) + .filter(content -> !content.trim().isEmpty()) + .filter(content -> !"null".equals(content)) // 过滤字符串"null" + .reduce("", (accumulated, chunk) -> accumulated + chunk) + .map(this::cleanGeneratedContent) // 进一步清洗内容 + .block(); + + if (generatedContent == null || generatedContent.trim().isEmpty()) { + throw new BizException(ApiCode.AI_SERVICE_ERROR, "AI生成内容为空"); + } + + long endTime = System.currentTimeMillis(); + long generationTime = endTime - startTime; + + logger.info("AI角色生成完成,耗时: {}ms,内容长度: {}", generationTime, generatedContent.length()); + + // 构建响应 + CharacterAiGenerateResponse response = new CharacterAiGenerateResponse(); + response.setName(request.getName()); + response.setDescription(request.getDescription()); + response.setGreeting(request.getGreeting()); + response.setGeneratedContent(generatedContent); + response.setPersona(extractPersona(generatedContent)); + response.setGenerationTime(generationTime); + response.setModelUsed(defaultModel); + + // 解析JSON内容并设置具体字段 + parseAndSetFields(response, generatedContent); + + return response; + + } catch (BizException e) { + logger.error("AI角色生成业务异常: {}", e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("AI角色生成失败", e); + throw new BizException(ApiCode.AI_SERVICE_ERROR, "AI角色生成失败: " + e.getMessage()); + } + } + + /** + * 构建AI请求对象 + */ + private UnifiedAiRequest buildAiRequest(CharacterAiGenerateRequest request) { + UnifiedAiRequest aiRequest = new UnifiedAiRequest(); + + // 构建用户消息,将模板中的占位符替换为实际值 + String userMessage = CHARACTER_GENERATION_PROMPT + .replace("{name}", request.getName()) + .replace("{description}", request.getDescription()) + .replace("{greeting}", request.getGreeting()); + + aiRequest.setUserMessage(userMessage); + + // 设置模型配置(优化速度) + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName(defaultModel); + modelConfig.setTemperature(0.7); // 降低温度提高一致性 + modelConfig.setMaxTokens(3000); // 减少token数量提高速度 + modelConfig.setTopP(0.8); // 适度降低采样范围 + + aiRequest.setModelConfig(modelConfig); + + return aiRequest; + } + + /** + * 清洗生成的内容,移除无效字符和格式化 + */ + private String cleanGeneratedContent(String content) { + if (content == null) { + return ""; + } + + return content + // 移除连续的null字符串 + .replaceAll("(?i)null+", "") + // 移除多余的空白字符 + .replaceAll("\\s{3,}", " ") + // 移除开头和结尾的空白 + .trim() + // 确保句子之间有适当的间隔 + .replaceAll("([。!?])([^\\s])", "$1 $2") + // 移除重复的换行符 + .replaceAll("\n{3,}", "\n\n"); + } + + /** + * 解析AI生成的JSON内容并设置响应字段 + */ + private void parseAndSetFields(CharacterAiGenerateResponse response, String generatedContent) { + try { + // 尝试从生成内容中提取JSON + String jsonContent = extractJsonFromContent(generatedContent); + if (jsonContent == null) { + logger.warn("无法从生成内容中提取JSON,使用默认值"); + setDefaultValues(response); + return; + } + + JsonNode jsonNode = objectMapper.readTree(jsonContent); + + // 解析personalityTraits + if (jsonNode.has("personalityTraits") && jsonNode.get("personalityTraits").isArray()) { + List traits = new ArrayList<>(); + jsonNode.get("personalityTraits").forEach(node -> traits.add(node.asText())); + response.setPersonalityTraits(traits); + } + + // 解析speakingStyle + if (jsonNode.has("speakingStyle")) { + response.setSpeakingStyle(jsonNode.get("speakingStyle").asText()); + } + + // 解析exampleDialogues + if (jsonNode.has("exampleDialogues") && jsonNode.get("exampleDialogues").isArray()) { + List dialogues = new ArrayList<>(); + jsonNode.get("exampleDialogues").forEach(dialogueNode -> { + if (dialogueNode.has("user") && dialogueNode.has("assistant")) { + dialogues.add(new CharacterAiGenerateResponse.DialogueExample( + dialogueNode.get("user").asText(), + dialogueNode.get("assistant").asText() + )); + } + }); + response.setExampleDialogues(dialogues); + } + + // 解析tags + if (jsonNode.has("tags") && jsonNode.get("tags").isArray()) { + List tags = new ArrayList<>(); + jsonNode.get("tags").forEach(node -> tags.add(node.asText())); + response.setTags(tags); + } + + // 解析searchKeywords + if (jsonNode.has("searchKeywords")) { + response.setSearchKeywords(jsonNode.get("searchKeywords").asText()); + } + + logger.info("JSON解析成功,设置了字段: personalityTraits={}, tags={}", + response.getPersonalityTraits(), response.getTags()); + + } catch (JsonProcessingException e) { + logger.error("解析AI生成的JSON内容失败", e); + setDefaultValues(response); + } + } + + /** + * 从生成内容中提取JSON字符串 + */ + private String extractJsonFromContent(String content) { + if (content == null || content.trim().isEmpty()) { + return null; + } + + // 尝试找到JSON的开始和结束位置 + int startIndex = content.indexOf("{"); + int endIndex = content.lastIndexOf("}"); + + if (startIndex != -1 && endIndex != -1 && startIndex < endIndex) { + return content.substring(startIndex, endIndex + 1); + } + + // 如果没有找到完整的JSON,尝试整个内容 + String trimmed = content.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed; + } + + return null; + } + + /** + * 设置默认值(当JSON解析失败时) + */ + private void setDefaultValues(CharacterAiGenerateResponse response) { + // 设置默认的性格特征 + response.setPersonalityTraits(Arrays.asList("智慧", "友善")); + + // 设置默认的说话风格 + response.setSpeakingStyle("温和友善,言辞得体,乐于助人。说话时会考虑对方的感受,用词恰当。"); + + // 设置默认的对话示例 + List defaultDialogues = Arrays.asList( + new CharacterAiGenerateResponse.DialogueExample("你好!", "你好!很高兴认识你!"), + new CharacterAiGenerateResponse.DialogueExample("你能帮助我吗?", "当然可以!我很乐意帮助你。请告诉我你需要什么帮助。"), + new CharacterAiGenerateResponse.DialogueExample("今天天气怎么样?", "今天是个美好的一天!无论天气如何,重要的是保持好心情。") + ); + response.setExampleDialogues(defaultDialogues); + + // 设置默认标签 + response.setTags(Arrays.asList("友善", "智能")); + + // 设置默认搜索关键词 + response.setSearchKeywords(response.getName() + " 角色 智能助手 友善"); + } + + /** + * 从生成的内容中提取人设prompt + */ + private String extractPersona(String generatedContent) { + // 先清洗内容 + String cleanedContent = cleanGeneratedContent(generatedContent); + // 简化版本:直接返回清洗后的完整内容作为persona + return cleanedContent; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/service/impl/CharacterServiceImpl.java b/vocata-server/src/main/java/com/vocata/character/service/impl/CharacterServiceImpl.java new file mode 100644 index 0000000..7deb5e4 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/service/impl/CharacterServiceImpl.java @@ -0,0 +1,766 @@ +package com.vocata.character.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vocata.character.dto.request.CharacterAiGenerateRequest; +import com.vocata.character.dto.request.CharacterCreateWithAiRequest; +import com.vocata.character.dto.response.CharacterAiGenerateResponse; +import com.vocata.character.dto.response.CharacterCreateWithAiResponse; +import com.vocata.character.entity.Character; +import com.vocata.character.mapper.CharacterMapper; +import com.vocata.character.service.CharacterAiGenerateService; +import com.vocata.character.service.CharacterService; +import com.vocata.character.service.CharacterChatCountService; +import com.vocata.common.constant.CharacterStatus; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.utils.UserContext; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 角色服务实现类 + */ +@Service +public class CharacterServiceImpl extends ServiceImpl implements CharacterService { + + private static final Logger logger = LoggerFactory.getLogger(CharacterServiceImpl.class); + + @Autowired + private CharacterChatCountService characterChatCountService; + + @Autowired + private CharacterAiGenerateService characterAiGenerateService; + + @Autowired + private ObjectMapper objectMapper; + + @Override + public Character getById(Long id) { + if (id == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + Character character = super.getById(id); + if (character != null) { + // 从Redis获取最新的聊天计数 + Long chatCount = characterChatCountService.getChatCount(id); + Long todayChatCount = characterChatCountService.getTodayChatCount(id); + + character.setChatCount(chatCount); + character.setChatCountToday(todayChatCount != null ? todayChatCount.intValue() : 0); + } + return character; + } + + @Override + public Character getByCharacterCode(String characterCode) { + if (StringUtils.isBlank(characterCode)) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + Character character = this.getOne(new LambdaQueryWrapper() + .eq(Character::getCharacterCode, characterCode) + .eq(Character::getStatus, CharacterStatus.PUBLISHED)); + + if (character != null) { + // 从Redis获取最新的聊天计数 + Long chatCount = characterChatCountService.getChatCount(character.getId()); + Long todayChatCount = characterChatCountService.getTodayChatCount(character.getId()); + + character.setChatCount(chatCount); + character.setChatCountToday(todayChatCount != null ? todayChatCount.intValue() : 0); + } + return character; + } + + @Override + public Character create(Character character) { + if (character == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + // 检查角色编码是否重复 + if (StringUtils.isNotBlank(character.getCharacterCode())) { + long count = this.count(new LambdaQueryWrapper() + .eq(Character::getCharacterCode, character.getCharacterCode())); + if (count > 0) { + throw new BizException(ApiCode.DATA_ALREADY_EXISTS.getCode(), "角色编码已存在"); + } + } + + // 设置默认值 + if (character.getStatus() == null) { + character.setStatus(CharacterStatus.UNDER_REVIEW); + } + if (character.getIsPrivate() == null) { + character.setIsPrivate(true); + } + if (character.getIsOfficial() == null) { + character.setIsOfficial(0); + } + if (character.getIsFeatured() == null) { + character.setIsFeatured(0); + } + if (character.getIsTrending() == null) { + character.setIsTrending(0); + } + if (character.getChatCount() == null) { + character.setChatCount(0L); + } + if (character.getUserCount() == null) { + character.setUserCount(0); + } + if (character.getSortWeight() == null) { + character.setSortWeight(0); + } + + // 设置创建者 + Long currentUserId = UserContext.getUserId(); + if (currentUserId != null) { + character.setCreateId(currentUserId); + } + + character.setCreateDate(LocalDateTime.now()); + character.setUpdateDate(LocalDateTime.now()); + + this.save(character); + return character; + } + + @Override + public Character update(Character character) { + if (character == null || character.getId() == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + Character existing = this.getById(character.getId()); + if (existing == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND.getCode(), "角色不存在"); + } + + // 权限检查 + if (!hasPermission(character.getId(), UserContext.getUserId())) { + throw new BizException(ApiCode.ACCESS_DENIED); + } + + // 检查角色编码是否重复(排除自己) + if (StringUtils.isNotBlank(character.getCharacterCode()) + && !character.getCharacterCode().equals(existing.getCharacterCode())) { + long count = this.count(new LambdaQueryWrapper() + .eq(Character::getCharacterCode, character.getCharacterCode()) + .ne(Character::getId, character.getId())); + if (count > 0) { + throw new BizException(ApiCode.DATA_ALREADY_EXISTS.getCode(), "角色编码已存在"); + } + } + + character.setUpdateDate(LocalDateTime.now()); + this.updateById(character); + return this.getById(character.getId()); + } + + @Override + public boolean delete(Long id) { + if (id == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + Character character = this.getById(id); + if (character == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND.getCode(), "角色不存在"); + } + + // 权限检查 + if (!hasPermission(id, UserContext.getUserId())) { + throw new BizException(ApiCode.ACCESS_DENIED); + } + + return this.removeById(id); + } + + @Override + public IPage getPublicCharacters(Page page, Integer status, Integer isFeatured, + List tags, String orderBy, String orderDirection) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Character::getIsPrivate, false); + + if (status != null) { + wrapper.eq(Character::getStatus, status); + } + if (isFeatured != null) { + wrapper.eq(Character::getIsFeatured, isFeatured); + } + // TODO: 标签过滤需要使用JSON查询,暂时跳过 + + // 动态排序 + applyOrderBy(wrapper, orderBy, orderDirection); + + return this.page(page, wrapper); + } + + @Override + public IPage getCharactersByCreator(Page page, Long createId, Integer status) { + if (createId == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Character::getCreateId, createId) + .orderByDesc(Character::getUpdateDate); + + if (status != null) { + wrapper.eq(Character::getStatus, status); + } + + return this.page(page, wrapper); + } + + @Override + public IPage searchCharacters(Page page, String keyword, Integer status) { + if (StringUtils.isBlank(keyword)) { + return getPublicCharacters(page, status, null, null, "chat_count", "desc"); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Character::getIsPrivate, false) + .and(w -> w.like(Character::getName, keyword) + .or() + .like(Character::getDescription, keyword) + .or() + .like(Character::getSearchKeywords, keyword)) + .orderByDesc(Character::getChatCount); + + if (status != null) { + wrapper.eq(Character::getStatus, status); + } + + return this.page(page, wrapper); + } + + @Override + public List getTrendingCharacters(int limit) { + Page page = new Page<>(1, limit); + IPage result = this.page(page, new LambdaQueryWrapper() + .eq(Character::getIsPrivate, false) + .eq(Character::getStatus, CharacterStatus.PUBLISHED) + .eq(Character::getIsTrending, 1) + .orderByDesc(Character::getTrendingScore)); + return result.getRecords(); + } + + @Override + public List> getTrendingCharactersWithCreator(int limit) { + return this.baseMapper.selectTrendingCharactersWithCreator(limit); + } + + @Override + public List getFeaturedCharacters(int limit) { + Page page = new Page<>(1, limit); + IPage result = this.page(page, new LambdaQueryWrapper() + .eq(Character::getIsPrivate, false) + .eq(Character::getStatus, CharacterStatus.PUBLISHED) + .eq(Character::getIsFeatured, 1) + .orderByDesc(Character::getSortWeight) + .orderByDesc(Character::getCreateDate)); + return result.getRecords(); + } + + @Override + public List> getFeaturedCharactersWithCreator(int limit) { + return this.baseMapper.selectFeaturedCharactersWithCreator(limit); + } + + @Override + public IPage> getPublicCharactersWithCreator(Page page, Integer status, + Integer isFeatured, List tags, + String orderBy, String orderDirection) { + // 设置默认排序参数 + if (StringUtils.isBlank(orderBy)) { + orderBy = "chat_count"; + } + if (StringUtils.isBlank(orderDirection)) { + orderDirection = "desc"; + } + + return this.baseMapper.selectPublicCharactersWithCreator(page, status, isFeatured, orderBy, orderDirection); + } + + @Override + public boolean updateStatus(Long id, Integer status) { + if (id == null || status == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + if (!CharacterStatus.isValidStatus(status)) { + throw new BizException(ApiCode.PARAM_ERROR.getCode(), "无效的状态值"); + } + + return this.update(new LambdaUpdateWrapper() + .eq(Character::getId, id) + .set(Character::getStatus, status) + .set(Character::getUpdateDate, LocalDateTime.now())); + } + + @Override + public boolean incrementChatCount(Long characterId, int increment) { + if (characterId == null || increment <= 0) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + // 使用Mapper原子操作避免并发问题 + return this.baseMapper.incrementChatCount(characterId, increment) > 0; + } + + @Override + public boolean hasPermission(Long characterId, Long userId) { + if (characterId == null || userId == null) { + return false; + } + + // 检查是否是管理员 + if (UserContext.isAdmin()) { + return true; + } + + // 检查是否是角色创建者 + Character character = this.getById(characterId); + if (character != null && userId.equals(character.getCreateId())) { + return true; + } + + return false; + } + + // ========== 标签管理相关方法实现 ========== + + @Override + public boolean syncCharacterTags(Long characterId) { + if (characterId == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + Character character = this.getById(characterId); + if (character == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "角色不存在"); + } + + try { + // 解析JSON标签数据 + Long[] tagIds = parseJsonToLongArray(character.getTags()); + String[] tagNames = parseJsonToStringArray(character.getTags()); + + // 生成标签摘要 + String tagSummary = generateTagSummary(tagNames); + + // 确定主要标签(前3个) + Long[] primaryTagIds = tagIds != null && tagIds.length > 0 ? + Arrays.copyOf(tagIds, Math.min(tagIds.length, 3)) : new Long[0]; + + // 更新数组字段 + return updateCharacterTagFields(characterId, tagIds, tagNames, primaryTagIds, tagSummary); + } catch (Exception e) { + // 标签同步失败不影响主流程,记录日志但不抛异常 + return false; + } + } + + @Override + public IPage getCharactersByTagIds(Page page, Long[] tagIds, Integer status) { + if (tagIds == null || tagIds.length == 0) { + return this.getPublicCharacters(page, status, null, null, "chat_count", "desc"); + } + + // 使用QueryWrapper查询包含任意标签的角色 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Character::getIsPrivate, false); + + if (status != null) { + wrapper.eq(Character::getStatus, status); + } + + // 这里需要自定义SQL或者先查询所有再过滤 + // 暂时使用简化逻辑:通过tagSummary字段进行模糊匹配 + if (tagIds.length > 0) { + wrapper.and(w -> { + for (Long tagId : tagIds) { + w.like(Character::getTagSummary, tagId.toString()).or(); + } + }); + } + + wrapper.orderByDesc(Character::getChatCount); + return this.page(page, wrapper); + } + + @Override + public List getRecommendedCharacters(Long[] primaryTagIds, int limit, Long excludeCharacterId) { + if (primaryTagIds == null || primaryTagIds.length == 0) { + return getTrendingCharacters(limit); + } + + Page page = new Page<>(1, limit); + IPage result = getCharactersByTagIds(page, primaryTagIds, CharacterStatus.PUBLISHED); + + List characters = result.getRecords(); + + // 排除指定角色 + if (excludeCharacterId != null) { + characters = characters.stream() + .filter(c -> !excludeCharacterId.equals(c.getId())) + .collect(Collectors.toList()); + } + + return characters; + } + + @Override + public boolean updateCharacterTagFields(Long characterId, Long[] tagIds, String[] tagNames, + Long[] primaryTagIds, String tagSummary) { + if (characterId == null) { + throw new BizException(ApiCode.PARAM_ERROR); + } + + try { + // 转换数组为JSON字符串用于PostgreSQL + String tagIdsJson = arrayToJson(tagIds); + String tagNamesJson = arrayToJson(tagNames); + String primaryTagIdsJson = arrayToJson(primaryTagIds); + + return this.baseMapper.updateCharacterTags(characterId, tagIdsJson, tagNamesJson, + primaryTagIdsJson, tagSummary) > 0; + } catch (Exception e) { + return false; + } + } + + // ========== 私有工具方法 ========== + + /** + * 解析JSON字符串为Long数组 + */ + private Long[] parseJsonToLongArray(String jsonString) { + if (StringUtils.isBlank(jsonString)) { + return new Long[0]; + } + try { + // 简化实现:假设JSON格式为 ["1","2","3"] + String cleaned = jsonString.replaceAll("[\\[\\]\"\\s]", ""); + if (StringUtils.isBlank(cleaned)) { + return new Long[0]; + } + return Arrays.stream(cleaned.split(",")) + .filter(StringUtils::isNotBlank) + .map(Long::parseLong) + .toArray(Long[]::new); + } catch (Exception e) { + return new Long[0]; + } + } + + /** + * 解析JSON字符串为String数组 + */ + private String[] parseJsonToStringArray(String jsonString) { + if (StringUtils.isBlank(jsonString)) { + return new String[0]; + } + try { + // 简化实现:假设JSON格式为 ["动漫","治愈","女友"] + String cleaned = jsonString.replaceAll("[\\[\\]\"]", ""); + if (StringUtils.isBlank(cleaned)) { + return new String[0]; + } + return Arrays.stream(cleaned.split(",")) + .filter(StringUtils::isNotBlank) + .map(String::trim) + .toArray(String[]::new); + } catch (Exception e) { + return new String[0]; + } + } + + /** + * 生成标签摘要 + */ + private String generateTagSummary(String[] tagNames) { + if (tagNames == null || tagNames.length == 0) { + return ""; + } + return String.join("、", Arrays.stream(tagNames) + .limit(5) // 最多5个标签 + .collect(Collectors.toList())); + } + + /** + * 数组转JSON字符串(用于PostgreSQL数组类型) + */ + private String arrayToJson(Object[] array) { + if (array == null || array.length == 0) { + return "{}"; + } + return "{" + Arrays.stream(array) + .map(Object::toString) + .collect(Collectors.joining(",")) + "}"; + } + + /** + * 应用动态排序 + */ + private void applyOrderBy(LambdaQueryWrapper wrapper, String orderBy, String orderDirection) { + // 设置默认值 + if (StringUtils.isBlank(orderBy)) { + orderBy = "chat_count"; + } + if (StringUtils.isBlank(orderDirection)) { + orderDirection = "desc"; + } + + boolean isAsc = "asc".equalsIgnoreCase(orderDirection); + + switch (orderBy.toLowerCase()) { + case "chat_count": + if (isAsc) { + wrapper.orderByAsc(Character::getChatCount); + } else { + wrapper.orderByDesc(Character::getChatCount); + } + break; + case "created_at": + if (isAsc) { + wrapper.orderByAsc(Character::getCreateDate); + } else { + wrapper.orderByDesc(Character::getCreateDate); + } + break; + case "updated_at": + if (isAsc) { + wrapper.orderByAsc(Character::getUpdateDate); + } else { + wrapper.orderByDesc(Character::getUpdateDate); + } + break; + case "trending_score": + if (isAsc) { + wrapper.orderByAsc(Character::getTrendingScore); + } else { + wrapper.orderByDesc(Character::getTrendingScore); + } + break; + case "sort_weight": + if (isAsc) { + wrapper.orderByAsc(Character::getSortWeight); + } else { + wrapper.orderByDesc(Character::getSortWeight); + } + break; + default: + // 默认按对话次数降序 + wrapper.orderByDesc(Character::getChatCount); + break; + } + + // 添加二级排序:创建时间降序 + wrapper.orderByDesc(Character::getCreateDate); + } + + @Override + public CharacterCreateWithAiResponse createWithAi(CharacterCreateWithAiRequest request) { + logger.info("开始创建带AI生成的角色,名称: {}", request.getName()); + + // 1. 先创建基础角色记录 + Character character = new Character(); + BeanUtils.copyProperties(request, character); + + // 生成唯一的角色编码 + character.setCharacterCode(generateCharacterCode(request.getName())); + + // 设置默认值 + character.setStatus(CharacterStatus.UNDER_REVIEW); + character.setIsPrivate(request.getIsPrivate() != null ? request.getIsPrivate() : false); + character.setIsOfficial(0); + character.setIsFeatured(0); + character.setIsTrending(0); + character.setTrendingScore(0); + character.setChatCount(0L); + character.setChatCountToday(0); + character.setChatCountWeek(0); + character.setUserCount(0); + character.setSortWeight(0); + character.setCreateId(UserContext.getUserId()); + + // 设置默认的模型配置 + character.setTemperature(new BigDecimal("0.7")); + character.setContextWindow(3000); + character.setLanguage("zh-CN"); + + // 保存角色记录 + boolean saved = this.save(character); + if (!saved) { + throw new BizException(ApiCode.ERROR, "角色创建失败"); + } + + logger.info("角色基础信息创建成功,ID: {}", character.getId()); + + // 2. 构建响应对象 + CharacterCreateWithAiResponse response = new CharacterCreateWithAiResponse(); + response.setCharacterId(character.getId().toString()); + response.setName(character.getName()); + response.setDescription(character.getDescription()); + response.setGreeting(character.getGreeting()); + response.setAvatarUrl(character.getAvatarUrl()); + response.setIsPrivate(character.getIsPrivate()); + response.setStatus(character.getStatus()); + response.setAiGenerationStatus("AI生成任务已启动,详细角色设定将在后台自动生成"); + + // 3. 异步启动AI生成任务 + asyncGenerateAiFields(character.getId(), request); + + return response; + } + + @Override + public boolean updateAiGeneratedFields(Long characterId, CharacterAiGenerateResponse aiResponse) { + logger.info("开始更新角色AI生成字段,角色ID: {}", characterId); + + try { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(Character::getId, characterId); + + // 设置persona + if (StringUtils.isNotBlank(aiResponse.getPersona())) { + updateWrapper.set(Character::getPersona, aiResponse.getPersona()); + } + + // 设置性格特征 + if (aiResponse.getPersonalityTraits() != null && !aiResponse.getPersonalityTraits().isEmpty()) { + String personalityTraitsJson = listToJson(aiResponse.getPersonalityTraits()); + updateWrapper.set(Character::getPersonalityTraits, personalityTraitsJson); + } + + // 设置说话风格 + if (StringUtils.isNotBlank(aiResponse.getSpeakingStyle())) { + updateWrapper.set(Character::getSpeakingStyle, aiResponse.getSpeakingStyle()); + } + + // 设置示例对话 + if (aiResponse.getExampleDialogues() != null && !aiResponse.getExampleDialogues().isEmpty()) { + String exampleDialoguesJson = dialogueListToJson(aiResponse.getExampleDialogues()); + updateWrapper.set(Character::getExampleDialogues, exampleDialoguesJson); + } + + // 设置标签 + if (aiResponse.getTags() != null && !aiResponse.getTags().isEmpty()) { + String tagsJson = listToJson(aiResponse.getTags()); + updateWrapper.set(Character::getTags, tagsJson); + } + + // 设置搜索关键词 + if (StringUtils.isNotBlank(aiResponse.getSearchKeywords())) { + updateWrapper.set(Character::getSearchKeywords, aiResponse.getSearchKeywords()); + } + + // 执行更新 + boolean updated = this.update(updateWrapper); + if (updated) { + logger.info("角色AI生成字段更新成功,角色ID: {}", characterId); + } else { + logger.error("角色AI生成字段更新失败,角色ID: {}", characterId); + } + + return updated; + + } catch (Exception e) { + logger.error("更新角色AI生成字段时发生异常,角色ID: " + characterId, e); + return false; + } + } + + /** + * 异步生成AI字段 + */ + @Async + public void asyncGenerateAiFields(Long characterId, CharacterCreateWithAiRequest request) { + logger.info("开始异步生成AI字段,角色ID: {}", characterId); + + try { + // 构建AI生成请求 + CharacterAiGenerateRequest aiRequest = new CharacterAiGenerateRequest(); + aiRequest.setName(request.getName()); + aiRequest.setDescription(request.getDescription()); + aiRequest.setGreeting(request.getGreeting()); + + // 调用AI生成服务 + CharacterAiGenerateResponse aiResponse = characterAiGenerateService.generateCharacter(aiRequest); + + // 更新数据库字段 + boolean updateResult = updateAiGeneratedFields(characterId, aiResponse); + + if (updateResult) { + logger.info("角色AI字段异步生成完成,角色ID: {}", characterId); + } else { + logger.error("角色AI字段异步更新失败,角色ID: {}", characterId); + } + + } catch (Exception e) { + logger.error("角色AI字段异步生成失败,角色ID: " + characterId, e); + } + } + + /** + * 生成唯一的角色编码 + */ + private String generateCharacterCode(String name) { + // 基于名称和时间戳生成唯一编码 + String baseCode = name.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5]", ""); + if (baseCode.length() > 10) { + baseCode = baseCode.substring(0, 10); + } + String timestamp = String.valueOf(System.currentTimeMillis()).substring(8); + String uuid = UUID.randomUUID().toString().substring(0, 4); + return baseCode + "_" + timestamp + "_" + uuid; + } + + /** + * 将List转换为JSON字符串 + */ + private String listToJson(List list) { + try { + return objectMapper.writeValueAsString(list); + } catch (JsonProcessingException e) { + logger.error("List转JSON失败", e); + return "[]"; + } + } + + /** + * 将对话列表转换为JSON字符串 + */ + private String dialogueListToJson(List dialogues) { + try { + return objectMapper.writeValueAsString(dialogues); + } catch (JsonProcessingException e) { + logger.error("对话列表转JSON失败", e); + return "[]"; + } + } + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/character/task/CharacterChatCountTask.java b/vocata-server/src/main/java/com/vocata/character/task/CharacterChatCountTask.java new file mode 100644 index 0000000..e6e9343 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/character/task/CharacterChatCountTask.java @@ -0,0 +1,87 @@ +package com.vocata.character.task; + +import com.vocata.character.service.CharacterChatCountService; +import com.vocata.character.constants.ChatCountCacheConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 角色聊天计数定时任务 + * + * 功能: + * 1. 每天凌晨2点同步Redis缓存数据到数据库 + * 2. 清理过期的今日计数缓存 + */ +@Component +public class CharacterChatCountTask { + + private static final Logger logger = LoggerFactory.getLogger(CharacterChatCountTask.class); + + @Autowired + private CharacterChatCountService characterChatCountService; + + /** + * 每天凌晨2点同步缓存数据到数据库 + * Cron表达式:秒 分 时 日 月 周年 + * 0 0 2 * * ? 表示每天凌晨2点执行 + */ + @Scheduled(cron = ChatCountCacheConstants.SYNC_CRON) + public void syncCacheToDatabase() { + logger.info("开始执行定时任务:同步角色聊天计数缓存到数据库"); + long startTime = System.currentTimeMillis(); + + try { + // 同步所有角色的聊天计数 + characterChatCountService.syncCacheToDatabase(null); + + long endTime = System.currentTimeMillis(); + logger.info("定时任务执行完成:同步角色聊天计数缓存到数据库,耗时:{}ms", endTime - startTime); + + } catch (Exception e) { + logger.error("定时任务执行失败:同步角色聊天计数缓存到数据库", e); + } + } + + /** + * 每小时清理过期的今日计数缓存 + * Cron表达式:0 0 * * * ? 表示每小时的0分0秒执行 + */ + @Scheduled(cron = ChatCountCacheConstants.CLEANUP_CRON) + public void cleanupExpiredTodayCache() { + logger.info("开始执行定时任务:清理过期今日计数缓存"); + long startTime = System.currentTimeMillis(); + + try { + characterChatCountService.cleanupExpiredTodayCache(); + + long endTime = System.currentTimeMillis(); + logger.info("定时任务执行完成:清理过期今日计数缓存,耗时:{}ms", endTime - startTime); + + } catch (Exception e) { + logger.error("定时任务执行失败:清理过期今日计数缓存", e); + } + } + + /** + * 每天凌晨3点重新预热缓存 + * 在数据同步完成后1小时,重新预热热门角色的缓存 + */ + @Scheduled(cron = ChatCountCacheConstants.WARMUP_CRON) + public void warmUpHotCharactersCache() { + logger.info("开始执行定时任务:预热热门角色缓存"); + long startTime = System.currentTimeMillis(); + + try { + characterChatCountService.warmUpCache(); + + long endTime = System.currentTimeMillis(); + logger.info("定时任务执行完成:预热热门角色缓存,耗时:{}ms", endTime - startTime); + + } catch (Exception e) { + logger.error("定时任务执行失败:预热热门角色缓存", e); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/config/BigDecimalDeserializer.java b/vocata-server/src/main/java/com/vocata/common/config/BigDecimalDeserializer.java new file mode 100644 index 0000000..f8b6c70 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/config/BigDecimalDeserializer.java @@ -0,0 +1,36 @@ +package com.vocata.common.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.math.BigDecimal; + +/** + * BigDecimal自定义反序列化器 + * 支持从字符串形式的Java代码反序列化BigDecimal + */ +public class BigDecimalDeserializer extends JsonDeserializer { + + @Override + public BigDecimal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getValueAsString(); + if (value == null || value.trim().isEmpty()) { + return null; + } + + // 处理 "new BigDecimal("0.7")" 格式 + if (value.startsWith("new BigDecimal(\"") && value.endsWith("\")")) { + String numberStr = value.substring(16, value.length() - 2); + return new BigDecimal(numberStr); + } + + // 处理普通数字字符串 + try { + return new BigDecimal(value); + } catch (NumberFormatException e) { + throw new IOException("Cannot deserialize BigDecimal from: " + value, e); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/config/JsonArrayDeserializer.java b/vocata-server/src/main/java/com/vocata/common/config/JsonArrayDeserializer.java new file mode 100644 index 0000000..b87ddb8 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/config/JsonArrayDeserializer.java @@ -0,0 +1,47 @@ +package com.vocata.common.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import cn.hutool.json.JSONUtil; + +import java.io.IOException; + +/** + * JSON数组字符串反序列化器 + * 将JSON数组或字符串转换为JSON字符串存储 + */ +public class JsonArrayDeserializer extends JsonDeserializer { + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + + if (node == null || node.isNull()) { + return "[]"; + } + + if (node.isArray()) { + // 如果是JSON数组,直接转换为字符串 + return node.toString(); + } else if (node.isTextual()) { + // 如果是字符串,尝试解析为JSON + String text = node.textValue(); + if ("string".equals(text) || text == null || text.trim().isEmpty()) { + return "[]"; + } + try { + // 验证是否为有效JSON + Object parsed = JSONUtil.parse(text); + return JSONUtil.toJsonStr(parsed); + } catch (Exception e) { + // 如果不是有效JSON,包装成数组 + return JSONUtil.toJsonStr(new String[]{text}); + } + } else { + // 其他类型,转换为字符串后包装成数组 + return JSONUtil.toJsonStr(new String[]{node.toString()}); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/config/LongDeserializer.java b/vocata-server/src/main/java/com/vocata/common/config/LongDeserializer.java new file mode 100644 index 0000000..ff5dbe4 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/config/LongDeserializer.java @@ -0,0 +1,29 @@ +package com.vocata.common.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import cn.hutool.core.util.StrUtil; + +import java.io.IOException; + +/** + * Long类型自定义反序列化器 + * 支持从字符串反序列化为Long类型 + */ +public class LongDeserializer extends JsonDeserializer { + + @Override + public Long deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getValueAsString(); + if (StrUtil.isBlank(value)) { + return null; + } + + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + throw new IOException("Cannot deserialize Long from: " + value, e); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/constant/CharacterStatus.java b/vocata-server/src/main/java/com/vocata/common/constant/CharacterStatus.java new file mode 100644 index 0000000..33b677a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/constant/CharacterStatus.java @@ -0,0 +1,50 @@ +package com.vocata.common.constant; + +/** + * 角色状态常量类 + * 对应vocata_character表的status字段 + */ +public class CharacterStatus { + + /** + * 已发布 + */ + public static final int PUBLISHED = 1; + + /** + * 审核中 + */ + public static final int UNDER_REVIEW = 2; + + /** + * 已下架 + */ + public static final int OFFLINE = 3; + + /** + * 获取状态名称 + * @param status 状态值 + * @return 状态名称 + */ + public static String getStatusName(int status) { + switch (status) { + case PUBLISHED: + return "已发布"; + case UNDER_REVIEW: + return "审核中"; + case OFFLINE: + return "已下架"; + default: + return "未知状态"; + } + } + + /** + * 检验状态值是否有效 + * @param status 状态值 + * @return 是否有效 + */ + public static boolean isValidStatus(int status) { + return status == PUBLISHED || status == UNDER_REVIEW || status == OFFLINE; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/controller/HealthController.java b/vocata-server/src/main/java/com/vocata/common/controller/HealthController.java new file mode 100644 index 0000000..9691014 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/controller/HealthController.java @@ -0,0 +1,29 @@ +package com.vocata.common.controller; + +import com.vocata.common.result.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 健康检查控制器 + */ +@RestController +@RequestMapping("/api") +public class HealthController { + + @GetMapping("/health") + public ApiResponse> health() { + Map healthInfo = new HashMap<>(); + healthInfo.put("status", "UP"); + healthInfo.put("timestamp", LocalDateTime.now()); + healthInfo.put("service", "VocaTa API"); + healthInfo.put("version", "1.0.0"); + + return ApiResponse.success(healthInfo); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/entity/BaseEntity.java b/vocata-server/src/main/java/com/vocata/common/entity/BaseEntity.java index 16e8313..0eea5a9 100644 --- a/vocata-server/src/main/java/com/vocata/common/entity/BaseEntity.java +++ b/vocata-server/src/main/java/com/vocata/common/entity/BaseEntity.java @@ -25,6 +25,9 @@ public class BaseEntity implements Serializable { @TableField(fill = FieldFill.INSERT_UPDATE, value = "update_date") private LocalDateTime updateDate; + /** + * 0.未删除 1.已删除 + */ @TableLogic @TableField(value = "is_delete") private Integer isDelete; diff --git a/vocata-server/src/main/java/com/vocata/common/exception/GlobalExceptionHandler.java b/vocata-server/src/main/java/com/vocata/common/exception/GlobalExceptionHandler.java index f341238..10a1dee 100644 --- a/vocata-server/src/main/java/com/vocata/common/exception/GlobalExceptionHandler.java +++ b/vocata-server/src/main/java/com/vocata/common/exception/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import java.util.HashMap; import java.util.Map; @@ -91,6 +92,16 @@ public ApiResponse handleNotPermissionException(NotPermissionException e) return ApiResponse.error(ApiCode.FORBIDDEN.getCode(), "权限不足"); } + /** + * 文件上传大小超限异常处理 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + log.error("文件上传大小超限: {}", e.getMessage()); + return ApiResponse.error(ApiCode.PARAM_ERROR.getCode(), "头像文件大小不能超过1MB,请选择更小的图片"); + } + /** * 其他未知异常处理 */ diff --git a/vocata-server/src/main/java/com/vocata/common/handler/UuidTypeHandler.java b/vocata-server/src/main/java/com/vocata/common/handler/UuidTypeHandler.java new file mode 100644 index 0000000..52bb78f --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/handler/UuidTypeHandler.java @@ -0,0 +1,113 @@ +package com.vocata.common.handler; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +/** + * UUID类型处理器 + * 用于处理UUID类型与数据库字段的转换 + */ +@MappedTypes({UUID.class}) +@MappedJdbcTypes({JdbcType.OTHER, JdbcType.VARCHAR}) +public class UuidTypeHandler extends BaseTypeHandler { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, UUID parameter, JdbcType jdbcType) throws SQLException { + ps.setObject(i, parameter); + } + + @Override + public UUID getNullableResult(ResultSet rs, String columnName) throws SQLException { + Object object = rs.getObject(columnName); + if (object == null) { + return null; + } + + if (object instanceof UUID) { + return (UUID) object; + } + if (object instanceof String) { + try { + return UUID.fromString((String) object); + } catch (IllegalArgumentException e) { + System.err.println("UUID解析失败,列名: " + columnName + ", 值: " + object + ", 错误: " + e.getMessage()); + return null; + } + } + + // 处理其他可能的类型(如PostgreSQL的UUID类型) + try { + String uuidStr = object.toString(); + return UUID.fromString(uuidStr); + } catch (IllegalArgumentException e) { + System.err.println("UUID转换失败,列名: " + columnName + ", 对象类型: " + object.getClass().getName() + ", 值: " + object + ", 错误: " + e.getMessage()); + return null; + } + } + + @Override + public UUID getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + Object object = rs.getObject(columnIndex); + if (object == null) { + return null; + } + + if (object instanceof UUID) { + return (UUID) object; + } + if (object instanceof String) { + try { + return UUID.fromString((String) object); + } catch (IllegalArgumentException e) { + System.err.println("UUID解析失败,列索引: " + columnIndex + ", 值: " + object + ", 错误: " + e.getMessage()); + return null; + } + } + + // 处理其他可能的类型 + try { + String uuidStr = object.toString(); + return UUID.fromString(uuidStr); + } catch (IllegalArgumentException e) { + System.err.println("UUID转换失败,列索引: " + columnIndex + ", 对象类型: " + object.getClass().getName() + ", 值: " + object + ", 错误: " + e.getMessage()); + return null; + } + } + + @Override + public UUID getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + Object object = cs.getObject(columnIndex); + if (object == null) { + return null; + } + + if (object instanceof UUID) { + return (UUID) object; + } + if (object instanceof String) { + try { + return UUID.fromString((String) object); + } catch (IllegalArgumentException e) { + System.err.println("UUID解析失败,CallableStatement索引: " + columnIndex + ", 值: " + object + ", 错误: " + e.getMessage()); + return null; + } + } + + // 处理其他可能的类型 + try { + String uuidStr = object.toString(); + return UUID.fromString(uuidStr); + } catch (IllegalArgumentException e) { + System.err.println("UUID转换失败,CallableStatement索引: " + columnIndex + ", 对象类型: " + object.getClass().getName() + ", 值: " + object + ", 错误: " + e.getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/result/ApiCode.java b/vocata-server/src/main/java/com/vocata/common/result/ApiCode.java index 5bd1b20..0d3e607 100644 --- a/vocata-server/src/main/java/com/vocata/common/result/ApiCode.java +++ b/vocata-server/src/main/java/com/vocata/common/result/ApiCode.java @@ -11,36 +11,60 @@ public enum ApiCode { FORBIDDEN(403, "权限不足"), NOT_FOUND(404, "请求的资源不存在"), ERROR(500, "系统内部错误"), + TOO_MANY_REQUESTS(429, "请求过于频繁,请稍后再试"), + INTERNAL_SERVER_ERROR(500, "服务器内部错误"), - // 参数验证相关 - VALIDATE_FAILED(1001, "参数验证失败"), - - // 用户相关 - USER_NOT_EXIST(1002, "用户不存在"), - USER_ALREADY_EXISTS(1003, "用户已存在"), - USER_PASSWORD_ERROR(1004, "用户名或密码错误"), - USER_DISABLED(1005, "用户已被禁用"), - - // 角色相关 + // 通用错误码 (1000-1099) + PARAM_ERROR(1001, "请求参数错误"), + INVALID_PARAM(1001, "参数无效"), + VALIDATE_FAILED(1002, "参数验证失败"), + DATA_NOT_FOUND(1003, "数据不存在"), + DATA_ALREADY_EXISTS(1004, "数据已存在"), + ACCESS_DENIED(1005, "权限不足"), + OPERATION_FAILED(1006, "操作失败"), + + // 用户相关 (1100-1199) + USER_NOT_EXIST(1100, "用户不存在"), + USER_ALREADY_EXISTS(1101, "用户已存在"), + USER_PASSWORD_ERROR(1102, "用户名或密码错误"), + USER_DISABLED(1103, "用户已被禁用"), + USER_NOT_LOGIN(1104, "用户未登录"), + USER_TOKEN_EXPIRED(1105, "用户登录已过期"), + // 角色相关 (2000-2099) CHARACTER_NOT_EXIST(2001, "角色不存在"), CHARACTER_ACCESS_DENIED(2002, "无权访问该角色"), + CHARACTER_CODE_ALREADY_EXISTS(2003, "角色编码已存在"), + CHARACTER_NAME_ALREADY_EXISTS(2004, "角色名称已存在"), + CHARACTER_STATUS_INVALID(2005, "角色状态无效"), + CHARACTER_CREATE_FAILED(2006, "角色创建失败"), + CHARACTER_UPDATE_FAILED(2007, "角色更新失败"), + CHARACTER_DELETE_FAILED(2008, "角色删除失败"), + CHARACTER_NOT_PUBLISHED(2009, "角色未发布"), + CHARACTER_UNDER_REVIEW(2010, "角色审核中"), + CHARACTER_REJECTED(2011, "角色已被拒绝"), + CHARACTER_BANNED(2012, "角色已被封禁"), + CHARACTER_PERMISSION_DENIED(2013, "无权限操作该角色"), + CHARACTER_OWNER_ONLY(2014, "仅角色创建者可执行此操作"), + CHARACTER_ADMIN_ONLY(2015, "仅管理员可执行此操作"), - // 对话相关 + // 对话相关 (3000-3099) CONVERSATION_NOT_EXIST(3001, "对话不存在"), CONVERSATION_ACCESS_DENIED(3002, "无权访问该对话"), - // 收藏相关 + // 收藏相关 (4000-4099) FAVORITE_ALREADY_EXISTS(4001, "已收藏该内容"), FAVORITE_NOT_EXIST(4002, "收藏不存在"), - // AI服务相关 + // AI服务相关 (5000-5099) AI_SERVICE_ERROR(5001, "AI服务异常"), AI_SERVICE_UNAVAILABLE(5002, "AI服务不可用"), - // 文件相关 + // 文件相关 (6000-6099) FILE_UPLOAD_FAILED(6001, "文件上传失败"), FILE_TYPE_NOT_SUPPORTED(6002, "不支持的文件类型"), - FILE_SIZE_EXCEEDED(6003, "文件大小超出限制"); + FILE_SIZE_EXCEEDED(6003, "文件大小超出限制"), + FILE_EMPTY(6004, "文件不能为空"), + FILE_TYPE_NOT_ALLOWED(6005, "文件类型不允许"); private final Integer code; private final String message; @@ -57,4 +81,4 @@ public Integer getCode() { public String getMessage() { return message; } -} \ No newline at end of file +} diff --git a/vocata-server/src/main/java/com/vocata/common/result/ApiResponse.java b/vocata-server/src/main/java/com/vocata/common/result/ApiResponse.java index 91bd6b3..7787375 100644 --- a/vocata-server/src/main/java/com/vocata/common/result/ApiResponse.java +++ b/vocata-server/src/main/java/com/vocata/common/result/ApiResponse.java @@ -36,6 +36,10 @@ public static ApiResponse success(String message, T data) { return new ApiResponse<>(200, message, data); } + public static ApiResponse success() { + return new ApiResponse<>(200, "操作成功", null); + } + public static ApiResponse error(Integer code, String message) { return new ApiResponse<>(code, message, null); } diff --git a/vocata-server/src/main/java/com/vocata/common/result/PageResult.java b/vocata-server/src/main/java/com/vocata/common/result/PageResult.java index cc7ee6c..ef6bec9 100644 --- a/vocata-server/src/main/java/com/vocata/common/result/PageResult.java +++ b/vocata-server/src/main/java/com/vocata/common/result/PageResult.java @@ -13,6 +13,7 @@ public class PageResult implements Serializable { private Long total; private Integer totalPages; private List list; + private List records; // 兼容性字段,与list指向同一个数据 private Boolean hasNext; private Boolean hasPrevious; @@ -23,6 +24,7 @@ public PageResult(Integer pageNum, Integer pageSize, Long total, List list) { this.pageSize = pageSize; this.total = total; this.list = list; + this.records = list; // 保持兼容性 this.totalPages = (int) Math.ceil((double) total / pageSize); this.hasNext = pageNum < totalPages; this.hasPrevious = pageNum > 1; @@ -71,6 +73,16 @@ public List getList() { public void setList(List list) { this.list = list; + this.records = list; // 保持兼容性 + } + + public List getRecords() { + return records; + } + + public void setRecords(List records) { + this.records = records; + this.list = records; // 保持兼容性 } public Boolean getHasNext() { @@ -88,4 +100,4 @@ public Boolean getHasPrevious() { public void setHasPrevious(Boolean hasPrevious) { this.hasPrevious = hasPrevious; } -} \ No newline at end of file +} diff --git a/vocata-server/src/main/java/com/vocata/common/utils/JsonFieldUtil.java b/vocata-server/src/main/java/com/vocata/common/utils/JsonFieldUtil.java new file mode 100644 index 0000000..934006f --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/utils/JsonFieldUtil.java @@ -0,0 +1,84 @@ +package com.vocata.common.utils; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; + +/** + * JSON字段工具类 + * 用于处理PostgreSQL JSONB字段的验证和转换 + */ +public class JsonFieldUtil { + + /** + * 验证并格式化JSON字符串 + * @param jsonStr 原始JSON字符串 + * @return 格式化后的JSON字符串,如果为空则返回null + */ + public static String validateAndFormat(String jsonStr) { + if (StrUtil.isBlank(jsonStr)) { + return null; + } + + // 如果是普通字符串"string",转换为JSON格式 + if ("string".equals(jsonStr.trim())) { + return null; // 忽略测试占位符 + } + + try { + // 验证是否为有效JSON + Object parsed = JSONUtil.parse(jsonStr); + return JSONUtil.toJsonStr(parsed); + } catch (Exception e) { + // 如果不是有效JSON,将其作为字符串处理 + return JSONUtil.toJsonStr(jsonStr); + } + } + + /** + * 验证JSON数组格式 + * @param jsonArrayStr JSON数组字符串 + * @return 格式化后的JSON数组字符串 + */ + public static String validateJsonArray(String jsonArrayStr) { + if (StrUtil.isBlank(jsonArrayStr) || "string".equals(jsonArrayStr.trim())) { + return "[]"; // 返回空数组 + } + + try { + // 验证是否为有效JSON数组 + Object parsed = JSONUtil.parse(jsonArrayStr); + if (parsed instanceof java.util.List) { + return JSONUtil.toJsonStr(parsed); + } else { + // 如果不是数组,包装成数组 + return JSONUtil.toJsonStr(java.util.Arrays.asList(jsonArrayStr)); + } + } catch (Exception e) { + // 解析失败,包装成数组 + return JSONUtil.toJsonStr(java.util.Arrays.asList(jsonArrayStr)); + } + } + + /** + * 验证JSON对象格式 + * @param jsonObjStr JSON对象字符串 + * @return 格式化后的JSON对象字符串 + */ + public static String validateJsonObject(String jsonObjStr) { + if (StrUtil.isBlank(jsonObjStr) || "string".equals(jsonObjStr.trim())) { + return "{}"; // 返回空对象 + } + + try { + // 验证是否为有效JSON对象 + Object parsed = JSONUtil.parse(jsonObjStr); + if (parsed instanceof java.util.Map) { + return JSONUtil.toJsonStr(parsed); + } else { + return "{}"; // 返回空对象 + } + } catch (Exception e) { + return "{}"; // 返回空对象 + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/utils/PasswordEncoder.java b/vocata-server/src/main/java/com/vocata/common/utils/PasswordEncoder.java new file mode 100644 index 0000000..c233bac --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/common/utils/PasswordEncoder.java @@ -0,0 +1,33 @@ +package com.vocata.common.utils; + +import at.favre.lib.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Component; + +/** + * 密码加密工具类 - 使用BCrypt算法 + */ +@Component +public class PasswordEncoder { + + private static final int COST = 12; // BCrypt成本因子 + + /** + * 加密密码 + * @param rawPassword 原始密码 + * @return 加密后的密码 + */ + public String encode(String rawPassword) { + return BCrypt.withDefaults().hashToString(COST, rawPassword.toCharArray()); + } + + /** + * 验证密码 + * @param rawPassword 原始密码 + * @param encodedPassword 加密后的密码 + * @return 是否匹配 + */ + public boolean matches(String rawPassword, String encodedPassword) { + BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword); + return result.verified; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/common/utils/UserContext.java b/vocata-server/src/main/java/com/vocata/common/utils/UserContext.java index 3861871..a300abf 100644 --- a/vocata-server/src/main/java/com/vocata/common/utils/UserContext.java +++ b/vocata-server/src/main/java/com/vocata/common/utils/UserContext.java @@ -76,7 +76,11 @@ public static String getUsername() { */ public static boolean isAdmin() { UserContextDTO userContext = getOrNull(); - return userContext != null && userContext.getIsAdmin(); + if (userContext == null) { + return false; + } + Boolean isAdmin = userContext.getIsAdmin(); + return isAdmin != null && isAdmin; } /** @@ -88,6 +92,15 @@ public static void checkAdmin() { } } + /** + * 检查非管理员权限(管理员不能访问普通用户功能) + */ + public static void checkNotAdmin() { + if (isAdmin()) { + throw new BizException(ApiCode.FORBIDDEN.getCode(), "管理员不能访问此功能,请使用管理后台"); + } + } + /** * 检查是否为当前用户或管理员 */ diff --git a/vocata-server/src/main/java/com/vocata/config/MybatisPlusConfig.java b/vocata-server/src/main/java/com/vocata/config/MybatisPlusConfig.java index 547d3d5..0c52c07 100644 --- a/vocata-server/src/main/java/com/vocata/config/MybatisPlusConfig.java +++ b/vocata-server/src/main/java/com/vocata/config/MybatisPlusConfig.java @@ -1,15 +1,25 @@ package com.vocata.config; import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.config.GlobalConfig; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.vocata.common.handler.UuidTypeHandler; import com.vocata.common.utils.UserContext; import org.apache.ibatis.reflection.MetaObject; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.type.JdbcType; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import javax.sql.DataSource; import java.time.LocalDateTime; +import java.util.UUID; /** * MyBatis Plus配置 @@ -17,6 +27,9 @@ @Configuration public class MybatisPlusConfig { + @Autowired + private DataSource dataSource; + /** * 分页插件配置 */ @@ -32,6 +45,37 @@ public MybatisPlusInterceptor mybatisPlusInterceptor() { return interceptor; } + /** + * 自定义SqlSessionFactory,注册TypeHandler并配置GlobalConfig + */ + @Bean + @Primary + public SqlSessionFactory sqlSessionFactory() throws Exception { + MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + + // 创建MyBatis配置 + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + + // 注册UUID类型处理器 + configuration.getTypeHandlerRegistry().register(UUID.class, JdbcType.OTHER, UuidTypeHandler.class); + System.out.println("✅ UUID TypeHandler 已注册到配置中"); + + // 设置配置 + factory.setConfiguration(configuration); + + // 配置MyBatis Plus的GlobalConfig,确保自动填充功能正常工作 + GlobalConfig globalConfig = new GlobalConfig(); + globalConfig.setMetaObjectHandler(metaObjectHandler()); + factory.setGlobalConfig(globalConfig); + + // 添加插件 + factory.setPlugins(mybatisPlusInterceptor()); + + return factory.getObject(); + } + /** * 自动填充处理器 */ @@ -43,6 +87,11 @@ public void insertFill(MetaObject metaObject) { Long userId = UserContext.getUserIdSafely(); LocalDateTime now = LocalDateTime.now(); + // 如果用户ID为null或为访客用户(-1L),使用系统默认值0 + if (userId == null || userId == -1L) { + userId = 0L; + } + this.strictInsertFill(metaObject, "createId", Long.class, userId); this.strictInsertFill(metaObject, "createDate", LocalDateTime.class, now); this.strictInsertFill(metaObject, "updateId", Long.class, userId); @@ -55,6 +104,11 @@ public void updateFill(MetaObject metaObject) { Long userId = UserContext.getUserIdSafely(); LocalDateTime now = LocalDateTime.now(); + // 如果用户ID为null或为访客用户(-1L),使用系统默认值0 + if (userId == null || userId == -1L) { + userId = 0L; + } + this.strictUpdateFill(metaObject, "updateId", Long.class, userId); this.strictUpdateFill(metaObject, "updateDate", LocalDateTime.class, now); } diff --git a/vocata-server/src/main/java/com/vocata/config/RedisConfig.java b/vocata-server/src/main/java/com/vocata/config/RedisConfig.java index 50cd75d..eadcd48 100644 --- a/vocata-server/src/main/java/com/vocata/config/RedisConfig.java +++ b/vocata-server/src/main/java/com/vocata/config/RedisConfig.java @@ -6,6 +6,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; /** * Redis配置 @@ -25,6 +30,39 @@ public class RedisConfig { @Value("${spring.data.redis.database:0}") private int database; + /** + * Redis连接工厂配置 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + LettuceConnectionFactory factory = new LettuceConnectionFactory(host, port); + factory.setDatabase(database); + if (password != null && !password.isEmpty()) { + factory.setPassword(password); + } + return factory; + } + + /** + * RedisTemplate配置 + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // 使用String序列化器处理key + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // 使用Jackson2Json序列化器处理value + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + template.afterPropertiesSet(); + return template; + } + /** * Redisson客户端配置 */ diff --git a/vocata-server/src/main/java/com/vocata/config/SaTokenConfig.java b/vocata-server/src/main/java/com/vocata/config/SaTokenConfig.java index 16872f4..f430c93 100644 --- a/vocata-server/src/main/java/com/vocata/config/SaTokenConfig.java +++ b/vocata-server/src/main/java/com/vocata/config/SaTokenConfig.java @@ -7,9 +7,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import cn.dev33.satoken.context.SaHolder; /** * Sa-Token配置 + * */ @Configuration public class SaTokenConfig implements WebMvcConfigurer { @@ -17,30 +19,53 @@ public class SaTokenConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handler -> { - // 全局登录检查 - SaRouter.match("/**") - .notMatch("/api/open/**") - .notMatch("/api/client/auth/**") - .notMatch("/static/**") - .notMatch("/images/**") - .notMatch("/css/**") - .notMatch("/js/**") - .notMatch("/actuator/health") - .notMatch("/error") - .notMatch("/favicon.ico") - .check(r -> { - StpUtil.checkLogin(); - // 设置用户上下文 - setUserContext(); - }); - - // 管理端权限控制 + // 跨域预检请求(OPTIONS),无需认证 + SaRouter.match("**").check(r -> { + if ("OPTIONS".equals(SaHolder.getRequest().getMethod())) { + return; + } + }); + + // 公开接口,无需认证 + SaRouter.match("/api/open/**", "/api/health", "/actuator/health", "/error", "/favicon.ico", "/debug/**", + "/ws/**", "/websocket/**") + .stop(); + + // 静态资源,无需认证 + SaRouter.match("/static/**", "/images/**", "/css/**", "/js/**", + "/websocket-test.html", "/*.html", "/*.css", "/*.js", + "/favicon.ico", "/*.ico") + .stop(); + + // 客户端认证相关接口,无需预先认证 + SaRouter.match("/api/client/auth/login", "/api/client/auth/register", + "/api/client/auth/send-register-code", "/api/client/auth/send-reset-code", + "/api/client/auth/reset-password", "/api/client/auth/refresh-token") + .stop(); + + // 管理员认证接口,无需预先认证(但会在服务层验证管理员身份) + SaRouter.match("/api/admin/auth/login", "/api/admin/auth/refresh-token") + .stop(); + + // 管理员专用接口,需要管理员权限 SaRouter.match("/api/admin/**").check(r -> { StpUtil.checkLogin(); setUserContext(); UserContext.checkAdmin(); }); + // 客户端接口,需要登录(管理员和普通用户都能访问) + SaRouter.match("/api/client/**").check(r -> { + StpUtil.checkLogin(); + setUserContext(); + }); + + // 其他接口,需要登录认证(通用接口) + SaRouter.match("/**").check(r -> { + StpUtil.checkLogin(); + setUserContext(); + }); + })).addPathPatterns("/**"); } @@ -58,10 +83,14 @@ private void setUserContext() { userContext.setUserId(userId); // 可以从Session中获取更多用户信息 userContext.setUsername((String) StpUtil.getSession().get("username")); - userContext.setIsAdmin((Boolean) StpUtil.getSession().get("isAdmin")); + + // 安全获取isAdmin字段,避免null值 + Boolean isAdmin = (Boolean) StpUtil.getSession().get("isAdmin"); + userContext.setIsAdmin(isAdmin != null ? isAdmin : false); + userContext.setEmail((String) StpUtil.getSession().get("email")); UserContext.set(userContext); } } -} \ No newline at end of file +} diff --git a/vocata-server/src/main/java/com/vocata/config/WebClientConfig.java b/vocata-server/src/main/java/com/vocata/config/WebClientConfig.java new file mode 100644 index 0000000..0480a45 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/config/WebClientConfig.java @@ -0,0 +1,22 @@ +package com.vocata.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * WebClient配置 + * 用于AI服务的HTTP调用 + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder() + .codecs(configurer -> { + // 增加内存缓冲区大小,支持大的响应 + configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024); // 10MB + }); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/config/WebConfig.java b/vocata-server/src/main/java/com/vocata/config/WebConfig.java index d69e004..a2cfdf8 100644 --- a/vocata-server/src/main/java/com/vocata/config/WebConfig.java +++ b/vocata-server/src/main/java/com/vocata/config/WebConfig.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; @@ -10,6 +12,9 @@ import org.springframework.web.filter.CorsFilter; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + /** * Web配置 */ @@ -23,7 +28,7 @@ public class WebConfig implements WebMvcConfigurer { public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); - // 允许所有来源(生产环境应该指定具体域名) + // 允许所有来源 config.addAllowedOriginPattern("*"); // 允许所有HTTP方法 @@ -51,12 +56,22 @@ public CorsFilter corsFilter() { public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); + // 创建时间模块并配置LocalDateTime序列化格式 + JavaTimeModule timeModule = new JavaTimeModule(); + + // 配置LocalDateTime的序列化格式为ISO-8601标准格式 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"); + timeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); + // 注册时间模块 - mapper.registerModule(new JavaTimeModule()); + mapper.registerModule(timeModule); + + // 禁用时间戳序列化,使用字符串格式 + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // 使用驼峰命名策略 mapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE); return mapper; } -} \ No newline at end of file +} diff --git a/vocata-server/src/main/java/com/vocata/config/WebMvcConfig.java b/vocata-server/src/main/java/com/vocata/config/WebMvcConfig.java new file mode 100644 index 0000000..a506d7c --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.vocata.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web MVC 配置 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 配置静态资源处理 - 避免拦截WebSocket路径 + registry.addResourceHandler("/static/**", "/images/**", "/css/**", "/js/**", + "*.html", "*.css", "*.js", "*.ico", "*.png", "*.jpg", "*.gif") + .addResourceLocations("classpath:/static/") + .setCachePeriod(3600); // 缓存1小时 + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/config/WebSocketConfig.java b/vocata-server/src/main/java/com/vocata/config/WebSocketConfig.java new file mode 100644 index 0000000..7e5c6d6 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/config/WebSocketConfig.java @@ -0,0 +1,45 @@ +package com.vocata.config; + +import com.vocata.ai.websocket.AiChatWebSocketHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; +import org.springframework.context.annotation.Bean; + +/** + * MVC WebSocket配置类 + * 专门处理AI语音对话WebSocket连接 + * 端点: ws://localhost:9009/ws/chat/{conversation_uuid} + */ +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + @Autowired + private AiChatWebSocketHandler aiChatWebSocketHandler; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + // 注册AI语音对话WebSocket处理器 + registry.addHandler(aiChatWebSocketHandler, "/ws/chat/**") + .setAllowedOrigins("*"); + } + + /** + * 配置WebSocket消息缓冲区大小 + */ + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + // 设置文本消息缓冲区为1MB + container.setMaxTextMessageBufferSize(1024 * 1024); + // 设置二进制消息缓冲区为5MB - 支持大音频文件 + container.setMaxBinaryMessageBufferSize(5 * 1024 * 1024); + // 设置会话空闲超时为10分钟 + container.setMaxSessionIdleTimeout(10 * 60 * 1000L); + return container; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/constants/ContentType.java b/vocata-server/src/main/java/com/vocata/conversation/constants/ContentType.java new file mode 100644 index 0000000..215ec7e --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/constants/ContentType.java @@ -0,0 +1,49 @@ +package com.vocata.conversation.constants; + +/** + * 消息内容类型枚举 + */ +public enum ContentType { + /** + * 文本消息 + */ + TEXT(1, "TEXT"), + + /** + * 图片消息 + */ + IMAGE(2, "IMAGE"), + + /** + * 音频消息 + */ + AUDIO(3, "AUDIO"); + + private final int code; + private final String description; + + ContentType(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + /** + * 根据代码获取枚举值 + */ + public static ContentType fromCode(int code) { + for (ContentType type : values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalArgumentException("Unknown content type code: " + code); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/constants/ConversationStatus.java b/vocata-server/src/main/java/com/vocata/conversation/constants/ConversationStatus.java new file mode 100644 index 0000000..d41d78a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/constants/ConversationStatus.java @@ -0,0 +1,44 @@ +package com.vocata.conversation.constants; + +/** + * 会话状态枚举 + */ +public enum ConversationStatus { + /** + * 活跃状态 + */ + ACTIVE(0, "ACTIVE"), + + /** + * 已归档状态 + */ + ARCHIVED(1, "ARCHIVED"); + + private final int code; + private final String description; + + ConversationStatus(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + /** + * 根据代码获取枚举值 + */ + public static ConversationStatus fromCode(int code) { + for (ConversationStatus status : values()) { + if (status.code == code) { + return status; + } + } + throw new IllegalArgumentException("Unknown conversation status code: " + code); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/constants/SenderType.java b/vocata-server/src/main/java/com/vocata/conversation/constants/SenderType.java new file mode 100644 index 0000000..4b1410f --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/constants/SenderType.java @@ -0,0 +1,44 @@ +package com.vocata.conversation.constants; + +/** + * 消息发送方类型枚举 + */ +public enum SenderType { + /** + * 用户发送的消息 + */ + USER(1, "USER"), + + /** + * AI角色发送的消息 + */ + CHARACTER(2, "CHARACTER"); + + private final int code; + private final String description; + + SenderType(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + /** + * 根据代码获取枚举值 + */ + public static SenderType fromCode(int code) { + for (SenderType type : values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalArgumentException("Unknown sender type code: " + code); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/controller/ConversationController.java b/vocata-server/src/main/java/com/vocata/conversation/controller/ConversationController.java new file mode 100644 index 0000000..a050d34 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/controller/ConversationController.java @@ -0,0 +1,185 @@ +package com.vocata.conversation.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.utils.UserContext; +import com.vocata.conversation.dto.request.CreateConversationRequest; +import com.vocata.conversation.dto.request.UpdateTitleRequest; +import com.vocata.conversation.dto.response.ConversationResponse; +import com.vocata.conversation.dto.response.MessageResponse; +import com.vocata.conversation.service.ConversationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * 对话会话控制器 + */ +@RestController +@RequestMapping("/api/client/conversations") +@SaCheckLogin +public class ConversationController { + + private static final Logger logger = LoggerFactory.getLogger(ConversationController.class); + + @Autowired + private ConversationService conversationService; + + /** + * 获取当前用户的对话列表 + * GET /api/conversations + */ + @GetMapping + public ApiResponse> getUserConversations() { + Long userId = UserContext.getUserId(); + logger.info("获取用户{}的对话列表", userId); + + List conversations = conversationService.getUserConversations(userId); + + return ApiResponse.success(conversations); + } + + /** + * 创建新的对话会话 + * POST /api/conversations + */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createConversation(@Validated @RequestBody CreateConversationRequest request) { + Long userId = UserContext.getUserId(); + logger.info("用户{}创建新对话,角色ID: {}", userId, request.getCharacterId()); + + ConversationResponse conversation = conversationService.createConversation(userId, request); + + return ApiResponse.success(conversation); + } + + /** + * 获取指定对话的所有消息(已废弃) + * GET /api/conversations/{conversation_uuid}/messages + * @deprecated 建议使用 /messages/recent 端点 + */ + @Deprecated + @GetMapping("/{conversationUuid}/messages") + public ApiResponse> getConversationMessages( + @PathVariable("conversationUuid") String conversationUuidStr) { + Long userId = UserContext.getUserId(); + UUID conversationUuid = UUID.fromString(conversationUuidStr); + + logger.info("用户{}获取对话{}的消息列表(已废弃方法)", userId, conversationUuid); + + // 验证对话是否属于当前用户 + if (!conversationService.validateConversationOwnership(conversationUuid, userId)) { + return ApiResponse.error(403, "无权限访问此对话"); + } + + List messages = conversationService.getConversationMessages(conversationUuid); + + return ApiResponse.success(messages); + } + + /** + * 获取指定对话的最新消息(默认20条) + * GET /api/conversations/{conversation_uuid}/messages/recent + */ + @GetMapping("/{conversationUuid}/messages/recent") + public ApiResponse> getConversationRecentMessages( + @PathVariable("conversationUuid") String conversationUuidStr, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + Long userId = UserContext.getUserId(); + UUID conversationUuid = UUID.fromString(conversationUuidStr); + + logger.info("用户{}获取对话{}的最新{}条消息", userId, conversationUuid, limit); + + // 验证对话是否属于当前用户 + if (!conversationService.validateConversationOwnership(conversationUuid, userId)) { + return ApiResponse.error(403, "无权限访问此对话"); + } + + List messages = conversationService.getConversationRecentMessages(conversationUuid, limit); + + return ApiResponse.success(messages); + } + + /** + * 分页获取指定对话的历史消息 + * GET /api/conversations/{conversation_uuid}/messages/history + */ + @GetMapping("/{conversationUuid}/messages/history") + public ApiResponse> getConversationMessagesHistory( + @PathVariable("conversationUuid") String conversationUuidStr, + @RequestParam(value = "offset", defaultValue = "0") int offset, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + Long userId = UserContext.getUserId(); + UUID conversationUuid = UUID.fromString(conversationUuidStr); + + logger.info("用户{}分页获取对话{}的历史消息,offset: {}, limit: {}", userId, conversationUuid, offset, limit); + + // 验证对话是否属于当前用户 + if (!conversationService.validateConversationOwnership(conversationUuid, userId)) { + return ApiResponse.error(403, "无权限访问此对话"); + } + + List messages = conversationService.getConversationMessagesWithPagination( + conversationUuid, offset, limit); + + return ApiResponse.success(messages); + } + + /** + * 归档对话 + * PUT /api/conversations/{conversation_uuid}/archive + */ + @PutMapping("/{conversationUuid}/archive") + public ApiResponse archiveConversation(@PathVariable("conversationUuid") String conversationUuidStr) { + Long userId = UserContext.getUserId(); + UUID conversationUuid = UUID.fromString(conversationUuidStr); + + logger.info("用户{}归档对话{}", userId, conversationUuid); + + conversationService.archiveConversation(conversationUuid, userId); + + return ApiResponse.success("对话已归档"); + } + + /** + * 删除对话 + * DELETE /api/conversations/{conversation_uuid} + */ + @DeleteMapping("/{conversationUuid}") + public ApiResponse deleteConversation(@PathVariable("conversationUuid") String conversationUuidStr) { + Long userId = UserContext.getUserId(); + UUID conversationUuid = UUID.fromString(conversationUuidStr); + + logger.info("用户{}删除对话{}", userId, conversationUuid); + + conversationService.deleteConversation(conversationUuid, userId); + + return ApiResponse.success("对话已删除"); + } + + /** + * 更新对话标题 + * PUT /api/conversations/{conversation_uuid}/title + */ + @PutMapping("/{conversationUuid}/title") + public ApiResponse updateConversationTitle( + @PathVariable("conversationUuid") String conversationUuidStr, + @RequestBody UpdateTitleRequest request) { + Long userId = UserContext.getUserId(); + UUID conversationUuid = UUID.fromString(conversationUuidStr); + + logger.info("用户{}更新对话{}的标题", userId, conversationUuid); + + conversationService.updateConversationTitle(conversationUuid, userId, request.getTitle()); + + return ApiResponse.success("标题更新成功"); + } + +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/dto/request/CreateConversationRequest.java b/vocata-server/src/main/java/com/vocata/conversation/dto/request/CreateConversationRequest.java new file mode 100644 index 0000000..d284932 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/dto/request/CreateConversationRequest.java @@ -0,0 +1,38 @@ +package com.vocata.conversation.dto.request; + +import jakarta.validation.constraints.NotNull; + +/** + * 创建新对话请求 + */ +public class CreateConversationRequest { + + /** + * 角色ID + */ + @NotNull(message = "角色ID不能为空") + private Long characterId; + + /** + * 对话标题(可选,如果不提供则由LLM生成) + */ + private String title; + + // Getters and Setters + + public Long getCharacterId() { + return characterId; + } + + public void setCharacterId(Long characterId) { + this.characterId = characterId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/dto/request/UpdateTitleRequest.java b/vocata-server/src/main/java/com/vocata/conversation/dto/request/UpdateTitleRequest.java new file mode 100644 index 0000000..5c21e7a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/dto/request/UpdateTitleRequest.java @@ -0,0 +1,16 @@ +package com.vocata.conversation.dto.request; + +/** + * 更新标题请求DTO + */ +public class UpdateTitleRequest { + private String title; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/dto/response/ConversationResponse.java b/vocata-server/src/main/java/com/vocata/conversation/dto/response/ConversationResponse.java new file mode 100644 index 0000000..6f148b6 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/dto/response/ConversationResponse.java @@ -0,0 +1,141 @@ +package com.vocata.conversation.dto.response; + +import java.time.LocalDateTime; + +/** + * 对话列表响应 + */ +public class ConversationResponse { + + /** + * 对话UUID(对外暴露的ID) + */ + private String conversationUuid; + + /** + * 角色ID(String类型,避免前端精度丢失) + */ + private String characterId; + + /** + * 角色名称 + */ + private String characterName; + + /** + * 角色头像URL + */ + private String characterAvatarUrl; + + /** + * 角色问候语 + */ + private String greeting; + + /** + * 对话标题 + */ + private String title; + + /** + * 最新消息摘要 + */ + private String lastMessageSummary; + + /** + * 会话状态(0: 活跃, 1: 已归档) + */ + private Integer status; + + /** + * 创建时间 + */ + private LocalDateTime createDate; + + /** + * 最后更新时间 + */ + private LocalDateTime updateDate; + + // Getters and Setters + + public String getConversationUuid() { + return conversationUuid; + } + + public void setConversationUuid(String conversationUuid) { + this.conversationUuid = conversationUuid; + } + + public String getCharacterId() { + return characterId; + } + + public void setCharacterId(String characterId) { + this.characterId = characterId; + } + + public String getCharacterName() { + return characterName; + } + + public void setCharacterName(String characterName) { + this.characterName = characterName; + } + + public String getCharacterAvatarUrl() { + return characterAvatarUrl; + } + + public void setCharacterAvatarUrl(String characterAvatarUrl) { + this.characterAvatarUrl = characterAvatarUrl; + } + + public String getGreeting() { + return greeting; + } + + public void setGreeting(String greeting) { + this.greeting = greeting; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getLastMessageSummary() { + return lastMessageSummary; + } + + public void setLastMessageSummary(String lastMessageSummary) { + this.lastMessageSummary = lastMessageSummary; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public LocalDateTime getCreateDate() { + return createDate; + } + + public void setCreateDate(LocalDateTime createDate) { + this.createDate = createDate; + } + + public LocalDateTime getUpdateDate() { + return updateDate; + } + + public void setUpdateDate(LocalDateTime updateDate) { + this.updateDate = updateDate; + } +} diff --git a/vocata-server/src/main/java/com/vocata/conversation/dto/response/MessageResponse.java b/vocata-server/src/main/java/com/vocata/conversation/dto/response/MessageResponse.java new file mode 100644 index 0000000..4f704bc --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/dto/response/MessageResponse.java @@ -0,0 +1,129 @@ +package com.vocata.conversation.dto.response; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 消息响应DTO + */ +public class MessageResponse { + + /** + * 消息UUID(对外暴露的ID) + */ + private String messageUuid; + + /** + * 发送方类型 (1: USER, 2: CHARACTER) + */ + private Integer senderType; + + /** + * 内容类型 (1: TEXT, 2: IMAGE, 3: AUDIO) + */ + private Integer contentType; + + /** + * 消息的文本内容 + */ + private String textContent; + + /** + * 消息的语音文件URL + */ + private String audioUrl; + + /** + * 生成此条回复所用的LLM模型ID + */ + private String llmModelId; + + /** + * 生成此条回复所用的TTS声音ID + */ + private String ttsVoiceId; + + /** + * JSON格式的元数据(性能、成本等信息) + */ + private Map metadata; + + /** + * 创建时间 + */ + private LocalDateTime createDate; + + // Getters and Setters + + public String getMessageUuid() { + return messageUuid; + } + + public void setMessageUuid(String messageUuid) { + this.messageUuid = messageUuid; + } + + public Integer getSenderType() { + return senderType; + } + + public void setSenderType(Integer senderType) { + this.senderType = senderType; + } + + public Integer getContentType() { + return contentType; + } + + public void setContentType(Integer contentType) { + this.contentType = contentType; + } + + public String getTextContent() { + return textContent; + } + + public void setTextContent(String textContent) { + this.textContent = textContent; + } + + public String getAudioUrl() { + return audioUrl; + } + + public void setAudioUrl(String audioUrl) { + this.audioUrl = audioUrl; + } + + public String getLlmModelId() { + return llmModelId; + } + + public void setLlmModelId(String llmModelId) { + this.llmModelId = llmModelId; + } + + public String getTtsVoiceId() { + return ttsVoiceId; + } + + public void setTtsVoiceId(String ttsVoiceId) { + this.ttsVoiceId = ttsVoiceId; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public LocalDateTime getCreateDate() { + return createDate; + } + + public void setCreateDate(LocalDateTime createDate) { + this.createDate = createDate; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/entity/Conversation.java b/vocata-server/src/main/java/com/vocata/conversation/entity/Conversation.java index e0445f6..54b704c 100644 --- a/vocata-server/src/main/java/com/vocata/conversation/entity/Conversation.java +++ b/vocata-server/src/main/java/com/vocata/conversation/entity/Conversation.java @@ -1,32 +1,63 @@ package com.vocata.conversation.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.vocata.common.entity.BaseEntity; +import com.vocata.common.handler.UuidTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.util.UUID; /** - * 对话实体 + * 对话会话实体类 + * 对应数据库表:vocata_conversations + * + * 作为聊天记录的容器,连接了特定的用户和特定的角色 */ -@TableName("tb_conversation") +@TableName("vocata_conversations") public class Conversation extends BaseEntity { + /** + * 会话主键ID + */ @TableId(type = IdType.ASSIGN_ID) private Long id; + /** + * 对外暴露的对话唯一ID + */ + @TableField(typeHandler = UuidTypeHandler.class, jdbcType = JdbcType.OTHER) + private UUID conversationUuid; + + /** + * 参与会话的用户ID + */ private Long userId; + /** + * 被聊天的角色ID + */ private Long characterId; + /** + * 对话标题,可由LLM生成首句摘要 + */ private String title; - private Integer messageCount; + /** + * 最新消息摘要,用于会话列表展示 + */ + private String lastMessageSummary; + /** + * 会话状态 (0: 活跃, 1: 已归档) + */ private Integer status; - private String lastMessage; - // Getters and Setters + public Long getId() { return id; } @@ -35,6 +66,14 @@ public void setId(Long id) { this.id = id; } + public UUID getConversationUuid() { + return conversationUuid; + } + + public void setConversationUuid(UUID conversationUuid) { + this.conversationUuid = conversationUuid; + } + public Long getUserId() { return userId; } @@ -59,12 +98,12 @@ public void setTitle(String title) { this.title = title; } - public Integer getMessageCount() { - return messageCount; + public String getLastMessageSummary() { + return lastMessageSummary; } - public void setMessageCount(Integer messageCount) { - this.messageCount = messageCount; + public void setLastMessageSummary(String lastMessageSummary) { + this.lastMessageSummary = lastMessageSummary; } public Integer getStatus() { @@ -74,12 +113,4 @@ public Integer getStatus() { public void setStatus(Integer status) { this.status = status; } - - public String getLastMessage() { - return lastMessage; - } - - public void setLastMessage(String lastMessage) { - this.lastMessage = lastMessage; - } } \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/entity/Message.java b/vocata-server/src/main/java/com/vocata/conversation/entity/Message.java index 8fda22b..526cac3 100644 --- a/vocata-server/src/main/java/com/vocata/conversation/entity/Message.java +++ b/vocata-server/src/main/java/com/vocata/conversation/entity/Message.java @@ -1,38 +1,81 @@ package com.vocata.conversation.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.vocata.common.entity.BaseEntity; +import com.vocata.common.handler.UuidTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.util.Map; +import java.util.UUID; /** - * 消息实体 + * 消息实体类 + * 对应数据库表:vocata_messages + * + * 存储在一次会话中的所有具体对话内容 */ -@TableName("tb_message") +@TableName(value = "vocata_messages", autoResultMap = true) public class Message extends BaseEntity { + /** + * 消息主键ID + */ @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long conversationId; + /** + * 对外暴露的消息唯一ID + */ + @TableField(typeHandler = UuidTypeHandler.class, jdbcType = JdbcType.OTHER) + private UUID messageUuid; - private Long userId; + /** + * 所属的对话ID + */ + private Long conversationId; - private Long characterId; + /** + * 消息发送方 (1: USER, 2: CHARACTER) + */ + private Integer senderType; - private String content; + /** + * 内容类型 (1: TEXT, 2: IMAGE, 3: AUDIO) + */ + private Integer contentType; - private Integer messageType; + /** + * 消息的文本内容 + */ + private String textContent; + /** + * 消息的语音文件URL + */ private String audioUrl; - private Integer audioDuration; + /** + * 生成此条回复所用的LLM模型ID + */ + private String llmModelId; - private Integer isFromUser; + /** + * 生成此条回复所用的TTS声音ID + */ + private String ttsVoiceId; - private String metadata; + /** + * JSON格式,存储所有过程诊断信息(性能、成本等) + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map metadata; // Getters and Setters + public Long getId() { return id; } @@ -41,44 +84,44 @@ public void setId(Long id) { this.id = id; } - public Long getConversationId() { - return conversationId; + public UUID getMessageUuid() { + return messageUuid; } - public void setConversationId(Long conversationId) { - this.conversationId = conversationId; + public void setMessageUuid(UUID messageUuid) { + this.messageUuid = messageUuid; } - public Long getUserId() { - return userId; + public Long getConversationId() { + return conversationId; } - public void setUserId(Long userId) { - this.userId = userId; + public void setConversationId(Long conversationId) { + this.conversationId = conversationId; } - public Long getCharacterId() { - return characterId; + public Integer getSenderType() { + return senderType; } - public void setCharacterId(Long characterId) { - this.characterId = characterId; + public void setSenderType(Integer senderType) { + this.senderType = senderType; } - public String getContent() { - return content; + public Integer getContentType() { + return contentType; } - public void setContent(String content) { - this.content = content; + public void setContentType(Integer contentType) { + this.contentType = contentType; } - public Integer getMessageType() { - return messageType; + public String getTextContent() { + return textContent; } - public void setMessageType(Integer messageType) { - this.messageType = messageType; + public void setTextContent(String textContent) { + this.textContent = textContent; } public String getAudioUrl() { @@ -89,27 +132,27 @@ public void setAudioUrl(String audioUrl) { this.audioUrl = audioUrl; } - public Integer getAudioDuration() { - return audioDuration; + public String getLlmModelId() { + return llmModelId; } - public void setAudioDuration(Integer audioDuration) { - this.audioDuration = audioDuration; + public void setLlmModelId(String llmModelId) { + this.llmModelId = llmModelId; } - public Integer getIsFromUser() { - return isFromUser; + public String getTtsVoiceId() { + return ttsVoiceId; } - public void setIsFromUser(Integer isFromUser) { - this.isFromUser = isFromUser; + public void setTtsVoiceId(String ttsVoiceId) { + this.ttsVoiceId = ttsVoiceId; } - public String getMetadata() { + public Map getMetadata() { return metadata; } - public void setMetadata(String metadata) { + public void setMetadata(Map metadata) { this.metadata = metadata; } } \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/mapper/ConversationMapper.java b/vocata-server/src/main/java/com/vocata/conversation/mapper/ConversationMapper.java new file mode 100644 index 0000000..61d6da7 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/mapper/ConversationMapper.java @@ -0,0 +1,47 @@ +package com.vocata.conversation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.conversation.entity.Conversation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.UUID; + +/** + * 对话会话Mapper接口 + */ +@Mapper +public interface ConversationMapper extends BaseMapper { + + /** + * 根据UUID查找对话 + */ + @Select("SELECT * FROM vocata_conversations WHERE conversation_uuid = #{conversationUuid} AND is_delete = 0") + Conversation findByConversationUuid(@Param("conversationUuid") UUID conversationUuid); + + /** + * 根据用户ID查找所有对话,按更新时间倒序 + */ + @Select("SELECT * FROM vocata_conversations WHERE user_id = #{userId} AND is_delete = 0 ORDER BY update_date DESC") + List findByUserIdOrderByUpdateDateDesc(@Param("userId") Long userId); + + /** + * 根据用户ID和角色ID查找对话 + */ + @Select("SELECT * FROM vocata_conversations WHERE user_id = #{userId} AND character_id = #{characterId} AND is_delete = 0 ORDER BY update_date DESC") + List findByUserIdAndCharacterId(@Param("userId") Long userId, @Param("characterId") Long characterId); + + /** + * 根据用户ID和状态查找对话 + */ + @Select("SELECT * FROM vocata_conversations WHERE user_id = #{userId} AND status = #{status} AND is_delete = 0 ORDER BY update_date DESC") + List findByUserIdAndStatus(@Param("userId") Long userId, @Param("status") Integer status); + + /** + * 根据用户ID查找所有对话,按创建时间倒序(最新创建的在前) + */ + @Select("SELECT * FROM vocata_conversations WHERE user_id = #{userId} AND is_delete = 0 ORDER BY create_date DESC") + List findByUserIdOrderByCreateDateDesc(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/mapper/MessageMapper.java b/vocata-server/src/main/java/com/vocata/conversation/mapper/MessageMapper.java new file mode 100644 index 0000000..218f614 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/mapper/MessageMapper.java @@ -0,0 +1,78 @@ +package com.vocata.conversation.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.conversation.entity.Message; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.UUID; + +/** + * 消息Mapper接口 + */ +@Mapper +public interface MessageMapper extends BaseMapper { + + /** + * 根据UUID查找消息 + */ + @Select("SELECT * FROM vocata_messages WHERE message_uuid = #{messageUuid} AND is_delete = 0") + Message findByMessageUuid(@Param("messageUuid") UUID messageUuid); + + /** + * 根据对话ID查找所有消息,按创建时间升序 + */ + @Select("SELECT * FROM vocata_messages WHERE conversation_id = #{conversationId} AND is_delete = 0 ORDER BY create_date ASC") + List findByConversationIdOrderByCreateDateAsc(@Param("conversationId") Long conversationId); + + /** + * 根据对话ID查找最新的指定数量消息,按创建时间倒序 + * 用于对话界面显示最近消息 + * + * @param conversationId 对话ID + * @param limit 限制数量,默认20,最大100 + * @return 消息列表,按创建时间倒序(最新的在前) + */ + @Select("SELECT * FROM vocata_messages WHERE conversation_id = #{conversationId} AND is_delete = 0 ORDER BY create_date DESC LIMIT #{limit}") + List findRecentMessagesByConversationId(@Param("conversationId") Long conversationId, @Param("limit") int limit); + + /** + * 根据对话ID分页查找历史消息,按创建时间倒序 + * 用于向前翻页查看历史消息 + * + * @param conversationId 对话ID + * @param offset 偏移量 + * @param limit 限制数量 + * @return 消息列表,按创建时间倒序 + */ + @Select("SELECT * FROM vocata_messages WHERE conversation_id = #{conversationId} AND is_delete = 0 ORDER BY create_date DESC LIMIT #{limit} OFFSET #{offset}") + List findMessagesByConversationIdWithPagination(@Param("conversationId") Long conversationId, + @Param("offset") int offset, + @Param("limit") int limit); + + /** + * 根据对话ID查找最后一条消息 + */ + @Select("SELECT * FROM vocata_messages WHERE conversation_id = #{conversationId} AND is_delete = 0 ORDER BY create_date DESC LIMIT 1") + Message findLastMessageByConversationId(@Param("conversationId") Long conversationId); + + /** + * 根据对话ID和发送方类型查找消息 + */ + @Select("SELECT * FROM vocata_messages WHERE conversation_id = #{conversationId} AND sender_type = #{senderType} AND is_delete = 0 ORDER BY create_date ASC") + List findByConversationIdAndSenderType(@Param("conversationId") Long conversationId, @Param("senderType") Integer senderType); + + /** + * 统计对话中的消息数量 + */ + @Select("SELECT COUNT(*) FROM vocata_messages WHERE conversation_id = #{conversationId} AND is_delete = 0") + int countByConversationId(@Param("conversationId") Long conversationId); + + /** + * 根据对话ID删除所有消息(软删除) + */ + @Select("UPDATE vocata_messages SET is_delete = 1 WHERE conversation_id = #{conversationId}") + void softDeleteByConversationId(@Param("conversationId") Long conversationId); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/service/ConversationService.java b/vocata-server/src/main/java/com/vocata/conversation/service/ConversationService.java new file mode 100644 index 0000000..b771ea3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/service/ConversationService.java @@ -0,0 +1,109 @@ +package com.vocata.conversation.service; + +import com.vocata.conversation.dto.request.CreateConversationRequest; +import com.vocata.conversation.dto.response.ConversationResponse; +import com.vocata.conversation.dto.response.MessageResponse; +import com.vocata.conversation.entity.Conversation; + +import java.util.List; +import java.util.UUID; + +/** + * 对话会话服务接口 + */ +public interface ConversationService { + + /** + * 获取当前用户的所有对话列表,按更新时间倒序 + */ + List getUserConversations(Long userId); + + /** + * 创建新的对话会话 + */ + ConversationResponse createConversation(Long userId, CreateConversationRequest request); + + /** + * 根据UUID获取对话详情 + */ + Conversation getConversationByUuid(UUID conversationUuid); + + /** + * 根据UUID验证对话是否属于指定用户 + */ + boolean validateConversationOwnership(UUID conversationUuid, Long userId); + + /** + * 获取指定对话的所有消息,按创建时间升序 + * @deprecated 建议使用 getConversationRecentMessages 方法 + */ + @Deprecated + List getConversationMessages(UUID conversationUuid); + + /** + * 获取指定对话的最新消息(默认20条) + * 适用于对话界面的初始加载,按时间倒序返回(最新消息在前) + * + * @param conversationUuid 对话UUID + * @return 最新消息列表,最新消息在前 + */ + List getConversationRecentMessages(UUID conversationUuid); + + /** + * 获取指定对话的最新消息(自定义数量) + * 适用于对话界面的初始加载,按时间倒序返回(最新消息在前) + * + * @param conversationUuid 对话UUID + * @param limit 限制数量(1-100) + * @return 最新消息列表,最新消息在前 + */ + List getConversationRecentMessages(UUID conversationUuid, int limit); + + /** + * 分页获取对话的历史消息 + * 适用于向前翻页查看历史消息,按时间倒序返回 + * + * @param conversationUuid 对话UUID + * @param offset 偏移量(从0开始) + * @param limit 限制数量(1-100) + * @return 历史消息列表,按时间倒序 + */ + List getConversationMessagesWithPagination(UUID conversationUuid, int offset, int limit); + + /** + * 更新对话的最后消息摘要 + */ + void updateLastMessageSummary(Long conversationId, String summary); + + /** + * 归档对话 + */ + void archiveConversation(UUID conversationUuid, Long userId); + + /** + * 删除对话(软删除) + */ + void deleteConversation(UUID conversationUuid, Long userId); + + /** + * 基于首次消息自动生成对话标题 + * @param conversationId 对话ID + * @param firstMessage 首次用户消息内容 + */ + void generateConversationTitleAsync(Long conversationId, String firstMessage); + + /** + * 检查并触发新对话的标题生成 + * 当对话满足条件时(第一次创建且有完整的一问一答),自动生成标题 + * @param conversationId 对话ID + */ + void triggerTitleGenerationForNewConversation(Long conversationId); + + /** + * 更新对话标题 + * @param conversationUuid 对话UUID + * @param userId 用户ID + * @param newTitle 新标题 + */ + void updateConversationTitle(UUID conversationUuid, Long userId, String newTitle); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/service/ConversationTitleGenerationService.java b/vocata-server/src/main/java/com/vocata/conversation/service/ConversationTitleGenerationService.java new file mode 100644 index 0000000..5b65fc3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/service/ConversationTitleGenerationService.java @@ -0,0 +1,297 @@ +package com.vocata.conversation.service; + +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.LlmProvider; +import com.vocata.conversation.entity.Conversation; +import com.vocata.conversation.entity.Message; +import com.vocata.conversation.mapper.ConversationMapper; +import com.vocata.conversation.mapper.MessageMapper; +import com.vocata.conversation.constants.SenderType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 对话标题自动生成服务 + * + * 根据对话的第一轮问答(用户问题 + AI回答)自动生成简短的对话标题 + * 只有当对话是首次创建且没有标题时才会触发生成 + */ +@Service +public class ConversationTitleGenerationService { + + private static final Logger logger = LoggerFactory.getLogger(ConversationTitleGenerationService.class); + + @Autowired + private ConversationMapper conversationMapper; + + @Autowired + private MessageMapper messageMapper; + + @Autowired + @Qualifier("siliconFlowLlmProvider") + private LlmProvider titleGenerationLlmProvider; + + @Value("${siliconflow.ai.default-model:Qwen/Qwen2.5-7B-Instruct}") + private String titleGenerationModel; + + /** + * 检查对话是否需要生成标题 + * + * @param conversationId 对话ID + * @return true 如果需要生成标题,false 如果不需要 + */ + public boolean shouldGenerateTitle(Long conversationId) { + try { + // 检查对话是否存在 + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null) { + logger.debug("对话不存在,跳过标题生成: {}", conversationId); + return false; + } + + // 检查是否已有标题 + if (conversation.getTitle() != null && !conversation.getTitle().trim().isEmpty()) { + logger.debug("对话已有标题,跳过生成: {} - 标题: {}", conversationId, conversation.getTitle()); + return false; + } + + // 检查消息数量,确保有至少一轮完整对话(用户消息 + AI回复) + List messages = messageMapper.findRecentMessagesByConversationId(conversationId, 10); + if (messages.size() < 2) { + logger.debug("对话消息数量不足,跳过标题生成: {} - 消息数: {}", conversationId, messages.size()); + return false; + } + + // 检查是否有用户消息和AI回复 + boolean hasUserMessage = messages.stream() + .anyMatch(msg -> msg.getSenderType() == SenderType.USER.getCode()); + boolean hasAiMessage = messages.stream() + .anyMatch(msg -> msg.getSenderType() == SenderType.CHARACTER.getCode()); + + if (!hasUserMessage || !hasAiMessage) { + logger.debug("对话缺少完整的问答,跳过标题生成: {} - 用户消息: {}, AI消息: {}", + conversationId, hasUserMessage, hasAiMessage); + return false; + } + + logger.info("对话满足标题生成条件: {}", conversationId); + return true; + + } catch (Exception e) { + logger.error("检查标题生成条件时出错,对话ID: {}", conversationId, e); + return false; + } + } + + /** + * 异步生成对话标题 + * 基于对话中的第一轮问答内容生成简短、准确的标题 + * + * @param conversationId 对话ID + */ + @Async + public void generateTitleAsync(Long conversationId) { + try { + logger.info("开始异步生成对话标题: {}", conversationId); + + // 稍微延迟确保主线程的事务已提交 + Thread.sleep(500); + + // 再次检查是否需要生成标题(防止并发情况) + if (!shouldGenerateTitle(conversationId)) { + logger.info("对话不需要生成标题,结束处理: {}", conversationId); + return; + } + + // 获取对话的所有消息,按时间正序(最老的在前) + List messages = messageMapper.findByConversationIdOrderByCreateDateAsc(conversationId); + logger.debug("查询到对话消息数量: {}", messages.size()); + + if (messages.size() < 2) { + logger.warn("对话消息数量不足,无法生成标题: {} - 消息数: {}", conversationId, messages.size()); + return; + } + + // 找到第一条用户消息和第一条AI回复(按时间顺序) + String firstUserMessage = null; + String firstAiReply = null; + + // 按时间正序查找第一轮对话 + for (Message message : messages) { + logger.debug("处理消息: messageId={}, senderType={}, content={}...", + message.getId(), message.getSenderType(), + message.getTextContent() != null ? message.getTextContent().substring(0, Math.min(50, message.getTextContent().length())) : "null"); + + if (firstUserMessage == null && message.getSenderType() == SenderType.USER.getCode()) { + firstUserMessage = message.getTextContent(); + logger.debug("找到第一条用户消息"); + } else if (firstUserMessage != null && firstAiReply == null && + message.getSenderType() == SenderType.CHARACTER.getCode()) { + firstAiReply = message.getTextContent(); + logger.debug("找到第一条AI回复"); + break; // 找到完整的一轮对话后停止 + } + } + + if (firstUserMessage == null || firstAiReply == null) { + logger.warn("未找到完整的第一轮对话,无法生成标题: {} - 用户消息: {}, AI回复: {}", + conversationId, firstUserMessage != null, firstAiReply != null); + return; + } + + logger.info("找到完整的第一轮对话,开始生成标题: {}", conversationId); + + // 调用AI生成标题 + String generatedTitle = generateTitleWithAi(firstUserMessage, firstAiReply); + + if (generatedTitle != null && !generatedTitle.trim().isEmpty()) { + // 更新数据库中的标题 + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null && (conversation.getTitle() == null || conversation.getTitle().trim().isEmpty())) { + conversation.setTitle(generatedTitle); + conversation.setUpdateId(conversation.getUserId()); + conversationMapper.updateById(conversation); + + logger.info("成功生成并更新对话标题: {} -> {}", conversationId, generatedTitle); + } else { + logger.info("对话已有标题,跳过更新: {}", conversationId); + } + } else { + logger.warn("AI生成的标题为空,设置默认标题: {}", conversationId); + setDefaultTitle(conversationId); + } + + } catch (Exception e) { + logger.error("生成对话标题失败: {}", conversationId, e); + try { + setDefaultTitle(conversationId); + } catch (Exception ex) { + logger.error("设置默认标题也失败了: {}", conversationId, ex); + } + } + } + + /** + * 使用AI生成对话标题 + * + * @param userMessage 用户的第一条消息 + * @param aiReply AI的第一条回复 + * @return 生成的标题,失败时返回null + */ + private String generateTitleWithAi(String userMessage, String aiReply) { + try { + // 检查LLM提供者是否可用 + if (!titleGenerationLlmProvider.isAvailable()) { + logger.warn("硅基流动LLM提供者不可用,无法生成标题"); + return null; + } + + // 构建标题生成的提示词 + String titlePrompt = String.format( + "请根据以下对话内容,生成一个简短、准确的中文标题(不超过15个字符)。只需要返回标题本身,不要有任何额外的解释、引号或格式。\n\n" + + "用户问:%s\n\n" + + "AI答:%s\n\n" + + "标题要求:\n" + + "1. 简洁明了,不超过15个字符\n" + + "2. 准确概括对话主题\n" + + "3. 使用中文\n" + + "4. 不要使用引号、书名号等标点符号\n" + + "5. 直接返回标题,不要任何前缀后缀", + userMessage.length() > 200 ? userMessage.substring(0, 200) + "..." : userMessage, + aiReply.length() > 200 ? aiReply.substring(0, 200) + "..." : aiReply + ); + + // 构建AI请求 + UnifiedAiRequest titleRequest = new UnifiedAiRequest(); + titleRequest.setUserMessage(titlePrompt); + titleRequest.setSystemPrompt("你是一个专业的对话标题生成助手。请根据用户提供的对话内容,生成一个简短、准确的中文标题。只返回标题,不要任何额外内容。"); + + // 设置模型配置,使用免费的硅基流动模型 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName(titleGenerationModel); + modelConfig.setTemperature(0.3); // 较低温度确保生成稳定 + modelConfig.setMaxTokens(50); // 限制token数量,标题应该很短 + titleRequest.setModelConfig(modelConfig); + + logger.debug("开始调用AI生成标题,模型: {}", titleGenerationModel); + + // 调用AI服务生成标题 + UnifiedAiStreamChunk titleChunk = titleGenerationLlmProvider.chat(titleRequest); + String generatedTitle = titleChunk != null ? titleChunk.getAccumulatedContent() : null; + + if (generatedTitle != null) { + // 清理生成的标题 + generatedTitle = cleanGeneratedTitle(generatedTitle); + logger.info("AI生成标题成功: {}", generatedTitle); + return generatedTitle; + } else { + logger.warn("AI返回的标题为空"); + return null; + } + + } catch (Exception e) { + logger.error("调用AI生成标题时出错", e); + return null; + } + } + + /** + * 清理AI生成的标题 + * + * @param rawTitle 原始生成的标题 + * @return 清理后的标题 + */ + private String cleanGeneratedTitle(String rawTitle) { + if (rawTitle == null) { + return null; + } + + String cleanedTitle = rawTitle.trim(); + + // 去除常见的引号和标点符号 + cleanedTitle = cleanedTitle.replaceAll("^[\"'`『』「」【】]+|[\"'`『』「」【】]+$", ""); + + // 去除可能的前缀 + cleanedTitle = cleanedTitle.replaceAll("^(标题:|标题:|题目:|题目:)", ""); + + // 限制长度为15个字符 + if (cleanedTitle.length() > 15) { + cleanedTitle = cleanedTitle.substring(0, 15); + } + + // 如果清理后为空,返回默认值 + if (cleanedTitle.trim().isEmpty()) { + cleanedTitle = "新对话"; + } + + return cleanedTitle.trim(); + } + + /** + * 设置默认标题 + * + * @param conversationId 对话ID + */ + private void setDefaultTitle(Long conversationId) { + try { + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null && (conversation.getTitle() == null || conversation.getTitle().trim().isEmpty())) { + conversation.setTitle("新对话"); + conversation.setUpdateId(conversation.getUserId()); + conversationMapper.updateById(conversation); + logger.info("设置默认标题成功: {}", conversationId); + } + } catch (Exception e) { + logger.error("设置默认标题失败: {}", conversationId, e); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/conversation/service/impl/ConversationServiceImpl.java b/vocata-server/src/main/java/com/vocata/conversation/service/impl/ConversationServiceImpl.java new file mode 100644 index 0000000..b9a554f --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/conversation/service/impl/ConversationServiceImpl.java @@ -0,0 +1,401 @@ +package com.vocata.conversation.service.impl; + +import com.vocata.character.mapper.CharacterMapper; +import com.vocata.character.entity.Character; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.common.utils.UserContext; +import com.vocata.conversation.constants.ConversationStatus; +import com.vocata.conversation.dto.request.CreateConversationRequest; +import com.vocata.conversation.dto.response.ConversationResponse; +import com.vocata.conversation.dto.response.MessageResponse; +import com.vocata.conversation.entity.Conversation; +import com.vocata.conversation.entity.Message; +import com.vocata.conversation.mapper.ConversationMapper; +import com.vocata.conversation.mapper.MessageMapper; +import com.vocata.conversation.service.ConversationService; +import com.vocata.conversation.service.ConversationTitleGenerationService; +import com.vocata.ai.llm.LlmProvider; +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 对话会话服务实现类 + */ +@Service +public class ConversationServiceImpl implements ConversationService { + + private static final Logger logger = LoggerFactory.getLogger(ConversationServiceImpl.class); + + @Autowired + private ConversationMapper conversationMapper; + + @Autowired + private MessageMapper messageMapper; + + @Autowired + private CharacterMapper characterMapper; + + @Autowired + @Qualifier("primaryLlmProvider") + private LlmProvider llmProvider; + + @Autowired + private ConversationTitleGenerationService titleGenerationService; + + @Value("${gemini.api.default-model:gemini-2.5-flash-lite}") + private String defaultLlmModel; + + @Override + public List getUserConversations(Long userId) { + logger.info("获取用户{}的对话列表", userId); + + // 按创建时间降序排序 - 最新创建的对话在最前面 + List conversations = conversationMapper.findByUserIdOrderByCreateDateDesc(userId); + + return conversations.stream().map(this::convertToResponse).collect(Collectors.toList()); + } + + @Override + @Transactional + public ConversationResponse createConversation(Long userId, CreateConversationRequest request) { + logger.info("用户{}创建与角色{}的新对话", userId, request.getCharacterId()); + + // 验证角色是否存在 + Character character = characterMapper.selectById(request.getCharacterId()); + if (character == null || character.getIsDelete() == 1) { + throw new BizException(ApiCode.INVALID_PARAM, "角色不存在或已删除"); + } + + // 创建新对话 + Conversation conversation = new Conversation(); + conversation.setConversationUuid(UUID.randomUUID()); + conversation.setUserId(userId); + conversation.setCharacterId(request.getCharacterId()); + conversation.setTitle(request.getTitle()); + conversation.setStatus(ConversationStatus.ACTIVE.getCode()); + conversation.setCreateId(userId); + conversation.setUpdateId(userId); + + conversationMapper.insert(conversation); + + logger.info("成功创建对话,UUID: {}", conversation.getConversationUuid()); + + // 转换并返回响应 + return convertToResponse(conversation); + } + + @Override + public Conversation getConversationByUuid(UUID conversationUuid) { + Conversation conversation = conversationMapper.findByConversationUuid(conversationUuid); + if (conversation == null) { + throw new BizException(ApiCode.CONVERSATION_NOT_EXIST); + } + return conversation; + } + + @Override + public boolean validateConversationOwnership(UUID conversationUuid, Long userId) { + Conversation conversation = conversationMapper.findByConversationUuid(conversationUuid); + return conversation != null && conversation.getUserId().equals(userId); + } + + @Override + public List getConversationMessages(UUID conversationUuid) { + logger.warn("使用已废弃的方法 getConversationMessages,建议使用 getConversationRecentMessages"); + logger.info("获取对话{}的所有消息", conversationUuid); + + // 验证对话是否存在 + Conversation conversation = getConversationByUuid(conversationUuid); + + // 获取消息列表(升序,保持向后兼容) + List messages = messageMapper.findByConversationIdOrderByCreateDateAsc(conversation.getId()); + + return messages.stream().map(this::convertMessageToResponse).collect(Collectors.toList()); + } + + @Override + public List getConversationRecentMessages(UUID conversationUuid) { + return getConversationRecentMessages(conversationUuid, 20); // 默认20条 + } + + @Override + public List getConversationRecentMessages(UUID conversationUuid, int limit) { + logger.info("获取对话{}的最新{}条消息", conversationUuid, limit); + + // 参数验证 + if (limit <= 0 || limit > 100) { + throw new BizException(ApiCode.INVALID_PARAM, "消息数量限制必须在1-100之间"); + } + + // 验证对话是否存在 + Conversation conversation = getConversationByUuid(conversationUuid); + + // 获取最新消息列表(倒序,最新的在前) + List messages = messageMapper.findRecentMessagesByConversationId(conversation.getId(), limit); + + return messages.stream().map(this::convertMessageToResponse).collect(Collectors.toList()); + } + + @Override + public List getConversationMessagesWithPagination(UUID conversationUuid, int offset, int limit) { + logger.info("分页获取对话{}的消息,offset: {}, limit: {}", conversationUuid, offset, limit); + + // 参数验证 + if (offset < 0) { + throw new BizException(ApiCode.INVALID_PARAM, "偏移量不能为负数"); + } + if (limit <= 0 || limit > 100) { + throw new BizException(ApiCode.INVALID_PARAM, "消息数量限制必须在1-100之间"); + } + + // 验证对话是否存在 + Conversation conversation = getConversationByUuid(conversationUuid); + + // 分页获取消息列表(倒序) + List messages = messageMapper.findMessagesByConversationIdWithPagination( + conversation.getId(), offset, limit); + + return messages.stream().map(this::convertMessageToResponse).collect(Collectors.toList()); + } + + @Override + @Transactional + public void updateLastMessageSummary(Long conversationId, String summary) { + logger.info("更新对话{}的最后消息摘要", conversationId); + + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null) { + conversation.setLastMessageSummary(summary); + conversation.setUpdateId(UserContext.getUserId()); + conversationMapper.updateById(conversation); + } + } + + @Override + @Transactional + public void archiveConversation(UUID conversationUuid, Long userId) { + logger.info("用户{}归档对话{}", userId, conversationUuid); + + Conversation conversation = conversationMapper.findByConversationUuid(conversationUuid); + if (conversation == null) { + throw new BizException(ApiCode.CONVERSATION_NOT_EXIST); + } + + if (!conversation.getUserId().equals(userId)) { + throw new BizException(ApiCode.FORBIDDEN, "无权限操作此对话"); + } + + conversation.setStatus(ConversationStatus.ARCHIVED.getCode()); + conversation.setUpdateId(userId); + conversationMapper.updateById(conversation); + } + + @Override + @Transactional + public void deleteConversation(UUID conversationUuid, Long userId) { + logger.info("用户{}删除对话{}", userId, conversationUuid); + + Conversation conversation = conversationMapper.findByConversationUuid(conversationUuid); + if (conversation == null) { + throw new BizException(ApiCode.CONVERSATION_NOT_EXIST); + } + + if (!conversation.getUserId().equals(userId)) { + throw new BizException(ApiCode.FORBIDDEN, "无权限操作此对话"); + } + + // 使用MyBatis Plus的deleteById方法进行软删除 + // 这将自动触发逻辑删除机制,设置 is_delete = 1 + conversationMapper.deleteById(conversation.getId()); + logger.info("已软删除对话,ID: {}", conversation.getId()); + + // 软删除相关消息 + messageMapper.softDeleteByConversationId(conversation.getId()); + logger.info("已软删除对话{}的所有相关消息", conversation.getId()); + } + + @Override + @Async + public void generateConversationTitleAsync(Long conversationId, String firstMessage) { + logger.info("开始异步生成对话{}的标题,基于首次消息: {}", conversationId, firstMessage); + + try { + // 构建生成标题的提示词 + String titlePrompt = "请根据用户的第一句话,为这次对话生成一个简短、准确的标题(不超过20个字符)。" + + "只需要返回标题本身,不要有任何额外的解释或格式。\n" + + "用户的话: " + firstMessage; + + // 调用LLM生成标题 - 构建请求对象 + UnifiedAiRequest titleRequest = new UnifiedAiRequest(); + titleRequest.setUserMessage(titlePrompt); + titleRequest.setSystemPrompt("你是一个专业的对话标题生成助手。请根据用户的第一句话,生成一个简短、准确的中文对话标题。"); + + // 设置简单的模型配置 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName(defaultLlmModel); // 使用配置的LLM模型 + modelConfig.setTemperature(0.3); // 较低温度确保生成的标题较为稳定 + titleRequest.setModelConfig(modelConfig); + + UnifiedAiStreamChunk titleChunk = llmProvider.chat(titleRequest); + String generatedTitle = titleChunk != null ? titleChunk.getAccumulatedContent() : null; + + // 清理生成的标题(去除引号和多余的空格) + if (generatedTitle != null) { + generatedTitle = generatedTitle.trim() + .replaceAll("^[\"'`]+|[\"'`]+$", "") // 去除首尾引号 + .substring(0, Math.min(generatedTitle.length(), 50)); // 限制长度 + + if (generatedTitle.isEmpty()) { + generatedTitle = "新对话"; + } + } else { + generatedTitle = "新对话"; + } + + // 更新数据库中的标题 + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null) { + conversation.setTitle(generatedTitle); + conversation.setUpdateId(conversation.getUserId()); + conversationMapper.updateById(conversation); + + logger.info("成功生成并更新对话{}的标题: {}", conversationId, generatedTitle); + } + + } catch (Exception e) { + logger.error("生成对话{}标题时出错: {}", conversationId, e.getMessage(), e); + // 发生错误时设置默认标题 + try { + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation != null && (conversation.getTitle() == null || conversation.getTitle().trim().isEmpty())) { + conversation.setTitle("新对话"); + conversation.setUpdateId(conversation.getUserId()); + conversationMapper.updateById(conversation); + } + } catch (Exception ex) { + logger.error("设置默认标题时也出错了: {}", ex.getMessage()); + } + } + } + + /** + * 新方法:基于一问一答生成对话标题 + * + * @param conversationId 对话ID + */ + @Override + public void triggerTitleGenerationForNewConversation(Long conversationId) { + logger.info("检查对话{}是否需要生成标题", conversationId); + + if (titleGenerationService.shouldGenerateTitle(conversationId)) { + logger.info("对话{}满足标题生成条件,开始异步生成", conversationId); + titleGenerationService.generateTitleAsync(conversationId); + } else { + logger.debug("对话{}不满足标题生成条件,跳过", conversationId); + } + } + + @Override + @Transactional + public void updateConversationTitle(UUID conversationUuid, Long userId, String newTitle) { + logger.info("用户{}更新对话{}的标题为: {}", userId, conversationUuid, newTitle); + + Conversation conversation = conversationMapper.findByConversationUuid(conversationUuid); + if (conversation == null) { + throw new BizException(ApiCode.CONVERSATION_NOT_EXIST); + } + + if (!conversation.getUserId().equals(userId)) { + throw new BizException(ApiCode.FORBIDDEN, "无权限操作此对话"); + } + + // 验证标题长度 + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new BizException(ApiCode.INVALID_PARAM, "标题不能为空"); + } + + if (newTitle.length() > 100) { + throw new BizException(ApiCode.INVALID_PARAM, "标题长度不能超过100个字符"); + } + + conversation.setTitle(newTitle.trim()); + conversation.setUpdateId(userId); + conversationMapper.updateById(conversation); + + logger.info("成功更新对话{}的标题", conversationUuid); + } + + /** + * 将对话实体转换为响应DTO + */ + private ConversationResponse convertToResponse(Conversation conversation) { + ConversationResponse response = new ConversationResponse(); + + // conversation_uuid是永久不变的唯一标识,绝不能修改 + if (conversation.getConversationUuid() != null) { + response.setConversationUuid(conversation.getConversationUuid().toString()); + } else { + logger.error("严重错误:对话ID {}的conversation_uuid为NULL,这违反了数据完整性约束", conversation.getId()); + throw new BizException(ApiCode.ERROR, "对话数据异常,请联系管理员"); + } + + response.setCharacterId(conversation.getCharacterId().toString()); + response.setTitle(conversation.getTitle()); + response.setLastMessageSummary(conversation.getLastMessageSummary()); + response.setStatus(conversation.getStatus()); + response.setCreateDate(conversation.getCreateDate()); + response.setUpdateDate(conversation.getUpdateDate()); + + // 获取角色信息 + if (conversation.getCharacterId() != null) { + Character character = characterMapper.selectById(conversation.getCharacterId()); + if (character != null) { + response.setCharacterName(character.getName()); + response.setCharacterAvatarUrl(character.getAvatarUrl()); + response.setGreeting(character.getGreeting()); + } + } + + return response; + } + + /** + * 将消息实体转换为响应DTO + */ + private MessageResponse convertMessageToResponse(Message message) { + MessageResponse response = new MessageResponse(); + + // 防护性检查UUID - 临时修复,待TypeHandler修复生效后可删除 + if (message.getMessageUuid() != null) { + response.setMessageUuid(message.getMessageUuid().toString()); + } else { + // 数据库中有UUID但Java对象中为null,这是TypeHandler问题 + logger.warn("消息ID {}的UUID在Java对象中为null,这可能是TypeHandler问题", message.getId()); + response.setMessageUuid("uuid-missing-" + message.getId()); // 临时处理 + } + + response.setSenderType(message.getSenderType()); + response.setContentType(message.getContentType()); + response.setTextContent(message.getTextContent()); + response.setAudioUrl(message.getAudioUrl()); + response.setLlmModelId(message.getLlmModelId()); + response.setTtsVoiceId(message.getTtsVoiceId()); + response.setMetadata(message.getMetadata()); + response.setCreateDate(message.getCreateDate()); + + return response; + } +} diff --git a/vocata-server/src/main/java/com/vocata/file/config/QiniuConfig.java b/vocata-server/src/main/java/com/vocata/file/config/QiniuConfig.java new file mode 100644 index 0000000..12a4924 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/config/QiniuConfig.java @@ -0,0 +1,134 @@ +package com.vocata.file.config; + +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.Region; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 七牛云配置 + * + * @author vocata + * @since 2025-09-24 + */ +@Configuration +@ConfigurationProperties(prefix = "qiniu") +public class QiniuConfig { + + /** + * Access Key + */ + private String accessKey; + + /** + * Secret Key + */ + private String secretKey; + + /** + * 存储空间名称 + */ + private String bucket; + + /** + * 访问域名 + */ + private String domain; + + /** + * 存储区域 + */ + private String region; + + /** + * 七牛云认证 + */ + @Bean + public Auth auth() { + return Auth.create(accessKey, secretKey); + } + + /** + * 七牛云上传管理器 + */ + @Bean + public UploadManager uploadManager() { + // 根据region配置选择存储区域 + Region qiniuRegion = getQiniuRegion(); + com.qiniu.storage.Configuration cfg = new com.qiniu.storage.Configuration(qiniuRegion); + return new UploadManager(cfg); + } + + /** + * 七牛云存储空间管理器 + */ + @Bean + public BucketManager bucketManager(Auth auth) { + Region qiniuRegion = getQiniuRegion(); + com.qiniu.storage.Configuration cfg = new com.qiniu.storage.Configuration(qiniuRegion); + return new BucketManager(auth, cfg); + } + + /** + * 根据配置获取七牛云区域 + */ + private Region getQiniuRegion() { + switch (region.toLowerCase()) { + case "huadong": + return Region.huadong(); + case "huabei": + return Region.huabei(); + case "huanan": + return Region.huanan(); + case "beimei": + return Region.beimei(); + default: + return Region.autoRegion(); + } + } + + // Getter and Setter methods + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getDomain() { + return domain; + } + + public void setDomain(String domain) { + this.domain = domain; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/config/QiniuProperties.java b/vocata-server/src/main/java/com/vocata/file/config/QiniuProperties.java new file mode 100644 index 0000000..3db264c --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/config/QiniuProperties.java @@ -0,0 +1,62 @@ +package com.vocata.file.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 七牛云配置属性 + */ +@Component +@ConfigurationProperties(prefix = "qiniu") +public class QiniuProperties { + + private String accessKey; + + private String secretKey; + + private String bucket; + + private String domain; + + private Long uploadTokenExpires = 3600L; + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getDomain() { + return domain; + } + + public void setDomain(String domain) { + this.domain = domain; + } + + public Long getUploadTokenExpires() { + return uploadTokenExpires; + } + + public void setUploadTokenExpires(Long uploadTokenExpires) { + this.uploadTokenExpires = uploadTokenExpires; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/constants/FileConstants.java b/vocata-server/src/main/java/com/vocata/file/constants/FileConstants.java new file mode 100644 index 0000000..cdea8e1 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/constants/FileConstants.java @@ -0,0 +1,19 @@ +package com.vocata.file.constants; + +/** + * 文件相关常量 + */ +public class FileConstants { + + public static final String AVATAR_PREFIX = "avatar/"; + + public static final String[] ALLOWED_IMAGE_TYPES = { + "image/jpeg", "image/png", "image/gif", "image/webp" + }; + + public static final String[] ALLOWED_IMAGE_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".webp" + }; + + public static final long MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5MB +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/controller/FileController.java b/vocata-server/src/main/java/com/vocata/file/controller/FileController.java new file mode 100644 index 0000000..1fbb666 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/controller/FileController.java @@ -0,0 +1,51 @@ +package com.vocata.file.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.vocata.common.result.ApiResponse; +import com.vocata.file.dto.FileUploadResponse; +import com.vocata.file.service.FileService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文件上传控制器 + * + * @author vocata + * @since 2025-09-24 + */ +@RestController +@RequestMapping("/api/client/file") +@SaCheckLogin +public class FileController { + + private static final Logger log = LoggerFactory.getLogger(FileController.class); + + private final FileService fileService; + + public FileController(FileService fileService) { + this.fileService = fileService; + } + + /** + * 上传文件 + * + * @param file 上传的文件 + * @param type 文件类型分类(可选,用于目录分类,如: avatar, image等) + * @return 上传结果 + */ + @PostMapping("/upload") + public ApiResponse uploadFile( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "type", required = false, defaultValue = "common") String type) { + + log.info("开始上传文件: {}, 类型: {}", file.getOriginalFilename(), type); + + FileUploadResponse response = fileService.uploadFile(file, type); + + log.info("文件上传成功: {}", response.getFileUrl()); + + return ApiResponse.success(response); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/dto/FileUploadResponse.java b/vocata-server/src/main/java/com/vocata/file/dto/FileUploadResponse.java new file mode 100644 index 0000000..65e99d8 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/dto/FileUploadResponse.java @@ -0,0 +1,149 @@ +package com.vocata.file.dto; + +/** + * 文件上传响应 + * + * @author vocata + * @since 2025-09-24 + */ +public class FileUploadResponse { + + /** + * 文件名 + */ + private String fileName; + + /** + * 原始文件名 + */ + private String originalFileName; + + /** + * 文件大小(字节) + */ + private Long fileSize; + + /** + * 文件类型 + */ + private String contentType; + + /** + * 文件访问URL + */ + private String fileUrl; + + /** + * 上传时间 + */ + private String uploadTime; + + public FileUploadResponse() { + } + + public FileUploadResponse(String fileName, String originalFileName, Long fileSize, + String contentType, String fileUrl, String uploadTime) { + this.fileName = fileName; + this.originalFileName = originalFileName; + this.fileSize = fileSize; + this.contentType = contentType; + this.fileUrl = fileUrl; + this.uploadTime = uploadTime; + } + + public static FileUploadResponseBuilder builder() { + return new FileUploadResponseBuilder(); + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getOriginalFileName() { + return originalFileName; + } + + public void setOriginalFileName(String originalFileName) { + this.originalFileName = originalFileName; + } + + public Long getFileSize() { + return fileSize; + } + + public void setFileSize(Long fileSize) { + this.fileSize = fileSize; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getFileUrl() { + return fileUrl; + } + + public void setFileUrl(String fileUrl) { + this.fileUrl = fileUrl; + } + + public String getUploadTime() { + return uploadTime; + } + + public void setUploadTime(String uploadTime) { + this.uploadTime = uploadTime; + } + + public static class FileUploadResponseBuilder { + private String fileName; + private String originalFileName; + private Long fileSize; + private String contentType; + private String fileUrl; + private String uploadTime; + + public FileUploadResponseBuilder fileName(String fileName) { + this.fileName = fileName; + return this; + } + + public FileUploadResponseBuilder originalFileName(String originalFileName) { + this.originalFileName = originalFileName; + return this; + } + + public FileUploadResponseBuilder fileSize(Long fileSize) { + this.fileSize = fileSize; + return this; + } + + public FileUploadResponseBuilder contentType(String contentType) { + this.contentType = contentType; + return this; + } + + public FileUploadResponseBuilder fileUrl(String fileUrl) { + this.fileUrl = fileUrl; + return this; + } + + public FileUploadResponseBuilder uploadTime(String uploadTime) { + this.uploadTime = uploadTime; + return this; + } + + public FileUploadResponse build() { + return new FileUploadResponse(fileName, originalFileName, fileSize, + contentType, fileUrl, uploadTime); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/dto/UploadTokenResponse.java b/vocata-server/src/main/java/com/vocata/file/dto/UploadTokenResponse.java new file mode 100644 index 0000000..5098140 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/dto/UploadTokenResponse.java @@ -0,0 +1,46 @@ +package com.vocata.file.dto; + +/** + * 上传凭证响应 + */ +public class UploadTokenResponse { + + private String token; + + private String key; + + private Long expires; + + public UploadTokenResponse() { + } + + public UploadTokenResponse(String token, String key, Long expires) { + this.token = token; + this.key = key; + this.expires = expires; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public Long getExpires() { + return expires; + } + + public void setExpires(Long expires) { + this.expires = expires; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/service/FileService.java b/vocata-server/src/main/java/com/vocata/file/service/FileService.java new file mode 100644 index 0000000..7864211 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/service/FileService.java @@ -0,0 +1,41 @@ +package com.vocata.file.service; + +import com.vocata.file.dto.FileUploadResponse; +import org.springframework.web.multipart.MultipartFile; + +/** + * 文件服务接口 + * + * @author vocata + * @since 2025-09-24 + */ +public interface FileService { + + /** + * 上传文件到七牛云 + * + * @param file 上传的文件 + * @param fileType 文件类型(用于目录分类) + * @return 上传结果 + */ + FileUploadResponse uploadFile(MultipartFile file, String fileType); + + /** + * 上传字节数组到七牛云 - 用于音频文件上传 + * + * @param audioData 音频字节数据 + * @param fileName 原始文件名(包含扩展名) + * @param fileType 文件类型(用于目录分类) + * @param contentType 文件MIME类型 + * @return 上传结果 + */ + FileUploadResponse uploadAudioFile(byte[] audioData, String fileName, String fileType, String contentType); + + /** + * 删除七牛云文件 + * + * @param fileName 文件名 + * @return 是否成功 + */ + boolean deleteFile(String fileName); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/file/service/impl/FileServiceImpl.java b/vocata-server/src/main/java/com/vocata/file/service/impl/FileServiceImpl.java new file mode 100644 index 0000000..cab35f1 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/file/service/impl/FileServiceImpl.java @@ -0,0 +1,319 @@ +package com.vocata.file.service.impl; + +import cn.hutool.core.util.IdUtil; +import com.qiniu.common.QiniuException; +import com.qiniu.http.Response; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.file.config.QiniuConfig; +import com.vocata.file.dto.FileUploadResponse; +import com.vocata.file.service.FileService; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * 文件服务实现 + * + * @author vocata + * @since 2025-09-24 + */ +@Service +public class FileServiceImpl implements FileService { + + private static final Logger log = LoggerFactory.getLogger(FileServiceImpl.class); + + private final Auth auth; + private final UploadManager uploadManager; + private final BucketManager bucketManager; + private final QiniuConfig qiniuConfig; + + public FileServiceImpl(Auth auth, UploadManager uploadManager, + BucketManager bucketManager, QiniuConfig qiniuConfig) { + this.auth = auth; + this.uploadManager = uploadManager; + this.bucketManager = bucketManager; + this.qiniuConfig = qiniuConfig; + } + + /** + * 允许的图片类型 + */ + private static final List ALLOWED_IMAGE_TYPES = Arrays.asList( + "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp" + ); + + /** + * 文件大小限制(5MB) + */ + private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; + + /** + * 允许的音频Content-Type + */ + private static final List ALLOWED_AUDIO_TYPES = Arrays.asList( + "audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3", "audio/aac", + "audio/m4a", "audio/flac", "audio/ogg", "audio/webm", "audio/webm;codecs=opus", + "audio/webm; codecs=opus" + ); + + @Override + public FileUploadResponse uploadFile(MultipartFile file, String fileType) { + // 验证文件 + validateFile(file); + + try { + // 生成文件名 + String fileName = generateFileName(file, fileType); + + // 获取上传token + String uploadToken = auth.uploadToken(qiniuConfig.getBucket()); + + // 上传文件 + Response response = uploadManager.put(file.getBytes(), fileName, uploadToken); + + if (!response.isOK()) { + log.error("文件上传失败,响应: {}", response.toString()); + throw new BizException(ApiCode.FILE_UPLOAD_FAILED); + } + + // 构建文件访问URL - 生成带签名的URL + String fileUrl = generateSignedUrl(fileName); + + return FileUploadResponse.builder() + .fileName(fileName) + .originalFileName(file.getOriginalFilename()) + .fileSize(file.getSize()) + .contentType(file.getContentType()) + .fileUrl(fileUrl) + .uploadTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + + } catch (Exception e) { + log.error("文件上传异常", e); + throw new BizException(ApiCode.FILE_UPLOAD_FAILED); + } + } + + @Override + public FileUploadResponse uploadAudioFile(byte[] audioData, String fileName, String fileType, String contentType) { + // 验证音频数据 + validateAudioFile(audioData, fileName, contentType); + + try { + // 生成唯一文件名 + String uniqueFileName = generateAudioFileName(fileName, fileType); + + // 获取上传token + String uploadToken = auth.uploadToken(qiniuConfig.getBucket()); + + // 上传音频数据 + Response response = uploadManager.put(audioData, uniqueFileName, uploadToken); + + if (!response.isOK()) { + log.error("音频文件上传失败,响应: {}", response.toString()); + throw new BizException(ApiCode.FILE_UPLOAD_FAILED); + } + + // 构建文件访问URL - 生成带签名的URL + String fileUrl = generateSignedUrl(uniqueFileName); + + log.info("音频文件上传成功: {}, 访问 URL: {}", uniqueFileName, fileUrl); + + return FileUploadResponse.builder() + .fileName(uniqueFileName) + .originalFileName(fileName) + .fileSize((long) audioData.length) + .contentType(contentType) + .fileUrl(fileUrl) + .uploadTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .build(); + + } catch (Exception e) { + log.error("音频文件上传异常: {}", fileName, e); + throw new BizException(ApiCode.FILE_UPLOAD_FAILED); + } + } + + @Override + public boolean deleteFile(String fileName) { + try { + Response response = bucketManager.delete(qiniuConfig.getBucket(), fileName); + return response.isOK(); + } catch (QiniuException e) { + log.error("文件删除失败: {}", fileName, e); + return false; + } + } + + /** + * 生成带签名的七牛云下载URL + * + * @param fileName 文件名 + * @return 带签名的下载URL + */ + private String generateSignedUrl(String fileName) { + try { + // 构建基础URL,确保使用正确的协议 + String domain = qiniuConfig.getDomain(); + + // 规范化域名,确保使用http协议(七牛云测试域名通常使用http) + if (domain.startsWith("https://")) { + domain = domain.replace("https://", "http://"); + } else if (!domain.startsWith("http://")) { + domain = "http://" + domain; + } + + String fullUrl = domain + "/" + fileName; + + // 生成带签名的下载URL,有效期为1年 (365 * 24 * 3600 秒) + long expireInSeconds = System.currentTimeMillis() / 1000 + 365 * 24 * 3600L; + String signedUrl = auth.privateDownloadUrl(fullUrl, expireInSeconds); + + log.debug("生成签名URL成功: {} -> {}", fileName, signedUrl); + return signedUrl; + + } catch (Exception e) { + log.error("生成签名URL失败,返回原始URL: {}", e.getMessage(), e); + // 如果签名失败,返回原始URL作为降级处理 + return qiniuConfig.getDomain() + "/" + fileName; + } + } + + /** + * 用于模块化要求,将文件验证和名称生成逻辑抽离 + */ + + /** + * 验证上传文件 + */ + private void validateFile(MultipartFile file) { + // 检查文件是否为空 + if (file == null || file.isEmpty()) { + throw new BizException(ApiCode.FILE_EMPTY); + } + + // 检查文件大小 + if (file.getSize() > MAX_FILE_SIZE) { + throw new BizException(ApiCode.FILE_SIZE_EXCEEDED); + } + + // 检查文件类型 + String contentType = file.getContentType(); + if (StringUtils.isBlank(contentType) || !ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase())) { + throw new BizException(ApiCode.FILE_TYPE_NOT_ALLOWED); + } + } + + /** + * 验证音频文件 + */ + private void validateAudioFile(byte[] audioData, String fileName, String contentType) { + // 检查数据是否为空 + if (audioData == null || audioData.length == 0) { + throw new BizException(ApiCode.FILE_EMPTY); + } + + // 检查文件大小 (10MB 限制,音频文件通常较大) + if (audioData.length > 10 * 1024 * 1024) { + throw new BizException(ApiCode.FILE_SIZE_EXCEEDED); + } + + // 检查文件名 + if (StringUtils.isBlank(fileName)) { + throw new BizException(ApiCode.FILE_EMPTY); + } + + // 检查内容类型 (支持常见音频格式) + if (StringUtils.isNotBlank(contentType)) { + String lowerContentType = contentType.toLowerCase(Locale.ROOT); + if (!ALLOWED_AUDIO_TYPES.contains(lowerContentType) && !lowerContentType.startsWith("audio/")) { + throw new BizException(ApiCode.FILE_TYPE_NOT_ALLOWED); + } + } else { + // 如果没有提供contentType,根据文件扩展名推断 + String extension = getFileExtension(fileName).toLowerCase(Locale.ROOT); + if (!isAllowedAudioExtension(extension)) { + throw new BizException(ApiCode.FILE_TYPE_NOT_ALLOWED); + } + } + } + + /** + * 检查是否为允许的音频扩展名 + */ + private boolean isAllowedAudioExtension(String extension) { + List allowedExtensions = Arrays.asList(".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".webm"); + return allowedExtensions.contains(extension); + } + + /** + * 获取文件扩展名 + */ + private String getFileExtension(String fileName) { + int dotIndex = fileName.lastIndexOf("."); + if (dotIndex > 0 && dotIndex < fileName.length() - 1) { + return fileName.substring(dotIndex); + } + return ""; + } + + /** + * 生成文件名 + */ + private String generateFileName(MultipartFile file, String fileType) { + String originalFilename = file.getOriginalFilename(); + if (StringUtils.isBlank(originalFilename)) { + originalFilename = "unknown"; + } + + // 获取文件扩展名 + String extension = ""; + int dotIndex = originalFilename.lastIndexOf("."); + if (dotIndex > 0 && dotIndex < originalFilename.length() - 1) { + extension = originalFilename.substring(dotIndex); + } + + // 生成唯一文件名: fileType/yyyyMMdd/uuid.ext + String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String uuid = IdUtil.simpleUUID(); + + return String.format("%s/%s/%s%s", + StringUtils.isNotBlank(fileType) ? fileType : "common", + dateStr, + uuid, + extension); + } + + /** + * 生成音频文件名 + */ + private String generateAudioFileName(String originalFileName, String fileType) { + // 获取文件扩展名 + String extension = getFileExtension(originalFileName); + if (StringUtils.isBlank(extension)) { + extension = ".mp3"; // 默认扩展名 + } + + // 生成唯一文件名: fileType/yyyyMMdd/uuid.ext + String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String uuid = IdUtil.simpleUUID(); + + return String.format("%s/%s/%s%s", + StringUtils.isNotBlank(fileType) ? fileType : "audio", + dateStr, + uuid, + extension); + } +} diff --git a/vocata-server/src/main/java/com/vocata/user/constants/UserConstants.java b/vocata-server/src/main/java/com/vocata/user/constants/UserConstants.java new file mode 100644 index 0000000..902bd04 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/constants/UserConstants.java @@ -0,0 +1,52 @@ +package com.vocata.user.constants; + +/** + * 用户相关常量定义 + * + * @author vocata + * @since 2025-09-24 + */ +public class UserConstants { + + /** + * 用户状态 + */ + public static class Status { + /** 正常 */ + public static final Integer NORMAL = 1; + /** 禁用 */ + public static final Integer DISABLED = 2; + } + + /** + * 性别 + */ + public static class Gender { + /** 未知 */ + public static final Integer UNKNOWN = 0; + /** 男 */ + public static final Integer MALE = 1; + /** 女 */ + public static final Integer FEMALE = 2; + } + + /** + * 软删除标记 + */ + public static class DeleteFlag { + /** 未删除 */ + public static final Integer NOT_DELETED = 0; + /** 已删除 */ + public static final Integer DELETED = 1; + } + + /** + * 管理员标记 + */ + public static class AdminFlag { + /** 普通用户 */ + public static final Boolean NORMAL_USER = false; + /** 管理员 */ + public static final Boolean ADMIN = true; + } +} diff --git a/vocata-server/src/main/java/com/vocata/user/controller/UserController.java b/vocata-server/src/main/java/com/vocata/user/controller/UserController.java new file mode 100644 index 0000000..79804e6 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/controller/UserController.java @@ -0,0 +1,166 @@ +package com.vocata.user.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.vocata.common.result.ApiResponse; +import com.vocata.common.result.PageResult; +import com.vocata.common.utils.UserContext; +import com.vocata.file.dto.FileUploadResponse; +import com.vocata.file.service.FileService; +import com.vocata.user.dto.UpdateUserProfileRequest; +import com.vocata.user.dto.UserProfileResponse; +import com.vocata.user.dto.request.BatchCheckFavoriteRequest; +import com.vocata.user.dto.request.FavoriteRequest; +import com.vocata.user.dto.response.FavoriteResponse; +import com.vocata.user.service.UserService; +import com.vocata.user.service.UserFavoriteService; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +/** + * 用户个人信息控制器 + * + * @author vocata + * @since 2025-09-24 + */ +@RestController +@RequestMapping("/api/client/user") +@SaCheckLogin +public class UserController { + + private static final Logger log = LoggerFactory.getLogger(UserController.class); + + private final UserService userService; + private final FileService fileService; + + @Autowired + private UserFavoriteService userFavoriteService; + + public UserController(UserService userService, FileService fileService) { + this.userService = userService; + this.fileService = fileService; + } + + /** + * 获取当前用户个人信息 + * + * @return 用户个人信息 + */ + @GetMapping("/profile") + public ApiResponse getCurrentUserProfile() { + UserProfileResponse profile = userService.getCurrentUserProfile(); + return ApiResponse.success(profile); + } + + /** + * 更新当前用户个人信息 + * + * @param request 更新请求 + * @return 更新后的用户信息 + */ + @PutMapping("/profile") + public ApiResponse updateCurrentUserProfile( + @RequestBody @Validated UpdateUserProfileRequest request) { + + log.info("用户更新个人信息: {}", request); + + UserProfileResponse profile = userService.updateCurrentUserProfile(request); + return ApiResponse.success(profile); + } + + /** + * 上传用户头像 + * + * @param file 头像文件 + * @return 更新后的用户信息 + */ + @PostMapping("/avatar") + public ApiResponse uploadAvatar( + @RequestParam("file") MultipartFile file) { + + log.info("用户上传头像: {}", file.getOriginalFilename()); + + // 上传文件到七牛云 + FileUploadResponse uploadResponse = fileService.uploadFile(file, "avatar"); + + // 更新用户头像 + UserProfileResponse profile = userService.updateUserAvatar(uploadResponse.getFileUrl()); + + log.info("用户头像上传成功: {}", uploadResponse.getFileUrl()); + + return ApiResponse.success(profile); + } + + // ========== 收藏功能相关接口 ========== + + /** + * 切换收藏状态(收藏/取消收藏) + * 如果已收藏则取消收藏,如果未收藏则添加收藏 + */ + @PostMapping("/favorite/toggle") + public ApiResponse> toggleFavorite(@RequestBody @Valid FavoriteRequest request) { + Long userId = UserContext.getUserId(); + Map result = userFavoriteService.toggleFavorite(userId, request.getCharacterId()); + return ApiResponse.success(result); + } + + /** + * 快速切换收藏状态(通过路径参数) + * GET /api/client/user/favorite/toggle/{characterId} + */ + @PostMapping("/favorite/toggle/{characterId}") + public ApiResponse> toggleFavoriteByPath(@PathVariable Long characterId) { + Long userId = UserContext.getUserId(); + Map result = userFavoriteService.toggleFavorite(userId, characterId); + return ApiResponse.success(result); + } + + /** + * 获取用户收藏的角色列表 + */ + @GetMapping("/favorites") + public ApiResponse> getUserFavorites( + @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum, + @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) { + Long userId = UserContext.getUserId(); + PageResult result = userFavoriteService.getUserFavorites(userId, pageNum, pageSize); + return ApiResponse.success(result); + } + + /** + * 获取用户收藏角色数量 + */ + @GetMapping("/favorite/count") + public ApiResponse getUserFavoriteCount() { + Long userId = UserContext.getUserId(); + Integer count = userFavoriteService.getUserFavoriteCount(userId); + return ApiResponse.success(count); + } + + /** + * 批量检查收藏状态 + */ + @PostMapping("/favorite/batch-check") + public ApiResponse> batchCheckFavoriteStatus(@RequestBody @Valid BatchCheckFavoriteRequest request) { + Long userId = UserContext.getUserId(); + Map result = userFavoriteService.batchCheckFavoriteStatus(userId, request.getCharacterIds()); + return ApiResponse.success(result); + } + + /** + * 检查单个角色的收藏状态 + */ + @GetMapping("/favorite/check/{characterId}") + public ApiResponse checkFavoriteStatus(@PathVariable Long characterId) { + Long userId = UserContext.getUserId(); + boolean result = userFavoriteService.isUserFavorite(userId, characterId); + return ApiResponse.success(result); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/UpdateUserProfileRequest.java b/vocata-server/src/main/java/com/vocata/user/dto/UpdateUserProfileRequest.java new file mode 100644 index 0000000..0a0b804 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/UpdateUserProfileRequest.java @@ -0,0 +1,138 @@ +package com.vocata.user.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +/** + * 更新用户个人信息请求DTO + * + * @author vocata + * @since 2025-09-24 + */ +public class UpdateUserProfileRequest { + + /** + * 昵称 + */ + @Size(max = 50, message = "昵称长度不能超过50个字符") + private String nickname; + + /** + * 头像URL + */ + @Size(max = 500, message = "头像URL长度不能超过500个字符") + private String avatar; + + /** + * 性别 (0:未设置 1:男 2:女) + */ + private Integer gender; + + /** + * 手机号 + */ + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + /** + * 生日 + */ + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate birthday; + + public UpdateUserProfileRequest() { + } + + public UpdateUserProfileRequest(String nickname, String avatar, Integer gender, + String phone, LocalDate birthday) { + this.nickname = nickname; + this.avatar = avatar; + this.gender = gender; + this.phone = phone; + this.birthday = birthday; + } + + public static UpdateUserProfileRequestBuilder builder() { + return new UpdateUserProfileRequestBuilder(); + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public LocalDate getBirthday() { + return birthday; + } + + public void setBirthday(LocalDate birthday) { + this.birthday = birthday; + } + + public static class UpdateUserProfileRequestBuilder { + private String nickname; + private String avatar; + private Integer gender; + private String phone; + private LocalDate birthday; + + public UpdateUserProfileRequestBuilder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public UpdateUserProfileRequestBuilder avatar(String avatar) { + this.avatar = avatar; + return this; + } + + public UpdateUserProfileRequestBuilder gender(Integer gender) { + this.gender = gender; + return this; + } + + public UpdateUserProfileRequestBuilder phone(String phone) { + this.phone = phone; + return this; + } + + public UpdateUserProfileRequestBuilder birthday(LocalDate birthday) { + this.birthday = birthday; + return this; + } + + public UpdateUserProfileRequest build() { + return new UpdateUserProfileRequest(nickname, avatar, gender, phone, birthday); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/UserProfileResponse.java b/vocata-server/src/main/java/com/vocata/user/dto/UserProfileResponse.java new file mode 100644 index 0000000..32a03d1 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/UserProfileResponse.java @@ -0,0 +1,217 @@ +package com.vocata.user.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; +import java.time.LocalDate; + +/** + * 用户个人信息响应DTO + * + * @author vocata + * @since 2025-09-24 + */ +public class UserProfileResponse { + + /** + * 用户ID + */ + private String id; + + /** + * 用户名 + */ + private String username; + + /** + * 邮箱 + */ + private String email; + + /** + * 昵称 + */ + private String nickname; + + /** + * 头像URL + */ + private String avatar; + + /** + * 性别 (0:未设置 1:男 2:女) + */ + private Integer gender; + + /** + * 手机号 + */ + private String phone; + + /** + * 生日 + */ + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate birthday; + + /** + * 注册时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createDate; + + public UserProfileResponse() { + } + + public UserProfileResponse(String id, String username, String email, String nickname, + String avatar, Integer gender, String phone, + LocalDate birthday, LocalDateTime createDate) { + this.id = id; + this.username = username; + this.email = email; + this.nickname = nickname; + this.avatar = avatar; + this.gender = gender; + this.phone = phone; + this.birthday = birthday; + this.createDate = createDate; + } + + public static UserProfileResponseBuilder builder() { + return new UserProfileResponseBuilder(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public LocalDate getBirthday() { + return birthday; + } + + public void setBirthday(LocalDate birthday) { + this.birthday = birthday; + } + + public LocalDateTime getCreateDate() { + return createDate; + } + + public void setCreateDate(LocalDateTime createDate) { + this.createDate = createDate; + } + + public static class UserProfileResponseBuilder { + private String id; + private String username; + private String email; + private String nickname; + private String avatar; + private Integer gender; + private String phone; + private LocalDate birthday; + private LocalDateTime createDate; + + public UserProfileResponseBuilder id(String id) { + this.id = id; + return this; + } + + public UserProfileResponseBuilder username(String username) { + this.username = username; + return this; + } + + public UserProfileResponseBuilder email(String email) { + this.email = email; + return this; + } + + public UserProfileResponseBuilder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public UserProfileResponseBuilder avatar(String avatar) { + this.avatar = avatar; + return this; + } + + public UserProfileResponseBuilder gender(Integer gender) { + this.gender = gender; + return this; + } + + public UserProfileResponseBuilder phone(String phone) { + this.phone = phone; + return this; + } + + public UserProfileResponseBuilder birthday(LocalDate birthday) { + this.birthday = birthday; + return this; + } + + public UserProfileResponseBuilder createDate(LocalDateTime createDate) { + this.createDate = createDate; + return this; + } + + public UserProfileResponse build() { + return new UserProfileResponse(id, username, email, nickname, avatar, + gender, phone, birthday, createDate); + } + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/UserRegisterRequest.java b/vocata-server/src/main/java/com/vocata/user/dto/UserRegisterRequest.java new file mode 100644 index 0000000..b0156e6 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/UserRegisterRequest.java @@ -0,0 +1,80 @@ +package com.vocata.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 用户注册请求 + */ +public class UserRegisterRequest { + + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 30, message = "密码长度必须在6-30字符之间") + private String password; + + @NotBlank(message = "确认密码不能为空") + private String confirmPassword; + + @NotBlank(message = "验证码不能为空") + @Size(min = 6, max = 6, message = "验证码长度必须为6位") + private String verificationCode; + + private String nickname; + + private Integer gender; + + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getConfirmPassword() { + return confirmPassword; + } + + public void setConfirmPassword(String confirmPassword) { + this.confirmPassword = confirmPassword; + } + + public String getVerificationCode() { + return verificationCode; + } + + public void setVerificationCode(String verificationCode) { + this.verificationCode = verificationCode; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/UserResponse.java b/vocata-server/src/main/java/com/vocata/user/dto/UserResponse.java new file mode 100644 index 0000000..eeb15e3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/UserResponse.java @@ -0,0 +1,130 @@ +package com.vocata.user.dto; + +import java.time.LocalDateTime; +import java.time.LocalDate; + +/** + * 用户响应 + */ +public class UserResponse { + + private String id; + + private String username; + + private String email; + + private String nickname; + + private String avatar; + + private Integer gender; + + private String phone; + + private LocalDate birthday; + + private Integer status; + + private Boolean isAdmin; + + private LocalDateTime createDate; + + private LocalDateTime lastLoginTime; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public LocalDate getBirthday() { + return birthday; + } + + public void setBirthday(LocalDate birthday) { + this.birthday = birthday; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Boolean getIsAdmin() { + return isAdmin; + } + + public void setIsAdmin(Boolean isAdmin) { + this.isAdmin = isAdmin; + } + + public LocalDateTime getCreateDate() { + return createDate; + } + + public void setCreateDate(LocalDateTime createDate) { + this.createDate = createDate; + } + + public LocalDateTime getLastLoginTime() { + return lastLoginTime; + } + + public void setLastLoginTime(LocalDateTime lastLoginTime) { + this.lastLoginTime = lastLoginTime; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/request/BatchCheckFavoriteRequest.java b/vocata-server/src/main/java/com/vocata/user/dto/request/BatchCheckFavoriteRequest.java new file mode 100644 index 0000000..42b99e7 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/request/BatchCheckFavoriteRequest.java @@ -0,0 +1,28 @@ +package com.vocata.user.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.vocata.common.config.LongDeserializer; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** + * 批量检查收藏状态请求DTO + */ +public class BatchCheckFavoriteRequest { + + /** + * 角色ID列表 + */ + @NotEmpty(message = "角色ID列表不能为空") + @JsonDeserialize(contentUsing = LongDeserializer.class) + private List characterIds; + + public List getCharacterIds() { + return characterIds; + } + + public void setCharacterIds(List characterIds) { + this.characterIds = characterIds; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/request/FavoriteRequest.java b/vocata-server/src/main/java/com/vocata/user/dto/request/FavoriteRequest.java new file mode 100644 index 0000000..f612245 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/request/FavoriteRequest.java @@ -0,0 +1,26 @@ +package com.vocata.user.dto.request; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.vocata.common.config.LongDeserializer; +import jakarta.validation.constraints.NotNull; + +/** + * 收藏角色请求DTO + */ +public class FavoriteRequest { + + /** + * 角色ID + */ + @NotNull(message = "角色ID不能为空") + @JsonDeserialize(using = LongDeserializer.class) + private Long characterId; + + public Long getCharacterId() { + return characterId; + } + + public void setCharacterId(Long characterId) { + this.characterId = characterId; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/dto/response/FavoriteResponse.java b/vocata-server/src/main/java/com/vocata/user/dto/response/FavoriteResponse.java new file mode 100644 index 0000000..9ceeb46 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/dto/response/FavoriteResponse.java @@ -0,0 +1,83 @@ +package com.vocata.user.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.vocata.character.dto.response.CharacterResponse; + +import java.time.LocalDateTime; + +/** + * 用户收藏响应DTO + */ +public class FavoriteResponse { + + /** + * 收藏ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** + * 用户ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + + /** + * 角色ID + */ + @JsonSerialize(using = ToStringSerializer.class) + private Long characterId; + + /** + * 角色详细信息 + */ + private CharacterResponse character; + + /** + * 收藏时间 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getCharacterId() { + return characterId; + } + + public void setCharacterId(Long characterId) { + this.characterId = characterId; + } + + public CharacterResponse getCharacter() { + return character; + } + + public void setCharacter(CharacterResponse character) { + this.character = character; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/entity/User.java b/vocata-server/src/main/java/com/vocata/user/entity/User.java new file mode 100644 index 0000000..d7f5a83 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/entity/User.java @@ -0,0 +1,167 @@ +package com.vocata.user.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.vocata.common.entity.BaseEntity; + +import java.time.LocalDateTime; +import java.time.LocalDate; + +/** + * 用户实体 + */ +@TableName("vocata_user") +public class User extends BaseEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private String username; + + private String email; + + private String password; + + private String nickname; + + private String avatar; + + private Integer gender; + + private String phone; + + private LocalDate birthday; + + private Integer status; + + private Boolean isAdmin; + + private LocalDateTime lastLoginTime; + + private String lastLoginIp; + + private Integer loginFailCount; + + private LocalDateTime lockTime; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public LocalDate getBirthday() { + return birthday; + } + + public void setBirthday(LocalDate birthday) { + this.birthday = birthday; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + public Boolean getIsAdmin() { + return isAdmin; + } + + public void setIsAdmin(Boolean isAdmin) { + this.isAdmin = isAdmin; + } + + public LocalDateTime getLastLoginTime() { + return lastLoginTime; + } + + public void setLastLoginTime(LocalDateTime lastLoginTime) { + this.lastLoginTime = lastLoginTime; + } + + public String getLastLoginIp() { + return lastLoginIp; + } + + public void setLastLoginIp(String lastLoginIp) { + this.lastLoginIp = lastLoginIp; + } + + public Integer getLoginFailCount() { + return loginFailCount; + } + + public void setLoginFailCount(Integer loginFailCount) { + this.loginFailCount = loginFailCount; + } + + public LocalDateTime getLockTime() { + return lockTime; + } + + public void setLockTime(LocalDateTime lockTime) { + this.lockTime = lockTime; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/entity/UserFavorite.java b/vocata-server/src/main/java/com/vocata/user/entity/UserFavorite.java new file mode 100644 index 0000000..7a80d2a --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/entity/UserFavorite.java @@ -0,0 +1,96 @@ +package com.vocata.user.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDateTime; + +/** + * 用户角色收藏实体类 + * 对应数据库表:vocata_user_favorite + */ +@TableName("vocata_user_favorite") +public class UserFavorite { + + /** + * 主键ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 用户ID + */ + @TableField("user_id") + private Long userId; + + /** + * 角色ID + */ + @TableField("character_id") + private Long characterId; + + /** + * 收藏时间 + */ + @TableField("created_at") + private LocalDateTime createdAt; + + /** + * 更新时间 + */ + @TableField("updated_at") + private LocalDateTime updatedAt; + + public UserFavorite() { + } + + public UserFavorite(Long userId, Long characterId) { + this.userId = userId; + this.characterId = characterId; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getCharacterId() { + return characterId; + } + + public void setCharacterId(Long characterId) { + this.characterId = characterId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/mapper/UserFavoriteMapper.java b/vocata-server/src/main/java/com/vocata/user/mapper/UserFavoriteMapper.java new file mode 100644 index 0000000..37dc4b3 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/mapper/UserFavoriteMapper.java @@ -0,0 +1,111 @@ +package com.vocata.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.user.dto.response.FavoriteResponse; +import com.vocata.user.entity.UserFavorite; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * 用户收藏Mapper接口 + */ +@Mapper +public interface UserFavoriteMapper extends BaseMapper { + + /** + * 分页查询用户收藏的角色列表 + * @param page 分页对象 + * @param userId 用户ID + * @return 收藏基础信息列表 + */ + @Select(""" + SELECT + uf.id, + uf.user_id, + uf.character_id, + uf.created_at + FROM vocata_user_favorite uf + LEFT JOIN vocata_character c ON uf.character_id = c.id + WHERE uf.user_id = #{userId} + AND c.is_delete = 0 + AND c.status = 1 + ORDER BY uf.created_at DESC + """) + Page getFavoritesByUserId(Page page, @Param("userId") Long userId); + + /** + * 获取用户收藏数量 + * @param userId 用户ID + * @return 收藏数量 + */ + @Select(""" + SELECT COUNT(1) + FROM vocata_user_favorite uf + LEFT JOIN vocata_character c ON uf.character_id = c.id + WHERE uf.user_id = #{userId} + AND c.is_delete = 0 + AND c.status = 1 + """) + Integer getFavoriteCountByUserId(@Param("userId") Long userId); + + /** + * 批量检查收藏状态 + * @param userId 用户ID + * @param characterIds 角色ID列表 + * @return 收藏状态Map,key为characterId,value为是否收藏 + */ + @Select(""" + + """) + List> batchCheckFavoriteStatus(@Param("userId") Long userId, @Param("characterIds") List characterIds); + + /** + * 获取角色收藏数排行榜 + * @param limit 限制数量 + * @return 角色收藏数排行,包含角色ID和收藏数 + */ + @Select(""" + SELECT + c.id as character_id, + c.character_code, + c.name, + c.avatar_url, + COUNT(uf.id) as favorite_count + FROM vocata_character c + LEFT JOIN vocata_user_favorite uf ON c.id = uf.character_id + WHERE c.is_delete = 0 + AND c.status = 1 + AND c.is_private = false + GROUP BY c.id, c.character_code, c.name, c.avatar_url + ORDER BY favorite_count DESC, c.created_at DESC + LIMIT #{limit} + """) + List> getFavoriteRanking(@Param("limit") Integer limit); + + /** + * 检查用户是否已收藏指定角色 + * @param userId 用户ID + * @param characterId 角色ID + * @return 收藏记录ID,不存在返回null + */ + @Select(""" + SELECT id + FROM vocata_user_favorite + WHERE user_id = #{userId} + AND character_id = #{characterId} + """) + Long checkUserFavorite(@Param("userId") Long userId, @Param("characterId") Long characterId); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/mapper/UserMapper.java b/vocata-server/src/main/java/com/vocata/user/mapper/UserMapper.java new file mode 100644 index 0000000..d5fc00b --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/mapper/UserMapper.java @@ -0,0 +1,12 @@ +package com.vocata.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.user.entity.User; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户Mapper + */ +@Mapper +public interface UserMapper extends BaseMapper { +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/service/UserFavoriteService.java b/vocata-server/src/main/java/com/vocata/user/service/UserFavoriteService.java new file mode 100644 index 0000000..c47420e --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/service/UserFavoriteService.java @@ -0,0 +1,77 @@ +package com.vocata.user.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.vocata.common.result.PageResult; +import com.vocata.user.dto.response.FavoriteResponse; + +import java.util.List; +import java.util.Map; + +/** + * 用户收藏服务接口 + */ +public interface UserFavoriteService { + + /** + * 切换收藏状态(收藏/取消收藏) + * @param userId 用户ID + * @param characterId 角色ID + * @return 操作结果,包含isFavorited(当前是否已收藏)和action(执行的操作:add/remove) + */ + Map toggleFavorite(Long userId, Long characterId); + + /** + * 收藏角色 + * @param userId 用户ID + * @param characterId 角色ID + * @return 是否收藏成功 + */ + boolean favoriteCharacter(Long userId, Long characterId); + + /** + * 取消收藏角色 + * @param userId 用户ID + * @param characterId 角色ID + * @return 是否取消收藏成功 + */ + boolean unfavoriteCharacter(Long userId, Long characterId); + + /** + * 分页获取用户收藏的角色列表 + * @param userId 用户ID + * @param pageNum 页码 + * @param pageSize 每页大小 + * @return 收藏角色分页列表 + */ + PageResult getUserFavorites(Long userId, Integer pageNum, Integer pageSize); + + /** + * 获取用户收藏角色数量 + * @param userId 用户ID + * @return 收藏数量 + */ + Integer getUserFavoriteCount(Long userId); + + /** + * 批量检查收藏状态 + * @param userId 用户ID + * @param characterIds 角色ID列表 + * @return 收藏状态Map,key为characterId,value为是否收藏 + */ + Map batchCheckFavoriteStatus(Long userId, List characterIds); + + /** + * 获取角色收藏数排行榜 + * @param limit 限制数量,默认10 + * @return 角色收藏排行列表 + */ + List> getFavoriteRanking(Integer limit); + + /** + * 检查用户是否已收藏指定角色 + * @param userId 用户ID + * @param characterId 角色ID + * @return 是否已收藏 + */ + boolean isUserFavorite(Long userId, Long characterId); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/service/UserService.java b/vocata-server/src/main/java/com/vocata/user/service/UserService.java new file mode 100644 index 0000000..134f747 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/service/UserService.java @@ -0,0 +1,36 @@ +package com.vocata.user.service; + +import com.vocata.user.dto.UpdateUserProfileRequest; +import com.vocata.user.dto.UserProfileResponse; + +/** + * 用户服务接口 + * + * @author vocata + * @since 2025-09-24 + */ +public interface UserService { + + /** + * 获取当前用户个人信息 + * + * @return 用户个人信息 + */ + UserProfileResponse getCurrentUserProfile(); + + /** + * 更新当前用户个人信息 + * + * @param request 更新请求 + * @return 更新后的用户信息 + */ + UserProfileResponse updateCurrentUserProfile(UpdateUserProfileRequest request); + + /** + * 更新用户头像 + * + * @param avatarUrl 头像URL + * @return 更新后的用户信息 + */ + UserProfileResponse updateUserAvatar(String avatarUrl); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/service/impl/UserFavoriteServiceImpl.java b/vocata-server/src/main/java/com/vocata/user/service/impl/UserFavoriteServiceImpl.java new file mode 100644 index 0000000..0115a37 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/service/impl/UserFavoriteServiceImpl.java @@ -0,0 +1,225 @@ +package com.vocata.user.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.vocata.character.dto.response.CharacterResponse; +import com.vocata.character.entity.Character; +import com.vocata.character.mapper.CharacterMapper; +import com.vocata.common.result.ApiCode; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.PageResult; +import com.vocata.user.dto.response.FavoriteResponse; +import com.vocata.user.entity.UserFavorite; +import com.vocata.user.mapper.UserFavoriteMapper; +import com.vocata.user.service.UserFavoriteService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 用户收藏服务实现类 + */ +@Service +public class UserFavoriteServiceImpl extends ServiceImpl implements UserFavoriteService { + + @Autowired + private UserFavoriteMapper userFavoriteMapper; + + @Autowired + private CharacterMapper characterMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map toggleFavorite(Long userId, Long characterId) { + Map result = new HashMap<>(); + + // 检查角色是否存在且可用 + Character character = characterMapper.selectOne( + new LambdaQueryWrapper() + .eq(Character::getId, characterId) + .eq(Character::getIsDelete, 0) + .eq(Character::getStatus, 1) + ); + if (character == null) { + throw new BizException(ApiCode.CHARACTER_NOT_EXIST); + } + + // 检查当前收藏状态 + Long existingFavoriteId = userFavoriteMapper.checkUserFavorite(userId, characterId); + + if (existingFavoriteId != null) { + // 已收藏,执行取消收藏操作(直接删除记录) + boolean success = this.removeById(existingFavoriteId); + result.put("success", success); + result.put("isFavorited", false); + result.put("action", "remove"); + result.put("message", "取消收藏成功"); + } else { + // 未收藏,执行收藏操作 + UserFavorite userFavorite = new UserFavorite(userId, characterId); + + boolean success = this.save(userFavorite); + result.put("success", success); + result.put("isFavorited", true); + result.put("action", "add"); + result.put("message", "收藏成功"); + } + + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean favoriteCharacter(Long userId, Long characterId) { + // 检查角色是否存在且可用 + Character character = characterMapper.selectOne( + new LambdaQueryWrapper() + .eq(Character::getId, characterId) + .eq(Character::getIsDelete, 0) + .eq(Character::getStatus, 1) + ); + if (character == null) { + throw new BizException(ApiCode.CHARACTER_NOT_EXIST); + } + + // 检查是否已经收藏 + Long existingFavoriteId = userFavoriteMapper.checkUserFavorite(userId, characterId); + if (existingFavoriteId != null) { + throw new BizException(ApiCode.FAVORITE_ALREADY_EXISTS); + } + + // 创建收藏记录 + UserFavorite userFavorite = new UserFavorite(userId, characterId); + + return this.save(userFavorite); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean unfavoriteCharacter(Long userId, Long characterId) { + // 检查收藏记录是否存在 + Long favoriteId = userFavoriteMapper.checkUserFavorite(userId, characterId); + if (favoriteId == null) { + throw new BizException(ApiCode.FAVORITE_NOT_EXIST); + } + + // 直接删除收藏记录 + return this.removeById(favoriteId); + } + + @Override + public PageResult getUserFavorites(Long userId, Integer pageNum, Integer pageSize) { + // 参数校验 + if (pageNum == null || pageNum < 1) { + pageNum = 1; + } + if (pageSize == null || pageSize < 1 || pageSize > 100) { + pageSize = 10; + } + + // 分页查询收藏记录 + Page page = new Page<>(pageNum, pageSize); + Page favoriteRecords = userFavoriteMapper.getFavoritesByUserId(page, userId); + + // 构建响应对象列表 + List responseList = favoriteRecords.getRecords().stream().map(favorite -> { + FavoriteResponse response = new FavoriteResponse(); + response.setId(favorite.getId()); + response.setUserId(favorite.getUserId()); + response.setCharacterId(favorite.getCharacterId()); + response.setCreatedAt(favorite.getCreatedAt()); + + // 获取角色详细信息 + Character character = characterMapper.selectById(favorite.getCharacterId()); + if (character != null) { + CharacterResponse characterResponse = convertToCharacterResponse(character); + response.setCharacter(characterResponse); + } + + return response; + }).collect(Collectors.toList()); + + return new PageResult<>(pageNum, pageSize, favoriteRecords.getTotal(), responseList); + } + + /** + * 将Character实体转换为CharacterResponse + */ + private CharacterResponse convertToCharacterResponse(Character character) { + CharacterResponse response = new CharacterResponse(); + response.setId(character.getId()); + response.setCharacterCode(character.getCharacterCode()); + response.setName(character.getName()); + response.setDescription(character.getDescription()); + response.setGreeting(character.getGreeting()); + response.setAvatarUrl(character.getAvatarUrl()); + response.setTags(character.getTags()); + response.setLanguage(character.getLanguage()); + response.setStatus(character.getStatus()); + response.setIsOfficial(character.getIsOfficial()); + response.setIsFeatured(character.getIsFeatured()); + response.setIsTrending(character.getIsTrending()); + response.setTrendingScore(character.getTrendingScore()); + response.setChatCount(character.getChatCount()); + response.setUserCount(character.getUserCount()); + response.setIsPrivate(character.getIsPrivate()); + response.setTagIds(character.getTagIds()); + response.setTagNames(character.getTagNames()); + response.setPrimaryTagIds(character.getPrimaryTagIds()); + response.setTagSummary(character.getTagSummary()); + response.setCreateId(character.getCreateId()); + response.setCreatedAt(character.getCreateDate()); + response.setUpdatedAt(character.getUpdateDate()); + return response; + } + + @Override + public Integer getUserFavoriteCount(Long userId) { + Integer count = userFavoriteMapper.getFavoriteCountByUserId(userId); + return count != null ? count : 0; + } + + @Override + public Map batchCheckFavoriteStatus(Long userId, List characterIds) { + if (characterIds == null || characterIds.isEmpty()) { + return new HashMap<>(); + } + + // 获取已收藏的角色ID列表 + List> favoriteList = userFavoriteMapper.batchCheckFavoriteStatus(userId, characterIds); + Map favoriteMap = favoriteList.stream() + .collect(Collectors.toMap( + item -> String.valueOf(item.get("character_id")), + item -> true + )); + + // 构建完整的结果Map,未收藏的设为false + Map result = new HashMap<>(); + for (Long characterId : characterIds) { + result.put(String.valueOf(characterId), favoriteMap.getOrDefault(String.valueOf(characterId), false)); + } + + return result; + } + + @Override + public List> getFavoriteRanking(Integer limit) { + if (limit == null || limit < 1 || limit > 100) { + limit = 10; + } + return userFavoriteMapper.getFavoriteRanking(limit); + } + + @Override + public boolean isUserFavorite(Long userId, Long characterId) { + Long favoriteId = userFavoriteMapper.checkUserFavorite(userId, characterId); + return favoriteId != null; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/user/service/impl/UserServiceImpl.java b/vocata-server/src/main/java/com/vocata/user/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..23dba50 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/user/service/impl/UserServiceImpl.java @@ -0,0 +1,113 @@ +package com.vocata.user.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.vocata.common.utils.UserContext; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.user.dto.UpdateUserProfileRequest; +import com.vocata.user.dto.UserProfileResponse; +import com.vocata.user.entity.User; +import com.vocata.user.mapper.UserMapper; +import com.vocata.user.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +/** + * 用户服务实现 + * + * @author vocata + * @since 2025-09-24 + */ +@Service +public class UserServiceImpl implements UserService { + + private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class); + + private final UserMapper userMapper; + + public UserServiceImpl(UserMapper userMapper) { + this.userMapper = userMapper; + } + + @Override + public UserProfileResponse getCurrentUserProfile() { + Long userId = UserContext.getUserId(); + + User user = userMapper.selectById(userId); + if (user == null) { + throw new BizException(ApiCode.USER_NOT_EXIST); + } + + return UserProfileResponse.builder() + .id(user.getId().toString()) + .username(user.getUsername()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .avatar(user.getAvatar()) + .gender(user.getGender()) + .phone(user.getPhone()) + .birthday(user.getBirthday()) + .createDate(user.getCreateDate()) + .build(); + } + + @Override + public UserProfileResponse updateCurrentUserProfile(UpdateUserProfileRequest request) { + Long userId = UserContext.getUserId(); + + User user = userMapper.selectById(userId); + if (user == null) { + throw new BizException(ApiCode.USER_NOT_EXIST); + } + + // 更新用户信息 + if (request.getNickname() != null) { + user.setNickname(request.getNickname()); + } + if (request.getAvatar() != null) { + user.setAvatar(request.getAvatar()); + } + if (request.getGender() != null) { + user.setGender(request.getGender()); + } + if (request.getPhone() != null) { + user.setPhone(request.getPhone()); + } + if (request.getBirthday() != null) { + user.setBirthday(request.getBirthday()); + } + + // 更新到数据库 + int updated = userMapper.updateById(user); + if (updated == 0) { + throw new BizException(ApiCode.ERROR); + } + + log.info("用户 {} 更新个人信息成功", userId); + + // 返回更新后的用户信息 + return getCurrentUserProfile(); + } + + @Override + public UserProfileResponse updateUserAvatar(String avatarUrl) { + Long userId = UserContext.getUserId(); + + User user = userMapper.selectById(userId); + if (user == null) { + throw new BizException(ApiCode.USER_NOT_EXIST); + } + + user.setAvatar(avatarUrl); + int updated = userMapper.updateById(user); + if (updated == 0) { + throw new BizException(ApiCode.ERROR); + } + + log.info("用户 {} 更新头像成功: {}", userId, avatarUrl); + + return getCurrentUserProfile(); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/controller/TtsVoiceController.java b/vocata-server/src/main/java/com/vocata/voice/controller/TtsVoiceController.java new file mode 100644 index 0000000..90f78e4 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/controller/TtsVoiceController.java @@ -0,0 +1,33 @@ +package com.vocata.voice.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import com.vocata.common.result.ApiResponse; +import com.vocata.voice.dto.TtsVoiceListResponse; +import com.vocata.voice.service.TtsVoiceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * TTS音色客户端控制器 + * 需要登录才能访问 + */ +@RestController +@RequestMapping("/api/client/tts-voice") +@SaCheckLogin +public class TtsVoiceController { + + @Autowired + private TtsVoiceService ttsVoiceService; + + /** + * 获取音色简化列表(仅包含id和name) + * 客户端使用,需要登录 + */ + @GetMapping("/list") + public ApiResponse> getVoiceList() { + List voices = ttsVoiceService.getVoiceList(); + return ApiResponse.success("获取音色列表成功", voices); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceAddRequest.java b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceAddRequest.java new file mode 100644 index 0000000..037c17f --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceAddRequest.java @@ -0,0 +1,59 @@ +package com.vocata.voice.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * TTS音色添加请求 + */ +public class TtsVoiceAddRequest { + + @NotBlank(message = "服务商音色ID不能为空") + @Size(max = 100, message = "服务商音色ID长度不能超过100字符") + private String providerVoiceId; + + @NotBlank(message = "音色名称不能为空") + @Size(max = 100, message = "音色名称长度不能超过100字符") + private String name; + + @NotBlank(message = "服务提供商不能为空") + @Size(max = 50, message = "服务提供商长度不能超过50字符") + private String provider; + + @NotBlank(message = "语言代码不能为空") + @Size(max = 10, message = "语言代码长度不能超过10字符") + private String languageCode; + + // Getters and Setters + public String getProviderVoiceId() { + return providerVoiceId; + } + + public void setProviderVoiceId(String providerVoiceId) { + this.providerVoiceId = providerVoiceId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getLanguageCode() { + return languageCode; + } + + public void setLanguageCode(String languageCode) { + this.languageCode = languageCode; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceListResponse.java b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceListResponse.java new file mode 100644 index 0000000..6075d9b --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceListResponse.java @@ -0,0 +1,28 @@ +package com.vocata.voice.dto; + +/** + * TTS音色列表项响应(仅包含id和name) + */ +public class TtsVoiceListResponse { + + private String id; + + private String name; + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceResponse.java b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceResponse.java new file mode 100644 index 0000000..a74d7f5 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceResponse.java @@ -0,0 +1,58 @@ +package com.vocata.voice.dto; + +/** + * TTS音色响应 + */ +public class TtsVoiceResponse { + + private String id; + + private String providerVoiceId; + + private String name; + + private String provider; + + private String languageCode; + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProviderVoiceId() { + return providerVoiceId; + } + + public void setProviderVoiceId(String providerVoiceId) { + this.providerVoiceId = providerVoiceId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getLanguageCode() { + return languageCode; + } + + public void setLanguageCode(String languageCode) { + this.languageCode = languageCode; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceUpdateRequest.java b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceUpdateRequest.java new file mode 100644 index 0000000..7405d30 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/dto/TtsVoiceUpdateRequest.java @@ -0,0 +1,59 @@ +package com.vocata.voice.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * TTS音色更新请求 + */ +public class TtsVoiceUpdateRequest { + + @NotBlank(message = "服务商音色ID不能为空") + @Size(max = 100, message = "服务商音色ID长度不能超过100字符") + private String providerVoiceId; + + @NotBlank(message = "音色名称不能为空") + @Size(max = 100, message = "音色名称长度不能超过100字符") + private String name; + + @NotBlank(message = "服务提供商不能为空") + @Size(max = 50, message = "服务提供商长度不能超过50字符") + private String provider; + + @NotBlank(message = "语言代码不能为空") + @Size(max = 10, message = "语言代码长度不能超过10字符") + private String languageCode; + + // Getters and Setters + public String getProviderVoiceId() { + return providerVoiceId; + } + + public void setProviderVoiceId(String providerVoiceId) { + this.providerVoiceId = providerVoiceId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getLanguageCode() { + return languageCode; + } + + public void setLanguageCode(String languageCode) { + this.languageCode = languageCode; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/entity/TtsVoice.java b/vocata-server/src/main/java/com/vocata/voice/entity/TtsVoice.java new file mode 100644 index 0000000..52512ad --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/entity/TtsVoice.java @@ -0,0 +1,79 @@ +package com.vocata.voice.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.vocata.common.entity.BaseEntity; + +/** + * TTS音色实体类 + * 对应数据库表 vocata_tts_voices + */ +@TableName("vocata_tts_voices") +public class TtsVoice extends BaseEntity { + + /** + * 音色主键ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * TTS服务商提供的声音ID (例如 ElevenLabs 的 "21m00Tcm4TlvDq8ikWAM") + */ + private String providerVoiceId; + + /** + * 声音的人类可读名称 (例如 "艾拉-温柔女声") + */ + private String name; + + /** + * TTS服务提供商 (例如 "ElevenLabs", "Azure", "OpenAI") + */ + private String provider; + + /** + * 主要支持的语言代码 (例如 "zh-CN", "en-US") + */ + private String languageCode; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getProviderVoiceId() { + return providerVoiceId; + } + + public void setProviderVoiceId(String providerVoiceId) { + this.providerVoiceId = providerVoiceId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getLanguageCode() { + return languageCode; + } + + public void setLanguageCode(String languageCode) { + this.languageCode = languageCode; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/entity/VoiceProfile.java b/vocata-server/src/main/java/com/vocata/voice/entity/VoiceProfile.java new file mode 100644 index 0000000..33d9364 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/entity/VoiceProfile.java @@ -0,0 +1,78 @@ +package com.vocata.voice.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.vocata.common.entity.BaseEntity; + +/** + * 音色配置表(精简版) + */ +@TableName("vocata_voice_profile") +public class VoiceProfile extends BaseEntity { + + /** + * 主键ID + */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 业务音色ID(如:voice-en-harry) + */ + private String voiceId; + + /** + * 音色名称(如:哈利波特) + */ + private String voiceName; + + /** + * TTS提供商(如:xunfei) + */ + private String provider; + + /** + * 提供商真实音色参数(如:aisjiuxu) + */ + private String providerVoiceId; + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getVoiceId() { + return voiceId; + } + + public void setVoiceId(String voiceId) { + this.voiceId = voiceId; + } + + public String getVoiceName() { + return voiceName; + } + + public void setVoiceName(String voiceName) { + this.voiceName = voiceName; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getProviderVoiceId() { + return providerVoiceId; + } + + public void setProviderVoiceId(String providerVoiceId) { + this.providerVoiceId = providerVoiceId; + } +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/mapper/TtsVoiceMapper.java b/vocata-server/src/main/java/com/vocata/voice/mapper/TtsVoiceMapper.java new file mode 100644 index 0000000..b314c6c --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/mapper/TtsVoiceMapper.java @@ -0,0 +1,12 @@ +package com.vocata.voice.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.vocata.voice.entity.TtsVoice; +import org.apache.ibatis.annotations.Mapper; + +/** + * TTS音色Mapper + */ +@Mapper +public interface TtsVoiceMapper extends BaseMapper { +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/service/TtsVoiceService.java b/vocata-server/src/main/java/com/vocata/voice/service/TtsVoiceService.java new file mode 100644 index 0000000..f1d0c1d --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/service/TtsVoiceService.java @@ -0,0 +1,45 @@ +package com.vocata.voice.service; + +import com.vocata.voice.dto.TtsVoiceAddRequest; +import com.vocata.voice.dto.TtsVoiceResponse; +import com.vocata.voice.dto.TtsVoiceUpdateRequest; +import com.vocata.voice.dto.TtsVoiceListResponse; + +import java.util.List; + +/** + * TTS音色服务接口 + */ +public interface TtsVoiceService { + + /** + * 添加音色 + */ + TtsVoiceResponse addVoice(TtsVoiceAddRequest request); + + /** + * 删除音色 + */ + void deleteVoice(Long id); + + /** + * 更新音色 + */ + TtsVoiceResponse updateVoice(Long id, TtsVoiceUpdateRequest request); + + /** + * 获取音色列表(仅包含id和name)- 客户端使用 + */ + List getVoiceList(); + + /** + * 获取音色完整列表 - 管理后台使用 + */ + List getFullVoiceList(); + + /** + * 根据音色名称获取服务商音色ID + * 用于角色音色查询 + */ + String getProviderVoiceIdByName(String name); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/service/VoiceProfileService.java b/vocata-server/src/main/java/com/vocata/voice/service/VoiceProfileService.java new file mode 100644 index 0000000..06d7efb --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/service/VoiceProfileService.java @@ -0,0 +1,21 @@ +package com.vocata.voice.service; + +import com.vocata.voice.entity.VoiceProfile; +import java.util.Optional; + +/** + * 音色配置服务接口 + */ +public interface VoiceProfileService { + + /** + * 根据音色ID和提供商获取音色配置 + */ + Optional getByVoiceIdAndProvider(String voiceId, String provider); + + /** + * 根据音色ID获取提供商音色参数 + * 这是TTS服务最常用的方法 + */ + String getProviderVoiceId(String voiceId, String provider); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/service/VoiceResolverService.java b/vocata-server/src/main/java/com/vocata/voice/service/VoiceResolverService.java new file mode 100644 index 0000000..568c988 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/service/VoiceResolverService.java @@ -0,0 +1,17 @@ +package com.vocata.voice.service; + +/** + * 音色解析服务 + * 负责将业务音色ID解析为具体TTS提供商的音色参数 + */ +public interface VoiceResolverService { + + /** + * 解析音色ID为指定TTS提供商的音色参数 + * + * @param voiceId 业务音色ID(如:voice-en-harry) + * @param provider TTS提供商(如:xunfei) + * @return TTS提供商的具体音色参数(如:aisjiuxu) + */ + String resolveVoiceId(String voiceId, String provider); +} \ No newline at end of file diff --git a/vocata-server/src/main/java/com/vocata/voice/service/impl/TtsVoiceServiceImpl.java b/vocata-server/src/main/java/com/vocata/voice/service/impl/TtsVoiceServiceImpl.java new file mode 100644 index 0000000..762cbb2 --- /dev/null +++ b/vocata-server/src/main/java/com/vocata/voice/service/impl/TtsVoiceServiceImpl.java @@ -0,0 +1,137 @@ +package com.vocata.voice.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.vocata.common.exception.BizException; +import com.vocata.common.result.ApiCode; +import com.vocata.voice.dto.TtsVoiceAddRequest; +import com.vocata.voice.dto.TtsVoiceResponse; +import com.vocata.voice.dto.TtsVoiceUpdateRequest; +import com.vocata.voice.dto.TtsVoiceListResponse; +import com.vocata.voice.entity.TtsVoice; +import com.vocata.voice.mapper.TtsVoiceMapper; +import com.vocata.voice.service.TtsVoiceService; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * TTS音色服务实现 + */ +@Service +public class TtsVoiceServiceImpl implements TtsVoiceService { + + private final TtsVoiceMapper ttsVoiceMapper; + + public TtsVoiceServiceImpl(TtsVoiceMapper ttsVoiceMapper) { + this.ttsVoiceMapper = ttsVoiceMapper; + } + + @Override + public TtsVoiceResponse addVoice(TtsVoiceAddRequest request) { + // 检查是否已存在相同的provider和providerVoiceId组合 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(TtsVoice::getProvider, request.getProvider()) + .eq(TtsVoice::getProviderVoiceId, request.getProviderVoiceId()); + + TtsVoice existingVoice = ttsVoiceMapper.selectOne(queryWrapper); + if (existingVoice != null) { + throw new BizException(ApiCode.PARAM_ERROR, "该服务商音色ID已存在"); + } + + // 创建新音色 + TtsVoice ttsVoice = new TtsVoice(); + BeanUtils.copyProperties(request, ttsVoice); + + ttsVoiceMapper.insert(ttsVoice); + + // 返回响应 + TtsVoiceResponse response = new TtsVoiceResponse(); + BeanUtils.copyProperties(ttsVoice, response); + response.setId(ttsVoice.getId().toString()); + + return response; + } + + @Override + public void deleteVoice(Long id) { + TtsVoice ttsVoice = ttsVoiceMapper.selectById(id); + if (ttsVoice == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "音色不存在"); + } + + ttsVoiceMapper.deleteById(id); + } + + @Override + public TtsVoiceResponse updateVoice(Long id, TtsVoiceUpdateRequest request) { + TtsVoice ttsVoice = ttsVoiceMapper.selectById(id); + if (ttsVoice == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "音色不存在"); + } + + // 检查是否存在相同的provider和providerVoiceId组合(排除当前记录) + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(TtsVoice::getProvider, request.getProvider()) + .eq(TtsVoice::getProviderVoiceId, request.getProviderVoiceId()) + .ne(TtsVoice::getId, id); + + TtsVoice existingVoice = ttsVoiceMapper.selectOne(queryWrapper); + if (existingVoice != null) { + throw new BizException(ApiCode.PARAM_ERROR, "该服务商音色ID已存在"); + } + + // 更新音色信息 + BeanUtils.copyProperties(request, ttsVoice); + ttsVoiceMapper.updateById(ttsVoice); + + // 返回响应 + TtsVoiceResponse response = new TtsVoiceResponse(); + BeanUtils.copyProperties(ttsVoice, response); + response.setId(ttsVoice.getId().toString()); + + return response; + } + + @Override + public List getVoiceList() { + List voices = ttsVoiceMapper.selectList(null); + + return voices.stream() + .map(voice -> { + TtsVoiceListResponse response = new TtsVoiceListResponse(); + response.setId(voice.getId().toString()); + response.setName(voice.getName()); + return response; + }) + .collect(Collectors.toList()); + } + + @Override + public List getFullVoiceList() { + List voices = ttsVoiceMapper.selectList(null); + + return voices.stream() + .map(voice -> { + TtsVoiceResponse response = new TtsVoiceResponse(); + BeanUtils.copyProperties(voice, response); + response.setId(voice.getId().toString()); + return response; + }) + .collect(Collectors.toList()); + } + + @Override + public String getProviderVoiceIdByName(String name) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(TtsVoice::getName, name); + + TtsVoice ttsVoice = ttsVoiceMapper.selectOne(queryWrapper); + if (ttsVoice == null) { + throw new BizException(ApiCode.DATA_NOT_FOUND, "音色不存在"); + } + + return ttsVoice.getProviderVoiceId(); + } +} \ No newline at end of file diff --git a/vocata-server/src/main/resources/application-local.yml.template b/vocata-server/src/main/resources/application-local.yml.template new file mode 100644 index 0000000..1d092ec --- /dev/null +++ b/vocata-server/src/main/resources/application-local.yml.template @@ -0,0 +1,56 @@ +# 复制此文件为 application-local.yml 并修改配置 + +spring: + # 数据库配置 + datasource: + url: jdbc:postgresql://YOUR_DB_HOST:5432/YOUR_DB_NAME + username: YOUR_DB_USERNAME + password: YOUR_DB_PASSWORD + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + # Redis配置 + data: + redis: + host: YOUR_REDIS_HOST + port: 6379 + password: YOUR_REDIS_PASSWORD + database: 0 + timeout: 10000ms + lettuce: + pool: + max-active: 8 + max-wait: -1ms + max-idle: 8 + min-idle: 0 + + # 邮件配置 (163邮箱SMTP) + mail: + host: smtp.163.com + port: 465 + username: YOUR_EMAIL@163.com + password: YOUR_EMAIL_PASSWORD + protocol: smtp + default-encoding: UTF-8 + test-connection: false + properties: + mail: + smtp: + auth: true + ssl: + enable: true + socketFactory: + class: javax.net.ssl.SSLSocketFactory + port: 465 + +# 开发环境日志配置 +logging: + level: + com.vocata: debug + com.baomidou.mybatisplus: debug + org.springframework.web: debug \ No newline at end of file diff --git a/vocata-server/src/main/resources/application-prod.yml b/vocata-server/src/main/resources/application-prod.yml index a9fe286..bb50c19 100644 --- a/vocata-server/src/main/resources/application-prod.yml +++ b/vocata-server/src/main/resources/application-prod.yml @@ -1,10 +1,14 @@ # 生产环境配置 +server: + port: ${SERVER_PORT:9009} + spring: # 数据源配置 datasource: - url: jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME:vocata} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: jdbc:postgresql://${DB_HOST_PROD}:${DB_PORT_PROD}/${DB_NAME_PROD}?stringtype=unspecified + username: ${DB_USERNAME_PROD} + password: ${DB_PASSWORD_PROD} + driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 20 minimum-idle: 10 @@ -15,10 +19,11 @@ spring: # Redis配置 data: redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD} - database: ${REDIS_DB:0} + host: ${REDIS_HOST_PROD} + port: ${REDIS_PORT_PROD:6379} + password: ${REDIS_PASSWORD_PROD} + database: 0 + timeout: 10000ms lettuce: pool: max-active: 16 @@ -26,28 +31,158 @@ spring: max-idle: 8 min-idle: 2 + # 邮件配置(使用QQ邮箱) + mail: + host: smtp.qq.com + port: 587 + username: ${EMAIL_USER_NAME:your-qq-email@qq.com} + password: ${EMAIL_USER_PASSWORD:your-qq-auth-code} + protocol: smtp + test-connection: false + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + timeout: 30000 + connectiontimeout: 30000 + writetimeout: 30000 + +# MyBatis Plus配置 +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + global-config: + db-config: + id-type: ASSIGN_ID + logic-delete-field: isDelete + logic-delete-value: 1 + logic-not-delete-value: 0 + insert-strategy: NOT_NULL + update-strategy: NOT_NULL + banner: false + mapper-locations: classpath*:/mapper/**/*.xml + +# Sa-Token配置 +sa-token: + # token名称 + token-name: Authorization + # token有效期,单位s (7天) + timeout: 604800 + # token临时有效期,单位s + active-timeout: -1 + # 更严格的安全配置 - 禁止并发登录 + is-concurrent: false + # 在多人登录同一账号时,是否共用一个token + is-share: false + # token风格 + token-style: uuid + # 是否输出操作日志 (生产环境关闭) + is-log: false + # 是否从header中读取token + is-read-header: true + # 是否从cookie中读取token + is-read-cookie: false + # 是否从body中读取token + is-read-body: false + # token前缀(支持Bearer前缀) + token-prefix: Bearer + # 配置Redis作为Sa-Token的持久层 + alone-redis: + database: 0 + +# AI服务配置 +ai: + llm: + provider: ${AI_LLM_PROVIDER:qiniu} # 默认使用七牛云AI + stt: + provider: ${AI_STT_PROVIDER:qiniu} + tts: + provider: ${AI_TTS_PROVIDER:volcan} + +# 七牛云配置 +qiniu: + access-key: ${QINIU_ACCESS_KEY_PROD} + secret-key: ${QINIU_SECRET_KEY_PROD} + bucket: ${QINIU_BUCKET_PROD:vocata-files-prod} + domain: ${QINIU_DOMAIN_PROD:https://files.vocata.com} + region: ${QINIU_REGION_PROD:huadong} + # 七牛云AI配置 + ai: + api-key: ${QINIU_AI_API_KEY} + base-url: ${QINIU_AI_BASE_URL:https://openai.qiniu.com/v1} + default-model: ${QINIU_AI_MODEL:x-ai/grok-4-fast} + timeout: ${QINIU_AI_TIMEOUT:60} + # 七牛云STT配置 + stt: + endpoint: ${QINIU_STT_ENDPOINT:https://openai.qiniu.com/v1} + model: ${QINIU_STT_MODEL:asr} + +# Gemini配置 +gemini: + api: + key: ${GEMINI_API_KEY} + base-url: ${GEMINI_BASE_URL:https://generativelanguage.googleapis.com} + default-model: ${GEMINI_MODEL:gemini-2.5-flash-lite} + timeout: ${GEMINI_TIMEOUT:60} + +# OpenAI配置 +openai: + api: + key: ${OPENAI_API_KEY} + base-url: ${OPENAI_BASE_URL:https://api.openai.com} + default-model: ${OPENAI_MODEL:gpt-3.5-turbo} + timeout: ${OPENAI_TIMEOUT:60} + +# SiliconFlow配置 +siliconflow: + ai: + api-key: ${SILICONFLOW_API_KEY} + base-url: ${SILICONFLOW_BASE_URL:https://api.siliconflow.cn/v1} + default-model: ${SILICONFLOW_AI_MODEL:Qwen/Qwen3-8B} + timeout: ${SILICONFLOW_TIMEOUT:120} + +# 科大讯飞配置 +xunfei: + # STT配置 + stt: + app-id: ${XUNFEI_STT_APP_ID:your-xunfei-stt-app-id} + api-key: ${XUNFEI_STT_API_KEY:your-xunfei-stt-api-key} + secret-key: ${XUNFEI_STT_SECRET_KEY:your-xunfei-stt-secret-key} + host: ${XUNFEI_STT_HOST:iat-api.xfyun.cn} + path: ${XUNFEI_STT_PATH:/v2/iat} + # TTS配置 + tts: + app-id: ${XUNFEI_TTS_APP_ID:your-xunfei-tts-app-id} + api-key: ${XUNFEI_TTS_API_KEY:your-xunfei-tts-api-key} + secret-key: ${XUNFEI_TTS_SECRET_KEY:your-xunfei-tts-secret-key} + host: ${XUNFEI_TTS_HOST:tts-api.xfyun.cn} + path: ${XUNFEI_TTS_PATH:/v2/tts} + +# 火山引擎TTS配置 +volcan: + tts: + access-token: ${VOLCAN_TTS_ACCESS_TOKEN:your-volcan-access-token} + app-id: ${VOLCAN_TTS_APP_ID:your-volcan-app-id} + host: ${VOLCAN_TTS_HOST:openspeech.bytedance.com} + region: ${VOLCAN_TTS_REGION:ap-beijing-1} + # 日志配置 logging: level: com.vocata: info root: warn org.springframework.web: warn + org.springframework.security: warn + com.baomidou.mybatisplus: warn + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" file: name: /var/log/vocata/vocata-server.log max-size: 100MB - max-history: 30 - -# MyBatis Plus配置 (生产环境关闭SQL日志) -mybatis-plus: - configuration: - log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl - -# Sa-Token配置 (生产环境安全配置) -sa-token: - # 是否输出操作日志 - is-log: false - # token有效期 (生产环境7天) - timeout: 604800 - # 更严格的安全配置 - is-concurrent: false - is-share: false \ No newline at end of file + max-history: 30 \ No newline at end of file diff --git a/vocata-server/src/main/resources/application-test.yml b/vocata-server/src/main/resources/application-test.yml index 831b7da..efa6d5b 100644 --- a/vocata-server/src/main/resources/application-test.yml +++ b/vocata-server/src/main/resources/application-test.yml @@ -1,36 +1,185 @@ -# 测试环境配置 (前后端联调) +# 测试环境配置 +server: + port: ${SERVER_PORT:9009} + spring: # 数据源配置 datasource: - url: jdbc:postgresql://${DB_HOST:test-server.vocata.com}:${DB_PORT:5432}/${DB_NAME:vocata_test} - username: ${DB_USERNAME:vocata_test} - password: ${DB_PASSWORD:vocata_test} + url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?stringtype=unspecified + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 15 + minimum-idle: 5 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 # Redis配置 data: redis: - host: ${REDIS_HOST:test-server.vocata.com} + host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} - database: ${REDIS_DB:1} + database: 0 + timeout: 10000ms + lettuce: + pool: + max-active: 8 + max-wait: -1ms + max-idle: 8 + min-idle: 0 -# 日志配置 -logging: - level: - com.vocata: info - org.springframework.web: info - org.springframework.security: info - file: - name: logs/vocata-test.log + # 邮件配置(使用QQ邮箱,对云服务器更友好) + mail: + host: smtp.qq.com + port: 587 + username: ${EMAIL_USER_NAME:your-qq-email@qq.com} + password: ${EMAIL_USER_PASSWORD:your-qq-auth-code} + protocol: smtp + default-encoding: UTF-8 + test-connection: false + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + timeout: 30000 + connectiontimeout: 30000 + writetimeout: 30000 -# MyBatis Plus配置 (测试环境关闭SQL日志) +# MyBatis Plus配置 mybatis-plus: configuration: + map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl + global-config: + db-config: + id-type: ASSIGN_ID + logic-delete-field: isDelete + logic-delete-value: 1 + logic-not-delete-value: 0 + insert-strategy: NOT_NULL + update-strategy: NOT_NULL + banner: false + mapper-locations: classpath*:/mapper/**/*.xml -# Sa-Token配置 (测试环境调整) +# Sa-Token配置 sa-token: + # token名称 + token-name: Authorization + # token有效期,单位s (7天) + timeout: 604800 + # token临时有效期,单位s + active-timeout: -1 + # 是否允许同一账号并发登录 + is-concurrent: true + # 在多人登录同一账号时,是否共用一个token + is-share: true + # token风格 + token-style: uuid # 是否输出操作日志 is-log: true - # token有效期调整为7天 - timeout: 604800 \ No newline at end of file + # 是否从header中读取token + is-read-header: true + # 是否从cookie中读取token + is-read-cookie: false + # 是否从body中读取token + is-read-body: false + # token前缀(支持Bearer前缀) + token-prefix: Bearer + # 配置Redis作为Sa-Token的持久层 + alone-redis: + database: 0 + +# AI服务配置 +ai: + llm: + provider: ${AI_LLM_PROVIDER:qiniu} # 默认使用七牛云AI + stt: + provider: ${AI_STT_PROVIDER:qiniu} + tts: + provider: ${AI_TTS_PROVIDER:xunfei} + +# 七牛云配置 +qiniu: + access-key: ${QINIU_ACCESS_KEY:your-qiniu-access-key} + secret-key: ${QINIU_SECRET_KEY:your-qiniu-secret-key} + bucket: ${QINIU_BUCKET:vocata-files-staging} + domain: ${QINIU_DOMAIN:https://test-files.vocata.com} + region: ${QINIU_REGION:huadong} + # 七牛云AI配置 + ai: + api-key: ${QINIU_AI_API_KEY:your-qiniu-ai-api-key} + base-url: ${QINIU_AI_BASE_URL:https://openai.qiniu.com/v1} + default-model: ${QINIU_AI_MODEL:x-ai/grok-4-fast} + timeout: ${QINIU_AI_TIMEOUT:60} + # 七牛云STT配置 + stt: + endpoint: ${QINIU_STT_ENDPOINT:https://openai.qiniu.com/v1} + model: ${QINIU_STT_MODEL:asr} + +# Gemini配置 +gemini: + api: + key: ${GEMINI_API_KEY:your-gemini-api-key} + base-url: ${GEMINI_BASE_URL:https://generativelanguage.googleapis.com} + default-model: ${GEMINI_MODEL:gemini-2.5-flash-lite} + timeout: ${GEMINI_TIMEOUT:60} + +# OpenAI配置 +openai: + api: + key: ${OPENAI_API_KEY:your-openai-api-key} + base-url: ${OPENAI_BASE_URL:https://api.openai.com} + default-model: ${OPENAI_MODEL:gpt-3.5-turbo} + timeout: ${OPENAI_TIMEOUT:60} + +# SiliconFlow配置 +siliconflow: + ai: + api-key: ${SILICONFLOW_API_KEY:your-siliconflow-api-key} + base-url: ${SILICONFLOW_BASE_URL:https://api.siliconflow.cn/v1} + default-model: ${SILICONFLOW_AI_MODEL:Qwen/Qwen3-8B} + timeout: ${SILICONFLOW_TIMEOUT:120} + +# 科大讯飞配置 +xunfei: + # STT配置 + stt: + app-id: ${XUNFEI_STT_APP_ID:your-xunfei-stt-app-id} + api-key: ${XUNFEI_STT_API_KEY:your-xunfei-stt-api-key} + secret-key: ${XUNFEI_STT_SECRET_KEY:your-xunfei-stt-secret-key} + host: ${XUNFEI_STT_HOST:iat-api.xfyun.cn} + path: ${XUNFEI_STT_PATH:/v2/iat} + # TTS配置 + tts: + app-id: ${XUNFEI_TTS_APP_ID:your-xunfei-tts-app-id} + api-key: ${XUNFEI_TTS_API_KEY:your-xunfei-tts-api-key} + secret-key: ${XUNFEI_TTS_SECRET_KEY:your-xunfei-tts-secret-key} + host: ${XUNFEI_TTS_HOST:tts-api.xfyun.cn} + path: ${XUNFEI_TTS_PATH:/v2/tts} + +# 火山引擎TTS配置 +volcan: + tts: + access-token: ${VOLCAN_TTS_ACCESS_TOKEN:your-volcan-access-token} + app-id: ${VOLCAN_TTS_APP_ID:your-volcan-app-id} + host: ${VOLCAN_TTS_HOST:openspeech.bytedance.com} + region: ${VOLCAN_TTS_REGION:ap-beijing-1} + +# 日志配置 +logging: + level: + com.vocata: info + org.springframework.web: info + org.springframework.security: info + com.baomidou.mybatisplus: warn + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" + file: + name: logs/vocata-test.log \ No newline at end of file diff --git a/vocata-server/src/main/resources/application.yml b/vocata-server/src/main/resources/application.yml index d5e89d7..e39247b 100644 --- a/vocata-server/src/main/resources/application.yml +++ b/vocata-server/src/main/resources/application.yml @@ -1,17 +1,31 @@ server: port: 9009 - servlet: - context-path: /api spring: + profiles: + active: local application: name: vocata-server - # 本地开发数据源配置 + # 静态资源配置 + web: + resources: + static-locations: classpath:/static/ + cache: + cachecontrol: + max-age: 3600 + + # Jackson时间格式配置 + jackson: + time-zone: GMT+8 + serialization: + write-dates-as-timestamps: false + + # 数据源配置 datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:vocata_local} - username: ${DB_USERNAME:vocata_local} - password: ${DB_PASSWORD:vocata_local} + url: jdbc:postgresql://${DB_HOST:}:${DB_PORT:}/${DB_NAME:}?stringtype=unspecified + username: ${DB_USERNAME:} + password: ${DB_PASSWORD:} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 10 @@ -26,7 +40,7 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} + database: 0 timeout: 10000ms lettuce: pool: @@ -35,6 +49,31 @@ spring: max-idle: 8 min-idle: 0 + # 定时任务配置 + task: + scheduling: + pool: + size: 10 + + # 邮件配置 + mail: + host: smtp.163.com + port: 465 + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + protocol: smtp + default-encoding: UTF-8 + test-connection: false + properties: + mail: + smtp: + auth: true + ssl: + enable: true + socketFactory: + class: javax.net.ssl.SSLSocketFactory + port: 465 + # MyBatis Plus配置 mybatis-plus: configuration: @@ -73,9 +112,59 @@ sa-token: is-read-cookie: false # 是否从body中读取token is-read-body: false + # token前缀(支持Bearer前缀) + token-prefix: Bearer # 配置Redis作为Sa-Token的持久层 alone-redis: - database: 1 + database: 0 + +# AI服务配置 +ai: + llm: + provider: ${AI_LLM_PROVIDER:qiniu} # 默认使用七牛云AI + stt: + provider: ${AI_STT_PROVIDER:qiniu} + tts: + provider: ${AI_TTS_PROVIDER:xunfei} + +# 七牛云AI配置 +qiniu: + ai: + api-key: ${QINIU_AI_API_KEY:your-qiniu-ai-api-key} + base-url: ${QINIU_AI_BASE_URL:https://openai.qiniu.com/v1} + default-model: ${QINIU_AI_MODEL:x-ai/grok-4-fast} + timeout: ${QINIU_AI_TIMEOUT:60} + # 七牛云存储配置 + access-key: ${QINIU_ACCESS_KEY:your-qiniu-access-key} + secret-key: ${QINIU_SECRET_KEY:your-qiniu-secret-key} + # 七牛云STT配置 + stt: + endpoint: ${QINIU_STT_ENDPOINT:https://openai.qiniu.com/v1} + model: ${QINIU_STT_MODEL:asr} + +# Gemini配置 +gemini: + api: + key: ${GEMINI_API_KEY:your-gemini-api-key} + base-url: ${GEMINI_BASE_URL:https://generativelanguage.googleapis.com} + default-model: ${GEMINI_MODEL:gemini-2.5-flash-lite} + timeout: ${GEMINI_TIMEOUT:60} + +# OpenAI配置 +openai: + api: + key: ${OPENAI_API_KEY:your-openai-api-key} + base-url: ${OPENAI_BASE_URL:https://api.openai.com} + default-model: ${OPENAI_MODEL:gpt-3.5-turbo} + timeout: ${OPENAI_TIMEOUT:60} + +# SiliconFlow配置 +siliconflow: + ai: + api-key: ${SILICONFLOW_API_KEY:your-siliconflow-api-key} + base-url: ${SILICONFLOW_BASE_URL:https://api.siliconflow.cn/v1} + default-model: ${SILICONFLOW_AI_MODEL:Qwen/Qwen3-8B} + timeout: ${SILICONFLOW_TIMEOUT:60} # 本地开发日志配置 logging: diff --git a/vocata-server/src/main/resources/static/real-time-voice-chat.html b/vocata-server/src/main/resources/static/real-time-voice-chat.html new file mode 100644 index 0000000..d25927a --- /dev/null +++ b/vocata-server/src/main/resources/static/real-time-voice-chat.html @@ -0,0 +1,854 @@ + + + + + + VocaTa AI实时语音对话 + + + +
+
+

🤖 VocaTa AI实时语音对话

+

进入页面后自动开始语音监听,说话即可与AI角色进行实时对话

+
+ +
+
+
+ WebSocket未连接 + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+

语音设置

+ +
+
+ +
+ + 0.1 +
+
+ +
+ +
+ + 1500 +
+
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
+
系统就绪,点击"连接"开始实时语音对话
+
+
+
+ + + + \ No newline at end of file diff --git a/vocata-server/src/test/java/com/vocata/ai/test/SiliconFlowAiTest.java b/vocata-server/src/test/java/com/vocata/ai/test/SiliconFlowAiTest.java new file mode 100644 index 0000000..721dbbe --- /dev/null +++ b/vocata-server/src/test/java/com/vocata/ai/test/SiliconFlowAiTest.java @@ -0,0 +1,329 @@ +package com.vocata.ai.test; + +import com.vocata.ai.dto.UnifiedAiRequest; +import com.vocata.ai.dto.UnifiedAiStreamChunk; +import com.vocata.ai.llm.impl.SiliconFlowLlmProvider; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * 硅基流动AI服务测试案例 + * 演示如何使用硅基流动提供商进行AI对话 + */ +@SpringBootTest +@ActiveProfiles("local") +public class SiliconFlowAiTest { + + /** + * 测试案例1:基本的单轮对话 + * 使用 DeepSeek 模型进行简单问答 + */ + @Test + public void testBasicChat() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + // 构建AI请求 + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt("你是一个友善的AI助手,请用简洁的中文回答用户问题。"); + request.setUserMessage("请简单介绍一下什么是人工智能?"); + + // 设置模型配置 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("deepseek-ai/DeepSeek-V2.5"); + modelConfig.setTemperature(0.7); + modelConfig.setMaxTokens(500); + request.setModelConfig(modelConfig); + + // 验证提供商可用性 + if (!provider.isAvailable()) { + System.out.println("⚠️ 硅基流动服务不可用,请检查API密钥配置"); + return; + } + + // 验证模型配置 + if (!provider.validateModelConfig(request.getModelConfig())) { + System.out.println("❌ 模型配置无效"); + return; + } + + System.out.println("🤖 开始基本对话测试..."); + System.out.println("模型: " + modelConfig.getModelName()); + System.out.println("问题: " + request.getUserMessage()); + System.out.println("回答: "); + + // 执行流式调用并收集响应 + try { + StringBuilder fullResponse = new StringBuilder(); + provider.streamChat(request) + .doOnNext(chunk -> { + if (chunk.getContent() != null && !chunk.getContent().isEmpty()) { + System.out.print(chunk.getContent()); + fullResponse.append(chunk.getContent()); + } + }) + .doOnComplete(() -> System.out.println("\n✅ 基本对话测试完成")) + .blockLast(Duration.ofSeconds(30)); + + } catch (Exception e) { + System.out.println("❌ 测试失败: " + e.getMessage()); + } + } + + /** + * 测试案例2:使用 Claude 模型进行创意写作 + */ + @Test + public void testCreativeWritingWithClaude() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt("你是一个创意写作专家,擅长写短篇故事。"); + request.setUserMessage("请写一个关于机器人学会感情的50字小故事。"); + + // 使用 Claude 模型,适合创意任务 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("anthropic/claude-3-5-sonnet-20241022"); + modelConfig.setTemperature(0.9); // 高温度鼓励创意 + modelConfig.setMaxTokens(200); + request.setModelConfig(modelConfig); + + // 收集完整响应 + String fullResponse = provider.streamChat(request) + .map(UnifiedAiStreamChunk::getContent) + .filter(content -> content != null && !content.isEmpty()) + .reduce("", String::concat) + .block(Duration.ofSeconds(30)); + + System.out.println("🎨 Claude创意写作结果:"); + System.out.println(fullResponse); + System.out.println("✅ 创意写作测试通过"); + } + + /** + * 测试案例3:使用 GPT-4 进行代码分析 + */ + @Test + public void testCodeAnalysisWithGPT4() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt("你是一个资深的Java开发专家,请分析代码并给出建议。"); + request.setUserMessage(""" + 请分析以下Java代码,指出潜在问题: + + public class UserService { + private List users = new ArrayList<>(); + + public User getUserById(int id) { + for (User user : users) { + if (user.getId() == id) { + return user; + } + } + return null; + } + } + """); + + // 使用 GPT-4 模型,适合代码分析 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("openai/gpt-4o"); + modelConfig.setTemperature(0.2); // 低温度确保准确性 + modelConfig.setMaxTokens(1000); + request.setModelConfig(modelConfig); + + String analysis = provider.streamChat(request) + .map(UnifiedAiStreamChunk::getContent) + .filter(content -> content != null && !content.isEmpty()) + .reduce("", String::concat) + .block(Duration.ofSeconds(45)); + + System.out.println("💻 GPT-4代码分析结果:"); + System.out.println(analysis); + System.out.println("✅ 代码分析测试通过"); + } + + /** + * 测试案例4:多轮对话测试 + */ + @Test + public void testMultiTurnConversation() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + // 构建多轮对话历史 + List chatHistory = new ArrayList<>(); + chatHistory.add(new UnifiedAiRequest.ChatMessage("user", "我想学习Spring Boot")); + chatHistory.add(new UnifiedAiRequest.ChatMessage("assistant", "Spring Boot是一个优秀的Java框架,它简化了Spring应用的开发。你想从哪个方面开始学习呢?")); + chatHistory.add(new UnifiedAiRequest.ChatMessage("user", "请推荐一个适合初学者的学习路径")); + + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt("你是一个Java技术导师,请给出专业的学习建议。"); + request.setUserMessage("最好能推荐一些实战项目"); + request.setContextMessages(chatHistory); + + // 使用 Qwen 模型 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("Qwen/Qwen2.5-72B-Instruct"); + modelConfig.setTemperature(0.6); + modelConfig.setMaxTokens(800); + request.setModelConfig(modelConfig); + + String response = provider.streamChat(request) + .map(UnifiedAiStreamChunk::getContent) + .filter(content -> content != null && !content.isEmpty()) + .reduce("", String::concat) + .block(Duration.ofSeconds(40)); + + System.out.println("📚 多轮对话结果:"); + System.out.println(response); + System.out.println("✅ 多轮对话测试通过"); + } + + /** + * 测试案例5:模型参数调优测试 + */ + @Test + public void testModelParameterTuning() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + String prompt = "请写一首关于春天的诗"; + + // 测试不同温度参数的效果 + double[] temperatures = {0.2, 0.7, 1.2}; + + for (double temp : temperatures) { + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt("你是一个诗人,请创作优美的诗歌。"); + request.setUserMessage(prompt); + + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("deepseek-ai/DeepSeek-V2.5"); + modelConfig.setTemperature(temp); + modelConfig.setMaxTokens(300); + request.setModelConfig(modelConfig); + + String poem = provider.streamChat(request) + .map(UnifiedAiStreamChunk::getContent) + .filter(content -> content != null && !content.isEmpty()) + .reduce("", String::concat) + .block(Duration.ofSeconds(30)); + + System.out.println(String.format("🌡️ 温度参数 %.1f 的创作结果:", temp)); + System.out.println(poem); + System.out.println("---"); + } + + System.out.println("✅ 参数调优测试通过"); + } + + /** + * 测试案例6:错误处理测试 + */ + @Test + public void testErrorHandling() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + // 测试无效模型名称 + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setUserMessage("测试消息"); + + UnifiedAiRequest.ModelConfig invalidConfig = new UnifiedAiRequest.ModelConfig(); + invalidConfig.setModelName("invalid-model-name"); + invalidConfig.setTemperature(3.0); // 超出范围的温度 + request.setModelConfig(invalidConfig); + + // 验证配置验证 + boolean isValid = provider.validateModelConfig(invalidConfig); + assert !isValid : "应该检测到无效配置"; + + System.out.println("❌ 成功检测到无效配置"); + System.out.println("✅ 错误处理测试通过"); + } + + /** + * 测试案例7:流式响应性能测试 + */ + @Test + public void testStreamingPerformance() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt("请详细回答用户的问题。"); + request.setUserMessage("请详细介绍机器学习的主要算法类型,每种类型给出具体例子和应用场景。"); + + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("Qwen/Qwen2.5-32B-Instruct"); + modelConfig.setTemperature(0.7); + modelConfig.setMaxTokens(2000); + request.setModelConfig(modelConfig); + + long startTime = System.currentTimeMillis(); + + List chunks = new ArrayList<>(); + provider.streamChat(request) + .doOnNext(chunk -> { + if (chunk.getContent() != null && !chunk.getContent().isEmpty()) { + chunks.add(chunk.getContent()); + System.out.print(chunk.getContent()); + } + }) + .blockLast(Duration.ofSeconds(60)); + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + System.out.println("\n⚡ 流式响应性能统计:"); + System.out.println(String.format("总耗时: %d 毫秒", duration)); + System.out.println(String.format("响应块数: %d", chunks.size())); + System.out.println(String.format("平均每块耗时: %.2f 毫秒", (double) duration / chunks.size())); + System.out.println("✅ 性能测试通过"); + } + + /** + * 实际使用示例:模拟真实的AI助手对话 + */ + @Test + public void testRealWorldUsage() { + SiliconFlowLlmProvider provider = new SiliconFlowLlmProvider(); + + // 模拟用户咨询技术问题 + UnifiedAiRequest request = new UnifiedAiRequest(); + request.setSystemPrompt(""" + 你是VocaTa平台的AI技术助手,专门帮助用户解决技术问题。 + 请提供准确、实用的技术建议,并适当推荐相关的学习资源。 + """); + request.setUserMessage(""" + 我在使用Spring Boot开发REST API时遇到了跨域问题, + 前端从localhost:3000访问后端localhost:8080的接口时被浏览器阻止了。 + 请帮我解决这个问题。 + """); + + // 使用适合技术咨询的模型 + UnifiedAiRequest.ModelConfig modelConfig = new UnifiedAiRequest.ModelConfig(); + modelConfig.setModelName("deepseek-ai/deepseek-coder-33b-instruct"); + modelConfig.setTemperature(0.3); // 低温度确保技术回答的准确性 + modelConfig.setMaxTokens(1500); + request.setModelConfig(modelConfig); + + System.out.println("🤖 VocaTa AI助手正在为您解答技术问题...\n"); + + StringBuilder fullResponse = new StringBuilder(); + provider.streamChat(request) + .doOnNext(chunk -> { + if (chunk.getContent() != null && !chunk.getContent().isEmpty()) { + fullResponse.append(chunk.getContent()); + System.out.print(chunk.getContent()); + } + }) + .blockLast(Duration.ofSeconds(45)); + + System.out.println("\n\n✅ 真实场景测试完成"); + System.out.println(String.format("回答长度: %d 字符", fullResponse.length())); + } +} \ No newline at end of file diff --git a/vocata-server/src/test/java/com/vocata/ai/test/SiliconFlowApiUsageExample.java b/vocata-server/src/test/java/com/vocata/ai/test/SiliconFlowApiUsageExample.java new file mode 100644 index 0000000..4a71cb6 --- /dev/null +++ b/vocata-server/src/test/java/com/vocata/ai/test/SiliconFlowApiUsageExample.java @@ -0,0 +1,323 @@ +package com.vocata.ai.test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** + * 硅基流动AI API调用示例 + * 演示如何通过REST接口使用硅基流动的各种模型 + */ +@SpringBootTest +@ActiveProfiles("local") +public class SiliconFlowApiUsageExample { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 示例1:获取所有可用的AI模型 + * GET /api/client/ai/models + */ + @Test + public void exampleGetAvailableModels() { + System.out.println("📋 获取可用模型列表"); + System.out.println("请求: GET /api/client/ai/models"); + System.out.println(); + + // 模拟响应数据 + Map mockResponse = new HashMap<>(); + mockResponse.put("providers", Arrays.asList( + Map.of( + "providerName", "SiliconFlow AI", + "beanName", "siliconFlowLlmProvider", + "isAvailable", true, + "maxContextLength", 128000, + "supportedModels", Arrays.asList( + "anthropic/claude-3-5-sonnet-20241022", + "openai/gpt-4o", + "deepseek-ai/DeepSeek-V2.5", + "Qwen/Qwen2.5-72B-Instruct" + ) + ) + )); + + try { + String jsonResponse = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(mockResponse); + System.out.println("响应示例:"); + System.out.println(jsonResponse); + } catch (Exception e) { + e.printStackTrace(); + } + + System.out.println("\n✅ 模型列表获取示例完成\n"); + } + + /** + * 示例2:使用Claude模型进行创意写作 + * POST /api/client/ai/chat + */ + @Test + public void exampleClaudeCreativeWriting() { + System.out.println("🎨 使用Claude进行创意写作"); + System.out.println("请求: POST /api/client/ai/chat"); + System.out.println(); + + // 构建请求体 + Map requestBody = new HashMap<>(); + requestBody.put("providerName", "siliconFlowLlmProvider"); + requestBody.put("modelName", "anthropic/claude-3-5-sonnet-20241022"); + requestBody.put("systemPrompt", "你是一个富有想象力的作家,擅长创作引人入胜的故事。"); + requestBody.put("userMessage", "请写一个关于时间旅行者的短篇科幻故事,大约200字。"); + requestBody.put("temperature", 0.9); + requestBody.put("maxTokens", 800); + + try { + String jsonRequest = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(requestBody); + System.out.println("请求体:"); + System.out.println(jsonRequest); + } catch (Exception e) { + e.printStackTrace(); + } + + System.out.println("\n响应示例:"); + System.out.println("\"在2045年的实验室里,物理学家林博士激活了时间机器...\""); + System.out.println("\n✅ Claude创意写作示例完成\n"); + } + + /** + * 示例3:使用GPT-4进行代码审查 + * POST /api/client/ai/chat + */ + @Test + public void exampleGPT4CodeReview() { + System.out.println("💻 使用GPT-4进行代码审查"); + System.out.println("请求: POST /api/client/ai/chat"); + System.out.println(); + + Map requestBody = new HashMap<>(); + requestBody.put("providerName", "siliconFlowLlmProvider"); + requestBody.put("modelName", "openai/gpt-4o"); + requestBody.put("systemPrompt", "你是一个资深的软件工程师,请对代码进行专业的review。"); + requestBody.put("userMessage", """ + 请审查以下Spring Boot控制器代码: + + @RestController + @RequestMapping("/api/users") + public class UserController { + @Autowired + private UserService userService; + + @GetMapping("/{id}") + public User getUser(@PathVariable Long id) { + return userService.findById(id); + } + } + + 指出潜在问题并给出改进建议。 + """); + requestBody.put("temperature", 0.3); + requestBody.put("maxTokens", 1200); + + try { + String jsonRequest = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(requestBody); + System.out.println("请求体:"); + System.out.println(jsonRequest); + } catch (Exception e) { + e.printStackTrace(); + } + + System.out.println("\n✅ GPT-4代码审查示例完成\n"); + } + + /** + * 示例4:使用DeepSeek进行技术答疑 + * POST /api/client/ai/chat + */ + @Test + public void exampleDeepSeekTechnicalQA() { + System.out.println("🔍 使用DeepSeek进行技术答疑"); + System.out.println("请求: POST /api/client/ai/chat"); + System.out.println(); + + Map requestBody = new HashMap<>(); + requestBody.put("providerName", "siliconFlowLlmProvider"); + requestBody.put("modelName", "deepseek-ai/DeepSeek-V2.5"); + requestBody.put("systemPrompt", "你是VocaTa平台的技术专家,请用专业且易懂的方式回答技术问题。"); + requestBody.put("userMessage", "什么是微服务架构?它相比单体架构有什么优势和挑战?"); + requestBody.put("temperature", 0.6); + requestBody.put("maxTokens", 1000); + + try { + String jsonRequest = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(requestBody); + System.out.println("请求体:"); + System.out.println(jsonRequest); + } catch (Exception e) { + e.printStackTrace(); + } + + System.out.println("\n✅ DeepSeek技术答疑示例完成\n"); + } + + /** + * 示例5:多轮对话场景 + * POST /api/client/ai/chat + */ + @Test + public void exampleMultiTurnConversation() { + System.out.println("💬 多轮对话示例"); + System.out.println("请求: POST /api/client/ai/chat"); + System.out.println(); + + Map requestBody = new HashMap<>(); + requestBody.put("providerName", "siliconFlowLlmProvider"); + requestBody.put("modelName", "Qwen/Qwen2.5-72B-Instruct"); + requestBody.put("systemPrompt", "你是一个耐心的编程导师,请循序渐进地指导学习者。"); + requestBody.put("userMessage", "请推荐一些具体的练习项目"); + + // 添加对话历史 + requestBody.put("messages", Arrays.asList( + Map.of("role", "user", "content", "我想学习Spring Boot,应该从哪里开始?"), + Map.of("role", "assistant", "content", "建议从Spring Boot基础概念开始,然后学习依赖注入、Web开发、数据访问等核心功能。"), + Map.of("role", "user", "content", "我已经了解了基础概念,想要实践") + )); + + requestBody.put("temperature", 0.7); + requestBody.put("maxTokens", 800); + + try { + String jsonRequest = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(requestBody); + System.out.println("请求体:"); + System.out.println(jsonRequest); + } catch (Exception e) { + e.printStackTrace(); + } + + System.out.println("\n✅ 多轮对话示例完成\n"); + } + + /** + * 示例6:流式调用示例 + * POST /api/client/ai/stream-chat + */ + @Test + public void exampleStreamingChat() { + System.out.println("🌊 流式调用示例"); + System.out.println("请求: POST /api/client/ai/stream-chat"); + System.out.println("Content-Type: application/json"); + System.out.println("Accept: text/event-stream"); + System.out.println(); + + Map requestBody = new HashMap<>(); + requestBody.put("providerName", "siliconFlowLlmProvider"); + requestBody.put("modelName", "anthropic/claude-3-5-haiku-20241022"); + requestBody.put("systemPrompt", "你是一个专业的技术写作助手。"); + requestBody.put("userMessage", "请详细解释什么是RESTful API设计原则,并给出实际例子。"); + requestBody.put("temperature", 0.5); + requestBody.put("maxTokens", 1500); + + try { + String jsonRequest = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(requestBody); + System.out.println("请求体:"); + System.out.println(jsonRequest); + } catch (Exception e) { + e.printStackTrace(); + } + + System.out.println("\n响应流示例:"); + System.out.println("data: RESTful"); + System.out.println("data: API"); + System.out.println("data: 是一种"); + System.out.println("data: 软件架构风格..."); + System.out.println(); + + System.out.println("✅ 流式调用示例完成\n"); + } + + /** + * 示例7:错误处理示例 + */ + @Test + public void exampleErrorHandling() { + System.out.println("❌ 错误处理示例"); + System.out.println(); + + // 无效提供商示例 + System.out.println("1. 无效提供商错误:"); + Map invalidProviderRequest = new HashMap<>(); + invalidProviderRequest.put("providerName", "nonexistent-provider"); + invalidProviderRequest.put("modelName", "some-model"); + invalidProviderRequest.put("userMessage", "测试消息"); + + System.out.println("响应: HTTP 400 Bad Request"); + System.out.println("{\"code\": 400, \"message\": \"未找到指定的AI提供商: nonexistent-provider\"}"); + System.out.println(); + + // 无效模型示例 + System.out.println("2. 无效模型配置错误:"); + Map invalidModelRequest = new HashMap<>(); + invalidModelRequest.put("providerName", "siliconFlowLlmProvider"); + invalidModelRequest.put("modelName", "invalid-model-name"); + invalidModelRequest.put("temperature", 3.0); // 超出范围 + invalidModelRequest.put("userMessage", "测试消息"); + + System.out.println("响应: HTTP 400 Bad Request"); + System.out.println("{\"code\": 400, \"message\": \"模型配置无效,请检查模型名称和参数\"}"); + System.out.println(); + + System.out.println("✅ 错误处理示例完成\n"); + } + + /** + * 完整的使用说明 + */ + @Test + public void printUsageGuide() { + System.out.println("📖 硅基流动AI服务使用指南"); + System.out.println("================================"); + System.out.println(); + + System.out.println("1. 配置API密钥"); + System.out.println(" 在 application-local.yml 中设置:"); + System.out.println(" siliconflow.ai.api-key: your-api-key"); + System.out.println(); + + System.out.println("2. 可用模型列表"); + System.out.println(" • Claude: anthropic/claude-3-5-sonnet-20241022 (创意写作)"); + System.out.println(" • GPT-4: openai/gpt-4o (通用智能)"); + System.out.println(" • DeepSeek: deepseek-ai/DeepSeek-V2.5 (技术问答)"); + System.out.println(" • Qwen: Qwen/Qwen2.5-72B-Instruct (中文对话)"); + System.out.println(" • Llama: meta-llama/Meta-Llama-3.1-70B-Instruct (开源)"); + System.out.println(); + + System.out.println("3. 参数调优建议"); + System.out.println(" • 创意任务: temperature=0.8-1.2"); + System.out.println(" • 技术问答: temperature=0.2-0.5"); + System.out.println(" • 日常对话: temperature=0.6-0.8"); + System.out.println(" • 代码生成: temperature=0.1-0.3"); + System.out.println(); + + System.out.println("4. API端点"); + System.out.println(" • GET /api/client/ai/models - 获取模型列表"); + System.out.println(" • POST /api/client/ai/chat - 同步对话"); + System.out.println(" • POST /api/client/ai/stream-chat - 流式对话"); + System.out.println(); + + System.out.println("5. 认证要求"); + System.out.println(" 需要在请求头中包含:"); + System.out.println(" Authorization: Bearer "); + System.out.println(); + + System.out.println("✅ 使用指南完成"); + } +} \ No newline at end of file diff --git a/vocata-web/.editorconfig b/vocata-web/.editorconfig new file mode 100644 index 0000000..3b510aa --- /dev/null +++ b/vocata-web/.editorconfig @@ -0,0 +1,8 @@ +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 100 diff --git a/vocata-web/.env.development b/vocata-web/.env.development new file mode 100644 index 0000000..e2b885d --- /dev/null +++ b/vocata-web/.env.development @@ -0,0 +1,4 @@ +# 测试环境配置 +VITE_APP_URL=http://127.0.0.1:9009 +VUE_APP_TITLE=VocaTa - 测试环境 +VITE_APP_ENV=test \ No newline at end of file diff --git a/vocata-web/.env.production b/vocata-web/.env.production new file mode 100644 index 0000000..f64a14f --- /dev/null +++ b/vocata-web/.env.production @@ -0,0 +1,5 @@ +# 生产环境配置 +# 注意:VITE_APP_URL 将在CI/CD构建时动态替换 +VITE_APP_URL=http://{{PRODUCTION_HOST}}:9009 +VUE_APP_TITLE=VocaTa +VITE_APP_ENV=production \ No newline at end of file diff --git a/vocata-web/.env.test b/vocata-web/.env.test new file mode 100644 index 0000000..ce93b83 --- /dev/null +++ b/vocata-web/.env.test @@ -0,0 +1,5 @@ +# 测试环境配置 +# 注意:VITE_APP_URL 将在CI/CD构建时动态替换 +VITE_APP_URL=http://{{STAGING_HOST}}:9009 +VUE_APP_TITLE=VocaTa - 测试环境 +VITE_APP_ENV=test \ No newline at end of file diff --git a/vocata-web/.gitattributes b/vocata-web/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/vocata-web/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/vocata-web/.gitignore b/vocata-web/.gitignore new file mode 100644 index 0000000..8ee54e8 --- /dev/null +++ b/vocata-web/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/vocata-web/.prettierrc.json b/vocata-web/.prettierrc.json new file mode 100644 index 0000000..29a2402 --- /dev/null +++ b/vocata-web/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/vocata-web/Dockerfile b/vocata-web/Dockerfile new file mode 100644 index 0000000..a99b11d --- /dev/null +++ b/vocata-web/Dockerfile @@ -0,0 +1,166 @@ +# VocaTa前端客户端 - 多阶段构建Dockerfile +# 基于Node.js官方镜像 + +# 构建阶段 +FROM node:20-alpine AS build + +# 安装必要的构建工具 +RUN apk add --no-cache python3 make g++ git + +# 设置工作目录 +WORKDIR /app + +# 复制package文件 +COPY package*.json ./ + +# 安装依赖 +RUN npm ci --only=production && npm cache clean --force + +# 复制源代码 +COPY . . + +# 构建应用 +ARG BUILD_MODE=production +RUN npm run build:${BUILD_MODE} + +# 生产阶段 - 使用Nginx托管静态文件 +FROM nginx:1.25-alpine + +# 安装必要工具 +RUN apk add --no-cache \ + curl \ + tzdata \ + dumb-init + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 创建nginx用户和目录 +RUN addgroup -g 1001 -S vocata && \ + adduser -u 1001 -S vocata -G vocata && \ + mkdir -p /var/cache/nginx /var/log/nginx /var/lib/nginx && \ + chown -R vocata:vocata /var/cache/nginx /var/log/nginx /var/lib/nginx /etc/nginx + +# 复制构建产物 +COPY --from=build --chown=vocata:vocata /app/dist /usr/share/nginx/html + +# 创建Nginx配置文件 +RUN cat > /etc/nginx/nginx.conf << 'EOF' +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 性能优化 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/js + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # 服务器配置 + server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html index.htm; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + + # SPA路由支持 + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API代理(如果需要) + location /api { + proxy_pass http://vocata-server:9009; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 健康检查端点 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} +EOF + +# 设置权限 +RUN chown -R vocata:vocata /usr/share/nginx/html /etc/nginx + +# 切换到非root用户 +USER vocata + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# 构建参数和标签 +ARG BUILD_DATE +ARG VERSION="1.0.0" +LABEL maintainer="VocaTa Team " \ + version="${VERSION}" \ + build-date="${BUILD_DATE}" \ + description="VocaTa AI角色扮演平台前端客户端" \ + org.opencontainers.image.title="vocata-web" \ + org.opencontainers.image.description="VocaTa AI Role Playing Platform Frontend Client" \ + org.opencontainers.image.url="https://github.com/leivik/vocata" \ + org.opencontainers.image.vendor="VocaTa Team" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" + +# 启动Nginx +ENTRYPOINT ["dumb-init", "--"] +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/vocata-web/README.md b/vocata-web/README.md new file mode 100644 index 0000000..c7ca5a3 --- /dev/null +++ b/vocata-web/README.md @@ -0,0 +1,39 @@ +# VocaTa-front + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Type Support for `.vue` Imports in TS + +TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Type-Check, Compile and Minify for Production + +```sh +npm run build +``` + +### Lint with [ESLint](https://eslint.org/) + +```sh +npm run lint +``` diff --git a/vocata-web/env.d.ts b/vocata-web/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/vocata-web/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vocata-web/eslint.config.ts b/vocata-web/eslint.config.ts new file mode 100644 index 0000000..20475f8 --- /dev/null +++ b/vocata-web/eslint.config.ts @@ -0,0 +1,22 @@ +import { globalIgnores } from 'eslint/config' +import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' +import pluginVue from 'eslint-plugin-vue' +import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' + +// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: +// import { configureVueProject } from '@vue/eslint-config-typescript' +// configureVueProject({ scriptLangs: ['ts', 'tsx'] }) +// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup + +export default defineConfigWithVueTs( + { + name: 'app/files-to-lint', + files: ['**/*.{ts,mts,tsx,vue}'], + }, + + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), + + pluginVue.configs['flat/essential'], + vueTsConfigs.recommended, + skipFormatting, +) diff --git a/vocata-web/index.html b/vocata-web/index.html new file mode 100644 index 0000000..fcd9708 --- /dev/null +++ b/vocata-web/index.html @@ -0,0 +1,16 @@ + + + + + + + + 语Ta + + + +
+ + + + \ No newline at end of file diff --git a/vocata-web/package-lock.json b/vocata-web/package-lock.json new file mode 100644 index 0000000..ec841c0 --- /dev/null +++ b/vocata-web/package-lock.json @@ -0,0 +1,6192 @@ +{ + "name": "vocata-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vocata-web", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@types/js-cookie": "^3.0.6", + "axios": "^1.12.2", + "element-plus": "^2.11.3", + "js-cookie": "^3.0.5", + "pinia": "^3.0.3", + "vue": "^3.5.18", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/node": "^22.16.5", + "@types/postcss-pxtorem": "^6.1.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.7.0", + "eslint": "^9.31.0", + "eslint-plugin-vue": "~10.3.0", + "jiti": "^2.4.2", + "npm-run-all2": "^8.0.4", + "postcss-pxtorem": "^6.1.0", + "prettier": "3.6.2", + "sass": "^1.93.0", + "typescript": "~5.8.0", + "vite": "^7.0.6", + "vite-plugin-vue-devtools": "^8.0.0", + "vue-tsc": "^3.0.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", + "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz", + "integrity": "sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-decorators": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.27.1.tgz", + "integrity": "sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz", + "integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", + "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.0.tgz", + "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.0.tgz", + "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.0.tgz", + "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.0.tgz", + "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.0.tgz", + "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.0.tgz", + "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.0.tgz", + "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.0.tgz", + "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.0.tgz", + "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.0.tgz", + "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.0.tgz", + "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.0.tgz", + "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.0.tgz", + "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.0.tgz", + "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.0.tgz", + "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.0.tgz", + "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.0.tgz", + "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.0.tgz", + "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.0.tgz", + "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.0.tgz", + "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.0.tgz", + "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.0.tgz", + "integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tsconfig/node22": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz", + "integrity": "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/postcss-pxtorem": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/postcss-pxtorem/-/postcss-pxtorem-6.1.0.tgz", + "integrity": "sha512-kHsYTjQgllOfhi3J+xunjMKUZ3APARV/JYeOOcIVLhvPVS162S8Ir8LsZwioFFyYCSnQp+aisupiSaRWVwKyDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss": "^8.2.6" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", + "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/type-utils": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.0.tgz", + "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.0.tgz", + "integrity": "sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.44.0", + "@typescript-eslint/types": "^8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.0.tgz", + "integrity": "sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.0.tgz", + "integrity": "sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.0.tgz", + "integrity": "sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.0.tgz", + "integrity": "sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.0.tgz", + "integrity": "sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.44.0", + "@typescript-eslint/tsconfig-utils": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.0.tgz", + "integrity": "sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.0.tgz", + "integrity": "sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", + "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz", + "integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.23" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz", + "integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.23", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz", + "integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", + "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz", + "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@vue/babel-helper-vue-transform-on": "1.5.0", + "@vue/babel-plugin-resolve-type": "1.5.0", + "@vue/shared": "^3.5.18" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz", + "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.0", + "@vue/compiler-sfc": "^3.5.18" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.18", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, + "node_modules/@vue/devtools-core": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-8.0.2.tgz", + "integrity": "sha512-V7eKTTHoS6KfK8PSGMLZMhGv/9yNDrmv6Qc3r71QILulnzPnqK2frsTyx3e2MrhdUZnENPEm6hcb4z0GZOqNhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.2", + "@vue/devtools-shared": "^8.0.2", + "mitt": "^3.0.1", + "nanoid": "^5.1.5", + "pathe": "^2.0.3", + "vite-hot-client": "^2.1.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/@vue/devtools-core/node_modules/@vue/devtools-kit": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.2.tgz", + "integrity": "sha512-yjZKdEmhJzQqbOh4KFBfTOQjDPMrjjBNCnHBvnTGJX+YLAqoUtY2J+cg7BE+EA8KUv8LprECq04ts75wCoIGWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.2", + "birpc": "^2.5.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-core/node_modules/@vue/devtools-shared": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.2.tgz", + "integrity": "sha512-mLU0QVdy5Lp40PMGSixDw/Kbd6v5dkQXltd2r+mdVQV7iUog2NlZuLxFZApFZ/mObUBDhoCpf0T3zF2FWWdeHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/devtools-core/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@vue/devtools-core/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", + "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.7", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", + "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/eslint-config-typescript": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.6.0.tgz", + "integrity": "sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.35.1", + "fast-glob": "^3.3.3", + "typescript-eslint": "^8.35.1", + "vue-eslint-parser": "^10.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0", + "eslint-plugin-vue": "^9.28.0 || ^10.0.0", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.0.7.tgz", + "integrity": "sha512-0sqqyqJ0Gn33JH3TdIsZLCZZ8Gr4kwlg8iYOnOrDDkJKSjFurlQY/bEFQx5zs7SX2C/bjMkmPYq/NiyY1fTOkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^2.0.5", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", + "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", + "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", + "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/runtime-core": "3.5.21", + "@vue/shared": "3.5.21", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", + "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "vue": "3.5.21" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.7.tgz", + "integrity": "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", + "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/birpc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz", + "integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true, + "license": "ISC" + }, + "node_modules/element-plus": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.3.tgz", + "integrity": "sha512-769xsjLR4B9Vf9cl5PDXnwTEdmFJvMgAkYtthdJKPhjVjU3hdAwTJ+gXKiO+PUyo2KWFwOYKZd4Ywh6PHfkbJg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.3.0.tgz", + "integrity": "sha512-A0u9snqjCfYaPnqqOaH6MBLVWDUIN4trXn8J3x67uDcXvR7X6Ut8p16N+nYhMCQ9Y7edg2BIRGzfyZsY0IdqoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "vue-eslint-parser": "^10.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/npm-run-all2/-/npm-run-all2-8.0.4.tgz", + "integrity": "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "picomatch": "^4.0.2", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^20.5.0 || >=22.0.0", + "npm": ">= 10" + } + }, + "node_modules/npm-run-all2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm-run-all2/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm-run-all2/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm-run-all2/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pinia": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz", + "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-pxtorem": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-pxtorem/-/postcss-pxtorem-6.1.0.tgz", + "integrity": "sha512-ROODSNci9ADal3zUcPHOF/K83TiCgNSPXQFSbwyPHNV8ioHIE4SaC+FPOufd8jsr5jV2uIz29v1Uqy1c4ov42g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz", + "integrity": "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.0.tgz", + "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.0", + "@rollup/rollup-android-arm64": "4.52.0", + "@rollup/rollup-darwin-arm64": "4.52.0", + "@rollup/rollup-darwin-x64": "4.52.0", + "@rollup/rollup-freebsd-arm64": "4.52.0", + "@rollup/rollup-freebsd-x64": "4.52.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", + "@rollup/rollup-linux-arm-musleabihf": "4.52.0", + "@rollup/rollup-linux-arm64-gnu": "4.52.0", + "@rollup/rollup-linux-arm64-musl": "4.52.0", + "@rollup/rollup-linux-loong64-gnu": "4.52.0", + "@rollup/rollup-linux-ppc64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-musl": "4.52.0", + "@rollup/rollup-linux-s390x-gnu": "4.52.0", + "@rollup/rollup-linux-x64-gnu": "4.52.0", + "@rollup/rollup-linux-x64-musl": "4.52.0", + "@rollup/rollup-openharmony-arm64": "4.52.0", + "@rollup/rollup-win32-arm64-msvc": "4.52.0", + "@rollup/rollup-win32-ia32-msvc": "4.52.0", + "@rollup/rollup-win32-x64-gnu": "4.52.0", + "@rollup/rollup-win32-x64-msvc": "4.52.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sass": { + "version": "1.93.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.0.tgz", + "integrity": "sha512-CQi5/AzCwiubU3dSqRDJ93RfOfg/hhpW1l6wCIvolmehfwgCI35R/0QDs1+R+Ygrl8jFawwwIojE2w47/mf94A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.0.tgz", + "integrity": "sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.44.0", + "@typescript-eslint/parser": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.0.tgz", + "integrity": "sha512-JLoggz+PvLVMJo+jZt97hdIIIZ2yTzGgft9e9q8iMrC4ewufl62ekeW7mixBghonn2gVb/ICjyvlmOCUBnJLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-utils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-dev-rpc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-dev-rpc/-/vite-dev-rpc-1.1.0.tgz", + "integrity": "sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==", + "dev": true, + "license": "MIT", + "dependencies": { + "birpc": "^2.4.0", + "vite-hot-client": "^2.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1 || ^7.0.0-0" + } + }, + "node_modules/vite-hot-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-2.1.0.tgz", + "integrity": "sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" + } + }, + "node_modules/vite-plugin-inspect": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-11.3.3.tgz", + "integrity": "sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.1.0", + "debug": "^4.4.1", + "error-stack-parser-es": "^1.0.5", + "ohash": "^2.0.11", + "open": "^10.2.0", + "perfect-debounce": "^2.0.0", + "sirv": "^3.0.1", + "unplugin-utils": "^0.3.0", + "vite-dev-rpc": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite-plugin-inspect/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-plugin-vue-devtools": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.2.tgz", + "integrity": "sha512-1069qvMBcyAu3yXQlvYrkwoyLOk0lSSR/gTKy/vy+Det7TXnouGei6ZcKwr5TIe938v/14oLlp0ow6FSJkkORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-core": "^8.0.2", + "@vue/devtools-kit": "^8.0.2", + "@vue/devtools-shared": "^8.0.2", + "execa": "^9.6.0", + "sirv": "^3.0.2", + "vite-plugin-inspect": "^11.3.3", + "vite-plugin-vue-inspector": "^5.3.2" + }, + "engines": { + "node": ">=v14.21.3" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0-0" + } + }, + "node_modules/vite-plugin-vue-devtools/node_modules/@vue/devtools-kit": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.2.tgz", + "integrity": "sha512-yjZKdEmhJzQqbOh4KFBfTOQjDPMrjjBNCnHBvnTGJX+YLAqoUtY2J+cg7BE+EA8KUv8LprECq04ts75wCoIGWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.2", + "birpc": "^2.5.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/vite-plugin-vue-devtools/node_modules/@vue/devtools-shared": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.2.tgz", + "integrity": "sha512-mLU0QVdy5Lp40PMGSixDw/Kbd6v5dkQXltd2r+mdVQV7iUog2NlZuLxFZApFZ/mObUBDhoCpf0T3zF2FWWdeHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/vite-plugin-vue-devtools/node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-plugin-vue-inspector": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.2.tgz", + "integrity": "sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/plugin-proposal-decorators": "^7.23.0", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.22.15", + "@vue/babel-plugin-jsx": "^1.1.5", + "@vue/compiler-dom": "^3.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.4" + }, + "peerDependencies": { + "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", + "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-sfc": "3.5.21", + "@vue/runtime-dom": "3.5.21", + "@vue/server-renderer": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", + "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-router": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.0.7.tgz", + "integrity": "sha512-BSMmW8GGEgHykrv7mRk6zfTdK+tw4MBZY/x6fFa7IkdXK3s/8hQRacPjG9/8YKFDIWGhBocwi6PlkQQ/93OgIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.23", + "@vue/language-core": "3.0.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/vocata-web/package.json b/vocata-web/package.json new file mode 100644 index 0000000..74b6734 --- /dev/null +++ b/vocata-web/package.json @@ -0,0 +1,54 @@ +{ + "name": "vocata-web", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite --mode development", + "dev:local": "vite", + "dev:test": "vite --mode test", + "build": "vite build --mode production", + "build:local": "vite build --mode local", + "build:test": "vite build --mode test", + "build:prod": "vite build --mode production", + "build:production": "vite build --mode production", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build", + "lint": "eslint . --fix", + "format": "prettier --write src/" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@types/js-cookie": "^3.0.6", + "axios": "^1.12.2", + "element-plus": "^2.11.3", + "js-cookie": "^3.0.5", + "pinia": "^3.0.3", + "vue": "^3.5.18", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/node": "^22.16.5", + "@types/postcss-pxtorem": "^6.1.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.7.0", + "eslint": "^9.31.0", + "eslint-plugin-vue": "~10.3.0", + "jiti": "^2.4.2", + "npm-run-all2": "^8.0.4", + "postcss-pxtorem": "^6.1.0", + "prettier": "3.6.2", + "sass": "^1.93.0", + "typescript": "~5.8.0", + "vite": "^7.0.6", + "vite-plugin-vue-devtools": "^8.0.0", + "vue-tsc": "^3.0.4" + } +} diff --git a/vocata-web/public/favicon.ico b/vocata-web/public/favicon.ico new file mode 100644 index 0000000..b9a89b0 Binary files /dev/null and b/vocata-web/public/favicon.ico differ diff --git a/vocata-web/src/App.vue b/vocata-web/src/App.vue new file mode 100644 index 0000000..de57a2e --- /dev/null +++ b/vocata-web/src/App.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/vocata-web/src/ExampleCom.vue b/vocata-web/src/ExampleCom.vue new file mode 100644 index 0000000..194cb15 --- /dev/null +++ b/vocata-web/src/ExampleCom.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/vocata-web/src/api/modules/conversation.ts b/vocata-web/src/api/modules/conversation.ts new file mode 100644 index 0000000..3541557 --- /dev/null +++ b/vocata-web/src/api/modules/conversation.ts @@ -0,0 +1,57 @@ +import type { + CreateConversationRequest, + ConversationResponse, + UpdateConversationTitleRequest, + MessageResponse, + Response +} from '@/types/api' +import request from '../request' + +export const conversationApi = { + // 创建新的对话会话 + createConversation(params: CreateConversationRequest): Promise> { + return request.post('/api/client/conversations', params) + }, + + // 获取当前用户的历史对话列表 + getConversationList(): Promise> { + return request.get('/api/client/conversations') + }, + + // 删除对话 + deleteConversation(conversationUuid: string): Promise> { + return request.delete(`/api/client/conversations/${conversationUuid}`) + }, + + // 更新对话标题 + updateConversationTitle( + conversationUuid: string, + params: UpdateConversationTitleRequest + ): Promise> { + return request.put(`/api/client/conversations/${conversationUuid}/title`, params) + }, + + // 获取最新消息(推荐)- 对话界面初始加载 + getRecentMessages(conversationUuid: string, limit?: number): Promise> { + const params = limit ? `?limit=${limit}` : '' + return request.get(`/api/client/conversations/${conversationUuid}/messages/recent${params}`) + }, + + // 分页获取历史消息 - 向前翻页查看更多历史消息 + getHistoryMessages( + conversationUuid: string, + offset?: number, + limit?: number + ): Promise> { + const params = new URLSearchParams() + if (offset !== undefined) params.append('offset', offset.toString()) + if (limit !== undefined) params.append('limit', limit.toString()) + const queryString = params.toString() ? `?${params.toString()}` : '' + return request.get(`/api/client/conversations/${conversationUuid}/messages/history${queryString}`) + }, + + // 获取所有消息(已废弃,不建议使用) + getAllMessages(conversationUuid: string): Promise> { + return request.get(`/api/client/conversations/${conversationUuid}/messages`) + } +} \ No newline at end of file diff --git a/vocata-web/src/api/modules/role.ts b/vocata-web/src/api/modules/role.ts new file mode 100644 index 0000000..558c6da --- /dev/null +++ b/vocata-web/src/api/modules/role.ts @@ -0,0 +1,67 @@ +import request from '../request' +import type { PublicRoleQuery } from '@/types/api' + +export const roleApi = { + // 获取公开角色列表 + getPublicRoleList(params: PublicRoleQuery) { + return request({ + url: '/api/open/character/list', + method: 'get', + params + }) + }, + // 获取精选角色列表 + getChoiceRoleList(params: { limit: number }) { + return request({ + url: '/api/open/character/featured', + method: 'get', + params + }) + }, + // 搜索角色 + searchRole(params: { keyword: string }) { + return request({ + url: '/api/open/character/search', + method: 'get', + params + }) + }, + // 获取我的角色列表 + getMyRoleList(params?: any) { + return request({ + url: '/api/client/character/my', + method: 'get', + params + }) + }, + // 创建角色 + createRole(data: any) { + return request({ + url: '/api/client/character', + method: 'post', + data + }) + }, + // 获取音色列表 + getSoundList() { + return request({ + url: '/api/client/tts-voice/list', + method: 'get' + }) + }, + // 获取角色详情 + getCharacterDetail(id: string | number) { + return request({ + url: `/api/open/character/${id}`, + method: 'get' + }) + }, + // AI生成角色提示词 + aiGenerate(data: { name: string; description: string; greeting: string }) { + return request({ + url: '/api/client/character/ai-generate', + method: 'post', + data + }) + } +} diff --git a/vocata-web/src/api/modules/user.ts b/vocata-web/src/api/modules/user.ts new file mode 100644 index 0000000..51059ee --- /dev/null +++ b/vocata-web/src/api/modules/user.ts @@ -0,0 +1,31 @@ +import type { LoginParams, RegisterParams, Response, LoginResponse, UserInfo, UpdateUserInfoParams } from '@/types/api' +import request from '../request' + +export const userApi = { + // 登录 + login(params: LoginParams): Promise> { + return request.post('/api/client/auth/login', params) + }, + // 注册 + register(params: RegisterParams): Promise> { + return request.post('/api/client/auth/register', params) + }, + // 发送验证码 + sendCode(email: string): Promise> { + return request.post('/api/client/auth/sendCode', { email }) + }, + + // 退出登录 + logout(): Promise> { + return request.post('/api/client/auth/logout') + }, + // 获取用户信息 + getUserInfo(): Promise> { + return request.get('/api/client/user/profile') + }, + + // 更新用户信息 + updateUserInfo(params: UpdateUserInfoParams): Promise> { + return request.put('/api/client/user/profile', params) + } +} \ No newline at end of file diff --git a/vocata-web/src/api/request.ts b/vocata-web/src/api/request.ts new file mode 100644 index 0000000..d8a7eb2 --- /dev/null +++ b/vocata-web/src/api/request.ts @@ -0,0 +1,73 @@ +// src/utils/request.js +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { getToken, removeToken } from '@/utils/token' +import router from '@/router' + +// 创建axios实例 +const request = axios.create({ + baseURL: import.meta.env.VITE_APP_URL, // 从环境变量读取 + // baseURL: 'http://127.0.0.1:4523/m1/7166225-6890394-default/', // 从环境变量读取 + timeout: 10000 // 请求超时时间 +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + if (getToken()) { + config.headers['Authorization'] = 'Bearer ' + getToken() + } + return config + }, + (error) => { + console.error('API请求错误:', error) + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + const res = response.data + + // // 根据你的后端接口约定修改判断逻辑 + // if (res.code === 200) { + return res + // } else { + // ElMessage.error(res.message || '请求失败') + // return Promise.reject(new Error(res.message || 'Error')) + // } + }, + (error) => { + // 处理HTTP错误状态码 + let message = '' + if (error.response) { + switch (error.response.status) { + case 401: + message = '未授权,请重新登录' + removeToken() + // 跳转到登录页 + router.push('/login') + break + case 403: + message = '拒绝访问' + break + case 404: + message = '请求地址错误' + break + case 500: + message = '服务器内部错误' + break + default: + message = '网络错误' + } + } else { + message = '未知错误' + } + + ElMessage.error(message) + return Promise.reject(error) + } +) + +export default request \ No newline at end of file diff --git a/vocata-web/src/assets/images/loginPic.png b/vocata-web/src/assets/images/loginPic.png new file mode 100644 index 0000000..f6eabe2 Binary files /dev/null and b/vocata-web/src/assets/images/loginPic.png differ diff --git a/vocata-web/src/assets/images/logo-text.png b/vocata-web/src/assets/images/logo-text.png new file mode 100644 index 0000000..847cd4c Binary files /dev/null and b/vocata-web/src/assets/images/logo-text.png differ diff --git a/vocata-web/src/assets/images/logo.png b/vocata-web/src/assets/images/logo.png new file mode 100644 index 0000000..b9a89b0 Binary files /dev/null and b/vocata-web/src/assets/images/logo.png differ diff --git a/vocata-web/src/assets/styles/SweiB2SansCJKsc-Bold.woff b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Bold.woff new file mode 100644 index 0000000..98a673a Binary files /dev/null and b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Bold.woff differ diff --git a/vocata-web/src/assets/styles/SweiB2SansCJKsc-Bold.woff2 b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Bold.woff2 new file mode 100644 index 0000000..4f87f38 Binary files /dev/null and b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Bold.woff2 differ diff --git a/vocata-web/src/assets/styles/SweiB2SansCJKsc-Regular.woff b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Regular.woff new file mode 100644 index 0000000..8fe55bc Binary files /dev/null and b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Regular.woff differ diff --git a/vocata-web/src/assets/styles/SweiB2SansCJKsc-Regular.woff2 b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Regular.woff2 new file mode 100644 index 0000000..add9d78 Binary files /dev/null and b/vocata-web/src/assets/styles/SweiB2SansCJKsc-Regular.woff2 differ diff --git a/vocata-web/src/assets/styles/fonts.css b/vocata-web/src/assets/styles/fonts.css new file mode 100644 index 0000000..4fa75d2 --- /dev/null +++ b/vocata-web/src/assets/styles/fonts.css @@ -0,0 +1,15 @@ +@font-face { + font-family: 'CustomFont'; + src: url('./SweiB2SansCJKsc-Bold.woff') format('woff'); + src: url('./SweiB2SansCJKsc-Bold.woff2') format('woff2'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'CustomFont'; + src: url('./SweiB2SansCJKsc-Regular.woff') format('woff'); + src: url('./SweiB2SansCJKsc-Regular.woff2') format('woff2'); + font-weight: normal; + font-style: normal; +} \ No newline at end of file diff --git a/vocata-web/src/assets/styles/pagination-theme.css b/vocata-web/src/assets/styles/pagination-theme.css new file mode 100644 index 0000000..8ff5717 --- /dev/null +++ b/vocata-web/src/assets/styles/pagination-theme.css @@ -0,0 +1,57 @@ +/* 分页组件黑白灰主题覆盖样式 */ +:root { + --el-color-primary: #333333; + --el-color-primary-light-3: #f8f8f8; + --el-color-primary-light-5: #cccccc; + --el-color-primary-light-7: #e5e5e5; + --el-color-primary-light-8: #f0f0f0; + --el-color-primary-light-9: #f8f8f8; +} + +/* 分页组件专用样式 */ +.pagination-container .el-pagination { + --el-color-primary: #333333; +} + +.pagination-container .el-pagination .el-pager li { + color: #666666 !important; + background-color: #ffffff !important; + border: 1px solid #e5e5e5 !important; +} + +.pagination-container .el-pagination .el-pager li:hover { + color: #333333 !important; + background-color: #f8f8f8 !important; + border-color: #cccccc !important; +} + +.pagination-container .el-pagination .el-pager li.is-active { + color: #ffffff !important; + background-color: #333333 !important; + border-color: #333333 !important; +} + +.pagination-container .el-pagination .btn-prev, +.pagination-container .el-pagination .btn-next { + color: #666666 !important; + background-color: #ffffff !important; + border: 1px solid #e5e5e5 !important; +} + +.pagination-container .el-pagination .btn-prev:hover:not(:disabled), +.pagination-container .el-pagination .btn-next:hover:not(:disabled) { + color: #333333 !important; + background-color: #f8f8f8 !important; + border-color: #cccccc !important; +} + +.pagination-container .el-pagination .btn-prev:disabled, +.pagination-container .el-pagination .btn-next:disabled { + color: #cccccc !important; + background-color: #ffffff !important; + border-color: #f0f0f0 !important; +} + +.pagination-container .el-pagination .el-pagination__total { + color: #666666 !important; +} \ No newline at end of file diff --git a/vocata-web/src/layouts/BasicLayout.vue b/vocata-web/src/layouts/BasicLayout.vue new file mode 100644 index 0000000..12f366a --- /dev/null +++ b/vocata-web/src/layouts/BasicLayout.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/vocata-web/src/layouts/SliderBar.vue b/vocata-web/src/layouts/SliderBar.vue new file mode 100644 index 0000000..17f3a8e --- /dev/null +++ b/vocata-web/src/layouts/SliderBar.vue @@ -0,0 +1,907 @@ + + + + + diff --git a/vocata-web/src/layouts/UserInfo.vue b/vocata-web/src/layouts/UserInfo.vue new file mode 100644 index 0000000..f010c0f --- /dev/null +++ b/vocata-web/src/layouts/UserInfo.vue @@ -0,0 +1,521 @@ + + + + + \ No newline at end of file diff --git a/vocata-web/src/main.ts b/vocata-web/src/main.ts new file mode 100644 index 0000000..c0ab4dd --- /dev/null +++ b/vocata-web/src/main.ts @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import '@/utils/rem.ts' +import '@/assets/styles/fonts.css' +import '@/assets/styles/pagination-theme.css' +import { zhCn } from 'element-plus/es/locales.mjs' +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { + locale: zhCn, +}) +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} +app.mount('#app') diff --git a/vocata-web/src/router/guards.ts b/vocata-web/src/router/guards.ts new file mode 100644 index 0000000..fb1a4ce --- /dev/null +++ b/vocata-web/src/router/guards.ts @@ -0,0 +1,29 @@ +import { getToken } from '@/utils/token' +import { ElMessage } from 'element-plus' +import type { Router } from 'vue-router' + +const whiteList = ['/login', '/404', '/searchRole', '/'] +export default function setupRouterGuard(router: Router) { + router.beforeEach(async (to, from, next) => { + const token = getToken() + + // 白名单检查 + if (whiteList.includes(to.path)) { + // 如果已经登录,访问登录页时重定向到首页 + if (token && to.path === '/login') { + next('/') + return + } + next() + return + } + + // 非白名单路径检查token + if (!token) { + ElMessage.warning('登录过期,请重新登录') + next(`/login?redirect=${encodeURIComponent(to.fullPath)}`) + return + } + next() + }) +} \ No newline at end of file diff --git a/vocata-web/src/router/index.ts b/vocata-web/src/router/index.ts new file mode 100644 index 0000000..e632cc0 --- /dev/null +++ b/vocata-web/src/router/index.ts @@ -0,0 +1,10 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import routes from './routes.ts' +import guard from './guards' + +const router = createRouter({ + history: createWebHashHistory(import.meta.env.BASE_URL), + routes +}) +guard(router) +export default router diff --git a/vocata-web/src/router/routes.ts b/vocata-web/src/router/routes.ts new file mode 100644 index 0000000..d2405a4 --- /dev/null +++ b/vocata-web/src/router/routes.ts @@ -0,0 +1,40 @@ +import type { RouteRecordRaw } from "vue-router" +import BasicLayout from '@/layouts/BasicLayout.vue' +const routes: RouteRecordRaw[] = [ + { + path: '/', + component: BasicLayout, + redirect: '/searchRole', + children: [ + { + path: '/searchRole', + component: () => import('@/views/SearchRole.vue'), + meta: { + title: '探索' + } + }, + { + path: '/newRole', + component: () => import('@/views/NewRole.vue'), + meta: { + title: '新建角色' + } + }, { + path: '/chat/:conversationUuid', + component: () => import('@/views/ChatPage.vue'), + meta: { + title: '对话' + } + } + ] + }, + { + path: '/login', + component: () => import('@/views/LoginPage.vue'), + meta: { + title: '登录' + } + } +] + +export default routes \ No newline at end of file diff --git a/vocata-web/src/store/index.ts b/vocata-web/src/store/index.ts new file mode 100644 index 0000000..dc4453f --- /dev/null +++ b/vocata-web/src/store/index.ts @@ -0,0 +1,12 @@ +// stores/index.ts +import { createPinia } from 'pinia' + +// 创建 pinia 实例 +const pinia = createPinia() + + +// 导出 store +export * from './modules/chatHistory' + +// 默认导出 pinia 实例 +export default pinia \ No newline at end of file diff --git a/vocata-web/src/store/modules/chatHistory.ts b/vocata-web/src/store/modules/chatHistory.ts new file mode 100644 index 0000000..246ca99 --- /dev/null +++ b/vocata-web/src/store/modules/chatHistory.ts @@ -0,0 +1,49 @@ +// stores/user.ts +import { conversationApi } from '@/api/modules/conversation' +import type { ConversationResponse } from '@/types/api' +import { ElMessage } from 'element-plus' +import { defineStore } from 'pinia' + +export const chatHistoryStore = defineStore('chatHistory', { + state: () => ({ + chatHistory: [] as ConversationResponse[] + }), + + getters: { + }, + + actions: { + // 获取历史对话记录 + async getChatHistory() { + const res = await conversationApi.getConversationList() + if (res.code == 200) { + this.chatHistory = res.data + } else { + ElMessage.error(res.message) + } + }, + + // 添加历史对话记录 + async addChatHistory(characterId: number | string) { + const res = await conversationApi.createConversation({ characterId }) + if (res.code == 200) { + this.chatHistory.unshift(res.data) + return res.data.conversationUuid + } else { + ElMessage.error(res.message) + throw Error(res.message) + } + }, + // 删除对话 + async deleteChatHistory(conversationUuid: string) { + const res = await conversationApi.deleteConversation(conversationUuid) + if (res.code == 200) { + ElMessage.success('对话已删除') + // 从列表中移除已删除的对话 + this.chatHistory = this.chatHistory.filter( + (chat) => chat.conversationUuid !== conversationUuid, + ) + } + } + }, +}) \ No newline at end of file diff --git a/vocata-web/src/types/api.ts b/vocata-web/src/types/api.ts new file mode 100644 index 0000000..f044e0e --- /dev/null +++ b/vocata-web/src/types/api.ts @@ -0,0 +1,113 @@ +// 公开角色查询参数 +export interface PublicRoleQuery { + keywords?: string, + status?: number, + isFeatured?: number, + isTrending?: number, + tags?: string[], + language?: string, + creatorId?: number, + pageNum: number, + pageSize: number, + orderBy?: string, + orderDirection?: string +} + +// 登录参数 +export interface LoginParams { + loginName: string, + password: string, + rememberMe: boolean +} + +// 注册参数 +export interface RegisterParams { + nickname: string, + password: string, + email: string, + confirmPassword: string, + verificationCode: string, + gender: number, + hasRead?: boolean +} + +// 登录响应数据 +export interface LoginResponse { + token: string, + expiresIn: number +} + +// 用户信息响应数据 +export interface UserInfo { + id: string, + nickname: string, + email: string, + avatar: string, + gender: number, + phone?: string, + birthday?: string, + createDate: string +} + +// 更新用户信息参数 +export interface UpdateUserInfoParams { + nickname?: string, + gender?: number, + phone?: string, + birthday?: string, + avatar?: string +} + +// 修改密码参数 +export interface ChangePasswordParams { + oldPassword: string, + newPassword: string +} + +// 返回参数 +export interface Response { + code: number, + message: string, + data: T, + timestamp?: number +} + +// 对话模块相关类型定义 + +// 创建对话请求参数 +export interface CreateConversationRequest { + characterId: string | number, + title?: string +} + +// 对话响应数据 +export interface ConversationResponse { + conversationUuid: string, + characterId: string, + characterName: string, + characterAvatarUrl: string, + greeting?: string, + title: string | null, + lastMessageSummary: string | null, + status: number, + createDate: string, + updateDate: string +} + +// 更新对话标题请求参数 +export interface UpdateConversationTitleRequest { + title: string +} + +// 消息响应数据 +export interface MessageResponse { + messageUuid: string, + senderType: number, // 1=用户, 2=AI角色 + contentType: number, // 1=文本, 2=图片, 3=音频 + textContent: string, + audioUrl: string | null, + llmModelId: string, + ttsVoiceId: string | null, + metadata: Record, + createDate: string +} diff --git a/vocata-web/src/types/common.ts b/vocata-web/src/types/common.ts new file mode 100644 index 0000000..0ef20ed --- /dev/null +++ b/vocata-web/src/types/common.ts @@ -0,0 +1,51 @@ +export interface roleInfo { + "id"?: number, + "characterCode"?: string, + "name"?: string, + "description"?: string, + "greeting"?: string, + "avatarUrl"?: string, + "tags"?: string, + "language"?: string, + "status"?: number, + "statusName"?: string, + "isOfficial"?: number, + "isFeatured"?: number, + "isTrending"?: number, + "trendingScore"?: number, + "chatCount"?: number, + "userCount"?: number, + "isPrivate"?: boolean, + "creatorId"?: number, + "createdAt"?: string, + "updatedAt"?: string +} + +// 聊天历史项接口 +export interface ChatHistoryItem { + id: string, + conversationUuid?: string, + title?: string, + lastTime?: Date | string, + characterName?: string, + characterAvatarUrl?: string, + lastMessageSummary?: string | null, + status?: number +} + +// 聊天消息接口 +export interface ChatMessage { + messageUuid?: string, + type: 'send' | 'receive', + content: string, + senderType?: number, // 1=用户, 2=AI角色 + contentType?: number, // 1=文本, 2=语音, 3=图片, 4=音频 + audioUrl?: string | null, + createDate?: string, + metadata?: Record, + // AI对话系统新增字段 + isStreaming?: boolean, // 是否为流式显示中的消息 + isRecognizing?: boolean, // 是否为语音识别中的消息 + characterName?: string, // AI角色名称 + confidence?: number // 语音识别置信度 +} \ No newline at end of file diff --git a/vocata-web/src/types/debounce.ts b/vocata-web/src/types/debounce.ts new file mode 100644 index 0000000..cd8c5bb --- /dev/null +++ b/vocata-web/src/types/debounce.ts @@ -0,0 +1,32 @@ +// 防抖函数 +function debounce void>( + func: T, + wait: number, + immediate: boolean = false +): (...args: Parameters) => void { + + let timeout: ReturnType | null = null; + + return function (this: unknown, ...args: Parameters): void { + const later = (): void => { + timeout = null; + if (!immediate) { + func.apply(this, args); + } + }; + + const shouldCallNow = immediate && timeout === null; + + if (timeout !== null) { + clearTimeout(timeout); + } + + timeout = setTimeout(later, wait); + + if (shouldCallNow) { + func.apply(this, args); + } + }; + +} +export default debounce; \ No newline at end of file diff --git a/vocata-web/src/utils/aiChat.ts b/vocata-web/src/utils/aiChat.ts new file mode 100644 index 0000000..e92e069 --- /dev/null +++ b/vocata-web/src/utils/aiChat.ts @@ -0,0 +1,983 @@ +/** + * VocaTa AI对话系统 - WebSocket客户端和音频管理器 + * 基于文档 VocaTa-AI对话完整对接文档.md 实现 + */ + +import { getToken } from './token' + +// WebSocket消息类型定义 +interface WebSocketMessage { + type: string + [key: string]: any +} + +interface STTResultMessage extends WebSocketMessage { + type: 'stt_result' + text: string + isFinal: boolean + confidence: number + timestamp: number +} + +interface LLMTextStreamMessage extends WebSocketMessage { + type: 'llm_text_stream' + text: string + characterName: string + isComplete: boolean + timestamp: number +} + +interface TTSAudioMetaMessage extends WebSocketMessage { + type: 'tts_audio_meta' + audioSize: number + format: string + sampleRate: number + channels: number + bitDepth: number + timestamp: number +} + +interface TTSResultMessage extends WebSocketMessage { + type: 'tts_result' + text: string + format: string + sampleRate: number + voiceId?: string + timestamp: number +} + +interface CompleteMessage extends WebSocketMessage { + type: 'complete' + message: string + timestamp: number +} + +interface ErrorMessage extends WebSocketMessage { + type: 'error' + error: string + timestamp: number +} + +// WebSocket客户端类 +export class VocaTaWebSocketClient { + private ws: WebSocket | null = null + private conversationUuid: string + private reconnectAttempts = 0 + private readonly maxReconnectAttempts = 5 + private callbacks: Map = new Map() + private manualClose = false + + constructor(conversationUuid: string) { + this.conversationUuid = conversationUuid + } + + connect(): void { + console.log('🔄 开始建立WebSocket连接,conversationUuid:', this.conversationUuid) + + const token = getToken() + if (!token) { + console.error('❌ 未找到认证令牌,无法建立WebSocket连接') + this.emit('error', new Error('认证令牌未找到')) + return + } + + const wsUrl = `ws://${import.meta.env.VITE_APP_URL.replace('http://', '')}/ws/chat/${this.conversationUuid}?token=${encodeURIComponent(token)}` + console.log('🔌 尝试连接WebSocket:', wsUrl) + console.log('🔐 使用Token:', token.substring(0, 20) + '...') + + try { + this.manualClose = false + this.ws = new WebSocket(wsUrl) + this.ws.binaryType = 'arraybuffer' + this.setupEventHandlers() + } catch (error) { + console.error('❌ WebSocket连接创建失败:', error) + this.emit('error', error) + } + } + + private setupEventHandlers(): void { + if (!this.ws) return + + this.ws.onopen = (event) => { + console.log('✅ WebSocket连接已建立') + console.log('🔍 WebSocket状态检查:', { + readyState: this.ws?.readyState, + isOpen: this.ws?.readyState === WebSocket.OPEN, + WebSocketOPEN: WebSocket.OPEN + }) + this.reconnectAttempts = 0 + this.emit('connected', event) + } + + this.ws.onmessage = (event) => { + // 检查是否为二进制音频数据 + if (event.data instanceof ArrayBuffer) { + console.log(`📦 收到音频数据(ArrayBuffer): ${event.data.byteLength} bytes`) + this.emit('audioData', event.data) + return + } + + // 检查是否为Blob音频数据 + if (event.data instanceof Blob) { + console.log(`📦 收到音频数据(Blob): ${event.data.size} bytes`) + // 将Blob转换为ArrayBuffer + event.data.arrayBuffer().then(arrayBuffer => { + this.emit('audioData', arrayBuffer) + }).catch(error => { + console.error('❌ Blob转ArrayBuffer失败:', error) + }) + return + } + + // 否则按JSON消息处理 + try { + const message: WebSocketMessage = JSON.parse(event.data) + console.log(`📨 收到消息:`, message) + this.emit('message', message) + } catch (e) { + console.error('❌ 解析消息失败:', event.data) + } + } + + this.ws.onclose = (event) => { + console.log(`🔌 WebSocket连接关闭: code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}`) + this.emit('disconnected', { event, manual: this.manualClose }) + const shouldReconnect = !this.manualClose + this.ws = null + if (shouldReconnect) { + this.attemptReconnect() + } else { + this.reconnectAttempts = 0 + } + } + + this.ws.onerror = (error) => { + console.error('❌ WebSocket错误:', error) + console.error('WebSocket readyState:', this.ws?.readyState) + this.emit('error', error) + } + } + + // 发送文字消息 + sendTextMessage(text: string): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.error('❌ WebSocket未连接') + return + } + + const message = { + type: 'text_message', + data: { message: text } + } + + console.log('📤 发送文字消息:', text) + this.ws.send(JSON.stringify(message)) + } + + // 发送音频数据 + sendAudioData(audioBuffer: ArrayBuffer): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return + } + this.ws.send(audioBuffer) + } + + // 音频录制控制 + startAudioRecording(): void { + this.sendControlMessage('audio_start') + } + + stopAudioRecording(): void { + this.sendControlMessage('audio_end') + } + + sendControlMessage(type: string, payload: Record = {}): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return + } + const message = { type, ...payload } + console.log(`📡 发送控制指令:`, message) + this.ws.send(JSON.stringify(message)) + } + + // 发送心跳 + sendPing(): void { + this.sendControlMessage('ping') + } + + // 事件监听器 + on(event: string, callback: Function): void { + if (!this.callbacks.has(event)) { + this.callbacks.set(event, []) + } + this.callbacks.get(event)?.push(callback) + } + + private emit(event: string, data?: any): void { + const callbacks = this.callbacks.get(event) + if (callbacks) { + callbacks.forEach(callback => callback(data)) + } + } + + // 自动重连 + private attemptReconnect(): void { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++ + const delay = Math.pow(2, this.reconnectAttempts) * 1000 + console.log(`🔄 尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts}) - ${delay}ms后`) + + setTimeout(() => { + this.connect() + }, delay) + } else { + console.error('❌ 重连次数已达上限') + this.emit('reconnectFailed') + } + } + + disconnect(): void { + if (this.ws) { + this.manualClose = true + try { + this.ws.close(1000, 'client_closed') + } finally { + this.ws = null + } + } + } + + // 获取连接状态 + get readyState(): number { + return this.ws?.readyState || WebSocket.CLOSED + } + + get isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN + } +} + +// 音频管理器类 - 批量录音模式 +export class AudioManager { + private audioContext: AudioContext | null = null + private mediaRecorder: MediaRecorder | null = null + private audioQueue: ArrayBuffer[] = [] + private isPlaying = false + private isRecording = false + private audioStream: MediaStream | null = null + + // 批量录音模式 - 收集完整音频段 + private recordedChunks: Blob[] = [] + private currentWsClient: VocaTaWebSocketClient | null = null + private stopRecordingPromise: Promise | null = null + private stopRecordingResolve?: () => void + private stopRecordingReject?: (reason?: any) => void + private playbackStateListener?: (isPlaying: boolean) => void + + async initialize(): Promise { + try { + console.log('🎵 音频管理器初始化完成(延迟初始化AudioContext)') + // 不再在初始化时立即创建AudioContext,而是在需要时才创建 + // 这样避免了浏览器的安全策略限制 + } catch (error) { + console.error('❌ 音频管理器初始化失败:', error) + throw error + } + } + + async preparePlayback(): Promise { + try { + await this.ensureAudioContext() + } catch (error) { + console.warn('⚠️ 准备音频播放失败:', error) + } + } + + // 延迟初始化AudioContext,在用户交互后调用 + private async ensureAudioContext(): Promise { + if (!this.audioContext) { + console.log('🎵 延迟初始化音频上下文...') + this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() + + // 检查音频上下文状态 + if (this.audioContext.state === 'suspended') { + await this.audioContext.resume() + } + + console.log('✅ 音频上下文初始化成功') + } else if (this.audioContext.state === 'suspended') { + console.log('🔄 音频上下文处于挂起状态,尝试恢复...') + await this.audioContext.resume() + } + } + + async startRecording(wsClient: VocaTaWebSocketClient): Promise { + try { + console.log('🎤 开始批量录音模式...') + this.currentWsClient = wsClient + this.recordedChunks = [] // 重置录音数据 + this.stopRecordingPromise = null + this.stopRecordingResolve = undefined + this.stopRecordingReject = undefined + + // 确保AudioContext已初始化 + await this.ensureAudioContext() + + console.log('🎤 请求麦克风权限...') + + // 直接获取麦克风权限 + this.audioStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: 16000, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }) + + // 验证音频流 + const tracks = this.audioStream.getTracks() + const audioTracks = tracks.filter(track => track.kind === 'audio') + + console.log('🔍 音频流详细信息:', { + tracks: this.audioStream.getTracks().length, + audioTracks: audioTracks.length, + active: this.audioStream.active + }) + + if (audioTracks.length === 0 || !this.audioStream.active) { + throw new Error('未能获取有效的音频轨道') + } + + // 选择最佳音频格式 + let mimeType = 'audio/webm;codecs=opus' + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'audio/webm' + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'audio/wav' + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = 'audio/mpeg' + if (!MediaRecorder.isTypeSupported(mimeType)) { + mimeType = '' // 使用浏览器默认格式 + } + } + } + } + + console.log('🎵 使用音频格式:', mimeType || '默认格式') + + // 创建MediaRecorder - 批量模式,不设置timeslice + const mediaRecorderOptions: MediaRecorderOptions = {} + if (mimeType) { + mediaRecorderOptions.mimeType = mimeType + } + + this.mediaRecorder = new MediaRecorder(this.audioStream, mediaRecorderOptions) + + // 批量录音 - 收集所有数据到chunks数组 + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + console.log(`🎤 收集音频块: ${event.data.size} bytes`) + this.recordedChunks.push(event.data) + } + } + + // 录音结束时发送完整音频 + this.mediaRecorder.onstop = () => { + this.handleMediaRecorderStop() + } + + // 开始录音(不设置timeslice,收集完整音频) + this.mediaRecorder.start() + this.isRecording = true + console.log('✅ 开始批量录音 (手动控制模式)') + + } catch (error) { + console.error('❌ 录音启动失败:', error) + throw error + } + } + + async stopRecording(): Promise { + if (!this.mediaRecorder || !this.isRecording) { + return + } + + if (!this.stopRecordingPromise) { + this.stopRecordingPromise = new Promise((resolve, reject) => { + this.stopRecordingResolve = resolve + this.stopRecordingReject = reject + + try { + this.mediaRecorder!.stop() + if (this.audioStream) { + this.audioStream.getTracks().forEach(track => track.stop()) + } + this.isRecording = false + + console.log('⏹️ 停止批量录音') + // 注意:不要在这里清理currentWsClient,因为processBatchAudio还需要使用它 + } catch (error) { + console.error('❌ 停止录音失败:', error) + this.stopRecordingResolve = undefined + this.stopRecordingReject = undefined + this.stopRecordingPromise = null + reject(error) + } + }) + } + + try { + await this.stopRecordingPromise + } finally { + this.stopRecordingPromise = null + this.stopRecordingResolve = undefined + this.stopRecordingReject = undefined + } + } + + // 处理批量录音音频数据 + private async processBatchAudio(): Promise { + try { + if (this.recordedChunks.length === 0) { + console.warn('⚠️ 没有录音数据') + return + } + + console.log(`🎤 处理批量音频: ${this.recordedChunks.length} 个音频块`) + + // 合并所有音频块 + const audioBlob = new Blob(this.recordedChunks, { type: this.recordedChunks[0].type }) + const audioBuffer = await audioBlob.arrayBuffer() + + console.log(`📦 批量音频数据: ${audioBuffer.byteLength} bytes, 格式: ${audioBlob.type}`) + + // 发送完整音频到WebSocket + if (this.currentWsClient?.isConnected) { + this.currentWsClient.sendAudioData(audioBuffer) + console.log(`📤 已发送批量音频到服务器: ${audioBuffer.byteLength} bytes`) + } else { + console.error('❌ WebSocket未连接,无法发送音频数据') + } + + // 清理录音数据 + this.recordedChunks = [] + + // 完成后清理WebSocket客户端引用 + this.currentWsClient = null + + } catch (error) { + console.error('❌ 处理批量音频失败:', error) + } + } + + private async handleMediaRecorderStop(): Promise { + try { + await this.processBatchAudio() + this.stopRecordingResolve?.() + } catch (error) { + console.error('❌ 处理录音停止事件失败:', error) + this.stopRecordingReject?.(error) + } finally { + this.stopRecordingResolve = undefined + this.stopRecordingReject = undefined + this.stopRecordingPromise = null + this.mediaRecorder = null + this.audioStream = null + } + } + + async playAudio(audioBuffer: ArrayBuffer): Promise { + try { + if (!this.audioContext) { + await this.initialize() + } + + const audioData = await this.audioContext!.decodeAudioData(audioBuffer.slice()) + const source = this.audioContext!.createBufferSource() + source.buffer = audioData + + // 添加音量控制 + const gainNode = this.audioContext!.createGain() + source.connect(gainNode) + gainNode.connect(this.audioContext!.destination) + + source.start() + console.log(`🔊 播放音频: 时长${audioData.duration.toFixed(2)}秒`) + + return new Promise((resolve) => { + source.onended = () => resolve() + }) + } catch (error) { + console.error('❌ 音频播放失败:', error) + } + } + + // 音频队列管理 + addToQueue(audioBuffer: ArrayBuffer): void { + this.audioQueue.push(audioBuffer) + if (!this.isPlaying) { + this.playQueue() + } + } + + private async playQueue(): Promise { + if (this.audioQueue.length === 0) { + this.isPlaying = false + this.notifyPlaybackState(false) + return + } + + this.isPlaying = true + this.notifyPlaybackState(true) + + try { + // 确保AudioContext已初始化 + await this.ensureAudioContext() + } catch (error) { + console.warn('⚠️ AudioContext初始化失败,跳过音频播放:', error) + this.isPlaying = false + return + } + + const audioBuffer = this.audioQueue.shift()! + + try { + await this.playAudio(audioBuffer) + } catch (error) { + console.error('❌ 队列音频播放失败:', error) + } + + // 播放下一个 + this.playQueue() + } + + clearQueue(): void { + this.audioQueue = [] + this.isPlaying = false + this.notifyPlaybackState(false) + console.log('🗑️ 清除音频队列') + } + + // 获取音量级别(用于可视化) + getVolumeAnalyzer(): (() => number) | null { + if (!this.audioStream || !this.audioContext) { + return null + } + + const analyser = this.audioContext.createAnalyser() + const microphone = this.audioContext.createMediaStreamSource(this.audioStream) + const dataArray = new Uint8Array(analyser.frequencyBinCount) + + microphone.connect(analyser) + analyser.fftSize = 256 + + return () => { + analyser.getByteFrequencyData(dataArray) + const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length + return average / 255 // 标准化到0-1 + } + } + + // 检查麦克风权限 + async checkMicrophonePermission(): Promise { + try { + const result = await navigator.permissions.query({ name: 'microphone' as PermissionName }) + return result.state + } catch { + return 'prompt' + } + } + + get recording(): boolean { + return this.isRecording + } + + get playing(): boolean { + return this.isPlaying + } + + setPlaybackStateListener(listener: (isPlaying: boolean) => void): void { + this.playbackStateListener = listener + } + + private notifyPlaybackState(isPlaying: boolean): void { + this.playbackStateListener?.(isPlaying) + } +} + +// 实时AI对话管理器 +export class VocaTaAIChat { + private wsClient: VocaTaWebSocketClient | null = null + private audioManager: AudioManager + private isAudioCallActive = false + private currentConversation: any = null + private currentCharacter: any = null + private conversationUuid: string | null = null + private connectingPromise: Promise | null = null + + // 临时消息存储,用于流式显示 + private currentLLMResponse = '' + private currentSTTText = '' + + // 回调函数 + private onMessageCallback?: (message: any) => void + private onSTTResultCallback?: (text: string, isFinal: boolean) => void + private onLLMStreamCallback?: (text: string, isComplete: boolean, characterName?: string) => void + private onAudioPlayCallback?: (isPlaying: boolean) => void + private onConnectionStatusCallback?: (status: 'connected' | 'disconnected' | 'error', message?: string) => void + + constructor() { + this.audioManager = new AudioManager() + this.audioManager.setPlaybackStateListener(isPlaying => { + this.onAudioPlayCallback?.(isPlaying) + }) + } + + async initialize(conversationUuid: string): Promise { + try { + console.log('🚀 初始化AI对话系统...') + + // 初始化音频管理器 + await this.audioManager.initialize() + + // 建立WebSocket连接并等待连接成功 + await this.connectWebSocket(conversationUuid) + this.conversationUuid = conversationUuid + + console.log('✅ AI对话系统初始化完成') + } catch (error) { + console.error('❌ AI对话系统初始化失败:', error) + throw error + } + } + + private connectWebSocket(conversationUuid: string): Promise { + if (this.connectingPromise) { + return this.connectingPromise + } + + this.connectingPromise = new Promise((resolve, reject) => { + let connectionResolved = false // 防止重复resolve + + const finalize = () => { + this.connectingPromise = null + } + + try { + this.wsClient = new VocaTaWebSocketClient(conversationUuid) + } catch (creationError) { + finalize() + reject(creationError) + return + } + + // 设置事件监听器 + this.wsClient.on('connected', () => { + console.log('🎉 WebSocket连接成功,等待服务器确认...') + // 不在这里resolve,等待服务器状态消息 + }) + + this.wsClient.on('message', (message: WebSocketMessage) => { + this.handleWebSocketMessage(message) + + // 如果收到状态消息表示连接已建立,则resolve + if (!connectionResolved && message.type === 'status' && + (message.message?.includes('连接已建立') || message.message?.includes('WebSocket连接已建立'))) { + console.log('🎉 收到服务器连接确认,连接完全建立') + connectionResolved = true + this.onConnectionStatusCallback?.('connected', 'WebSocket连接已建立') + resolve() + finalize() + } + + // 如果还没有连接确认,但收到了任何其他消息(AI回复等),也认为连接成功 + if (!connectionResolved && (message.type === 'llm_text_stream' || message.type === 'text_message')) { + console.log('🎯 收到AI消息,连接确认成功') + connectionResolved = true + this.onConnectionStatusCallback?.('connected', 'AI系统连接成功') + resolve() + finalize() + } + }) + + this.wsClient.on('audioData', (audioBuffer: ArrayBuffer) => { + this.handleAudioData(audioBuffer) + }) + + this.wsClient.on('error', (error: any) => { + console.error('❌ WebSocket错误:', error) + this.onConnectionStatusCallback?.('error', 'WebSocket连接错误') + if (!connectionResolved) { + connectionResolved = true + reject(error) + finalize() + } + }) + + this.wsClient.on('disconnected', (payload: { event: CloseEvent, manual: boolean }) => { + if (!payload?.manual) { + console.log('📡 WebSocket连接断开,正在重连...') + this.onConnectionStatusCallback?.('disconnected', '连接已断开,正在重连...') + } else { + console.log('📡 WebSocket连接已手动关闭') + } + }) + + this.wsClient.on('reconnectFailed', () => { + console.error('❌ WebSocket重连失败') + this.onConnectionStatusCallback?.('error', '连接失败,请刷新页面重试') + }) + + // 启动连接 + this.wsClient.connect() + + // 设置超时,如果10秒内没有连接成功,则reject + setTimeout(() => { + if (!connectionResolved) { + console.error('❌ WebSocket连接超时') + connectionResolved = true + reject(new Error('WebSocket连接超时')) + finalize() + } + }, 10000) + }) + + return this.connectingPromise + } + + private handleWebSocketMessage(message: WebSocketMessage): void { + switch (message.type) { + case 'stt_result': + this.handleSTTResult(message as STTResultMessage) + break + + case 'llm_text_stream': + this.handleLLMTextStream(message as LLMTextStreamMessage) + break + + case 'tts_result': + this.handleTTSResult(message as TTSResultMessage) + break + + case 'tts_audio_meta': + this.handleTTSAudioMeta(message as TTSAudioMetaMessage) + break + + case 'complete': + this.handleProcessComplete(message as CompleteMessage) + break + + case 'error': + this.handleError(message as ErrorMessage) + break + + default: + console.log('🔄 收到其他类型消息:', message) + } + + // 触发通用消息回调 + this.onMessageCallback?.(message) + } + + private handleSTTResult(message: STTResultMessage): void { + console.log(`🎤 STT识别: ${message.text} (${message.isFinal ? '最终' : '临时'})`) + + this.currentSTTText = message.text + this.onSTTResultCallback?.(message.text, message.isFinal) + } + + private handleLLMTextStream(message: LLMTextStreamMessage): void { + console.log(`🤖 LLM响应: ${message.text} (${message.isComplete ? '完成' : '流式'})`) + + // 修复:始终累积文本,无论是否完成 + // 流式渲染应该累积所有收到的文本片段 + this.currentLLMResponse += message.text + + console.log(`🔍 当前累积文本长度: ${this.currentLLMResponse.length}`) + + this.onLLMStreamCallback?.(this.currentLLMResponse, message.isComplete, message.characterName) + + if (message.isComplete) { + this.currentLLMResponse = '' // 重置 + } + } + + private handleTTSResult(message: TTSResultMessage): void { + console.log(`🗣️ TTS最终文字: ${message.text} (格式: ${message.format}, 采样率: ${message.sampleRate})`) + + if (message.text) { + this.onLLMStreamCallback?.(message.text, true, message.voiceId) + } + } + + private handleTTSAudioMeta(message: TTSAudioMetaMessage): void { + console.log(`🔊 TTS音频元数据: ${message.audioSize} bytes, ${message.format}`) + } + + private handleAudioData(audioBuffer: ArrayBuffer): void { + console.log(`🔊 播放音频数据: ${audioBuffer.byteLength} bytes`) + this.audioManager.addToQueue(audioBuffer) + } + + private handleProcessComplete(message: CompleteMessage): void { + console.log('✅ 处理完成:', message.message) + } + + private handleError(message: ErrorMessage): void { + console.error('❌ 服务器错误:', message.error) + } + + // 公开方法 + sendTextMessage(text: string): void { + if (!this.wsClient) { + console.error('❌ WebSocket客户端未初始化') + return + } + + this.wsClient.sendTextMessage(text) + } + + // 开始录音 + async startRecording(): Promise { + try { + console.log('📞 开始批量录音') + + await this.ensureWebSocketConnection() + await this.audioManager.startRecording(this.wsClient!) + this.wsClient?.startAudioRecording() + + } catch (error) { + console.error('❌ 无法启动录音:', error) + throw error + } + } + + async prepareAudioPlayback(): Promise { + try { + await this.audioManager.preparePlayback() + } catch (error) { + console.warn('⚠️ 准备音频上下文失败:', error) + } + } + + // 停止录音 + async stopRecording(): Promise { + console.log('📞 停止录音并发送批量音频') + + await this.audioManager.stopRecording() + this.wsClient?.stopAudioRecording() + } + + // 兼容旧的音频通话方法 + async startAudioCall(): Promise { + await this.ensureWebSocketConnection() + if (!this.wsClient || !this.wsClient.isConnected) { + throw new Error('WebSocket未连接,无法启动音频通话') + } + + console.log('📞 音频通话已激活,等待用户点击开始说话') + this.isAudioCallActive = true + + // 清空残留的播放队列,确保新的通话段落从空状态开始 + this.audioManager.clearQueue() + this.onAudioPlayCallback?.(false) + } + + async stopAudioCall(): Promise { + if (this.recording) { + await this.stopRecording() + } + this.isAudioCallActive = false + this.audioManager.clearQueue() + this.onAudioPlayCallback?.(false) + + if (this.wsClient) { + const client = this.wsClient + if (client.isConnected) { + client.sendControlMessage('audio_cancel') + setTimeout(() => { + client.disconnect() + }, 100) + } else { + client.disconnect() + } + this.wsClient = null + } + } + + // 设置回调函数 + onMessage(callback: (message: any) => void): void { + this.onMessageCallback = callback + } + + onSTTResult(callback: (text: string, isFinal: boolean) => void): void { + this.onSTTResultCallback = callback + } + + onLLMStream(callback: (text: string, isComplete: boolean, characterName?: string) => void): void { + this.onLLMStreamCallback = callback + } + + onAudioPlay(callback: (isPlaying: boolean) => void): void { + this.onAudioPlayCallback = callback + } + + onConnectionStatus(callback: (status: 'connected' | 'disconnected' | 'error', message?: string) => void): void { + this.onConnectionStatusCallback = callback + } + + // 获取状态 + get connected(): boolean { + const isConnected = this.wsClient?.isConnected || false + console.log('🔍 检查连接状态:', { + wsClient: !!this.wsClient, + readyState: this.wsClient?.readyState, + isConnected: isConnected, + expectedReadyState: WebSocket.OPEN + }) + return isConnected + } + + get audioCallActive(): boolean { + return this.isAudioCallActive + } + + get recording(): boolean { + return this.audioManager.recording + } + + get playing(): boolean { + return this.audioManager.playing + } + + // 清理资源 + destroy(): void { + console.log('🧹 清理AI对话系统资源') + + void this.stopAudioCall() + this.wsClient?.disconnect() + this.audioManager.clearQueue() + + this.wsClient = null + this.onMessageCallback = undefined + this.onSTTResultCallback = undefined + this.onLLMStreamCallback = undefined + this.onAudioPlayCallback = undefined + this.onConnectionStatusCallback = undefined + } + + private async ensureWebSocketConnection(): Promise { + if (this.wsClient && this.wsClient.isConnected) { + return + } + + if (!this.conversationUuid) { + throw new Error('缺少会话标识,无法建立语音连接') + } + + await this.connectWebSocket(this.conversationUuid) + } +} diff --git a/vocata-web/src/utils/isMobile.ts b/vocata-web/src/utils/isMobile.ts new file mode 100644 index 0000000..f1918b2 --- /dev/null +++ b/vocata-web/src/utils/isMobile.ts @@ -0,0 +1,10 @@ +// src/utils/isMobile.js - 基于userAgent的简版 + +// 判断是否为移动设备 +export const isMobile = () => { + const userAgent = navigator.userAgent.toLowerCase() + return /iphone|ipod|android.*mobile|windows.*phone|blackberry.*mobile/i.test(userAgent) +} + +// 直接导出一个布尔值(当前状态) +export const isMobileNow = isMobile() \ No newline at end of file diff --git a/vocata-web/src/utils/rem.ts b/vocata-web/src/utils/rem.ts new file mode 100644 index 0000000..10fa4b9 --- /dev/null +++ b/vocata-web/src/utils/rem.ts @@ -0,0 +1,18 @@ +(function () { + function setRem() { + const width = document.documentElement.clientWidth + + if (width <= 768) { + // 移动端:基于375px设计稿,1rem = 100px + document.documentElement.style.fontSize = (width / 375 * 100) + 'px' + } else { + // PC端:基于1920px设计稿,1rem = 100px + const fontSize = Math.min(width / 1920 * 100, 100) // 限制最大字体大小 + document.documentElement.style.fontSize = fontSize + 'px' + } + } + + setRem() + window.addEventListener('resize', setRem) + window.addEventListener('pageshow', (e) => e.persisted && setRem()) +})() \ No newline at end of file diff --git a/vocata-web/src/utils/token.ts b/vocata-web/src/utils/token.ts new file mode 100644 index 0000000..bd68fb9 --- /dev/null +++ b/vocata-web/src/utils/token.ts @@ -0,0 +1,24 @@ +const TOKEN_KEY = 'vocata_token' + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string, time?: number) { + localStorage.setItem(TOKEN_KEY, token) + if (time) { + const expireTime = Date.now() + time * 1000 + localStorage.setItem(`${TOKEN_KEY}_expire`, String(expireTime)) + } +} + +export function removeToken() { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(`${TOKEN_KEY}_expire`) +} + +export function isTokenExpired(): boolean { + const expireTime = localStorage.getItem(`${TOKEN_KEY}_expire`) + if (!expireTime) return false + return Date.now() > Number(expireTime) +} \ No newline at end of file diff --git a/vocata-web/src/views/ChatPage.vue b/vocata-web/src/views/ChatPage.vue new file mode 100644 index 0000000..d9921ab --- /dev/null +++ b/vocata-web/src/views/ChatPage.vue @@ -0,0 +1,2118 @@ + + + + + diff --git a/vocata-web/src/views/LoginPage.vue b/vocata-web/src/views/LoginPage.vue new file mode 100644 index 0000000..570484a --- /dev/null +++ b/vocata-web/src/views/LoginPage.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/vocata-web/src/views/NewRole.vue b/vocata-web/src/views/NewRole.vue new file mode 100644 index 0000000..562022d --- /dev/null +++ b/vocata-web/src/views/NewRole.vue @@ -0,0 +1,367 @@ + + + + + \ No newline at end of file diff --git a/vocata-web/src/views/SearchRole.vue b/vocata-web/src/views/SearchRole.vue new file mode 100644 index 0000000..0354c28 --- /dev/null +++ b/vocata-web/src/views/SearchRole.vue @@ -0,0 +1,1067 @@ + + + + + diff --git a/vocata-web/src/views/components/RoleDialog.vue b/vocata-web/src/views/components/RoleDialog.vue new file mode 100644 index 0000000..a7055df --- /dev/null +++ b/vocata-web/src/views/components/RoleDialog.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/vocata-web/tsconfig.app.json b/vocata-web/tsconfig.app.json new file mode 100644 index 0000000..913b8f2 --- /dev/null +++ b/vocata-web/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/vocata-web/tsconfig.json b/vocata-web/tsconfig.json new file mode 100644 index 0000000..66b5e57 --- /dev/null +++ b/vocata-web/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/vocata-web/tsconfig.node.json b/vocata-web/tsconfig.node.json new file mode 100644 index 0000000..a83dfc9 --- /dev/null +++ b/vocata-web/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/vocata-web/vite.config.ts b/vocata-web/vite.config.ts new file mode 100644 index 0000000..4db2d8b --- /dev/null +++ b/vocata-web/vite.config.ts @@ -0,0 +1,36 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => { + // 根据当前模式加载对应的环境变量 + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, + server: { + port: 3000, + host: '0.0.0.0', // 允许外部访问 + strictPort: true, // 端口被占用时不自动尝试下一个端口 + proxy: { + // 代理所有 /api 开头的请求到后端服务器 + '/api': { + target: env.VITE_APP_URL, + changeOrigin: true, + secure: false, + } + } + }, + } +})