From e58159fc42e867c74ead4de23be9f0f64023df48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Mon, 4 May 2026 16:07:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=9E=B6=E6=9E=84=E6=94=B9=E9=80=A0=EF=BC=88?= =?UTF-8?q?Adapter=20Protocol=20+=20Gateway=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: 异常体系统一 - 新增 PlatformError / PlatformErrorType 标准定义 - 改造所有 Provider 异常抛出为 PlatformError - 注册全局 PlatformError exception handler Phase 2: Adapter Protocol - 新增 app/ai/adapters/base.py(PlatformAdapter + SyncCapable + TaskCapable + CallbackCapable) - 新增 app/ai/adapters/constants.py(Method 常量) - 新增 PlatformConfigLoader(config/platform-config.yaml) Phase 3: HTTP Client 统一 - ViduProvider 从 aiohttp 迁移到 httpx(注入方式) - VolcengineCaptionService 改为注入 http_client - lifespan 统一管理所有 Client 创建和关闭 Phase 4: Gateway 骨架 + Adapter 实现 - 新增 ViduAdapter / VolcengineArkAdapter / VolcengineCaptionAdapter - 新增 PlatformGateway(call_sync / submit_task / query_task / handle_webhook) - 新增 LLMGateway(带 Fallback 降级链) - lifespan 注册所有 Adapter 和 Gateway Phase 6: 清理与验证 - 从 Settings 移除 VIDU_BASE_URL / VOLCENGINE_BASE_URL - Provider 改为从 PlatformConfigLoader 读取 base_url - 清理 volcengine_caption_service 全局单例 - config_loader 默认路径改为 platform-config.yaml - Scheduler 注入共享 HTTP client - vidu.py 回调路由使用 Adapter 验签和解析 - ruff 全量通过,应用启动测试通过 --- AGENTS.md | 1047 ++++++----------- docs/third-party-integration-architecture.md | 704 +++++++++++ ...d-party-integration-implementation-plan.md | 532 +++++++++ python-api/app/ai/adapters/__init__.py | 0 python-api/app/ai/adapters/base.py | 115 ++ python-api/app/ai/adapters/constants.py | 23 + python-api/app/ai/adapters/vidu_adapter.py | 265 +++++ .../app/ai/adapters/volcengine_ark_adapter.py | 101 ++ .../ai/adapters/volcengine_caption_adapter.py | 142 +++ python-api/app/ai/gateways/.gitkeep | 0 python-api/app/ai/gateways/__init__.py | 0 python-api/app/ai/gateways/llm_gateway.py | 150 +++ python-api/app/ai/model_router.py | 8 +- python-api/app/ai/providers/vidu_provider.py | 170 ++- .../app/ai/providers/volcengine_provider.py | 54 +- python-api/app/api/v1/caption.py | 139 +-- python-api/app/api/v1/vidu.py | 109 +- python-api/app/api/v1/voice.py | 47 +- python-api/app/config.py | 30 +- python-api/app/core/config_loader.py | 47 +- python-api/app/core/exceptions.py | 79 ++ python-api/app/core/platform_config.py | 135 +++ python-api/app/core/runtime_config.py | 114 ++ python-api/app/main.py | 122 +- python-api/app/models/__init__.py | 2 +- python-api/app/platform_gateway.py | 162 +++ .../scheduler/handlers/subtitle_handler.py | 10 +- python-api/app/scheduler/main.py | 31 +- python-api/app/services/vidu_service.py | 4 +- .../services/volcengine_caption_service.py | 187 ++- python-api/config/platform-config.yaml | 180 +++ python-api/docker-compose.dev.yml | 1 - python-api/docker-compose.prod.yml | 4 - python-api/docker-compose.test.yml | 4 - 34 files changed, 3688 insertions(+), 1030 deletions(-) create mode 100644 docs/third-party-integration-architecture.md create mode 100644 docs/third-party-integration-implementation-plan.md create mode 100644 python-api/app/ai/adapters/__init__.py create mode 100644 python-api/app/ai/adapters/base.py create mode 100644 python-api/app/ai/adapters/constants.py create mode 100644 python-api/app/ai/adapters/vidu_adapter.py create mode 100644 python-api/app/ai/adapters/volcengine_ark_adapter.py create mode 100644 python-api/app/ai/adapters/volcengine_caption_adapter.py create mode 100644 python-api/app/ai/gateways/.gitkeep create mode 100644 python-api/app/ai/gateways/__init__.py create mode 100644 python-api/app/ai/gateways/llm_gateway.py create mode 100644 python-api/app/core/platform_config.py create mode 100644 python-api/app/core/runtime_config.py create mode 100644 python-api/app/platform_gateway.py create mode 100644 python-api/config/platform-config.yaml diff --git a/AGENTS.md b/AGENTS.md index a552ac8..934f479 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,794 +1,405 @@ -# 美家卡智影 (Meijiaka Smart Cut) - AI 视频剪辑桌面应用 +# AGENTS.md — 美家卡智影 + +> 本文档面向 AI Coding Agent。项目主要使用中文进行注释和界面文案,文档亦以中文撰写。 + +--- ## 项目概述 -美家卡智影是一款 AI 驱动的**短视频剪辑**桌面应用,采用 **Tauri + React + FastAPI** 混合架构。用户导入长视频素材或提供口播文案,AI 自动分镜并生成配音,最终合成带字幕的成品短视频。 +**美家卡智影**是一款面向桌面端的 AI 视频创作应用,采用"Python 后端 API + Tauri 桌面前端"的混合架构。 -### 核心功能流程(6 步) +- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-jian` +- **版本**: `0.1.0` +- **核心功能**: AI 脚本生成、AI 配音(TTS)、数字人视频生成、声音复刻、视频字幕生成、视频合成(FFmpeg)、项目本地持久化 -1. **脚本生成** (Step 1) - AI 生成或粘贴口播文案,自动拆分为带预估时长的分镜 -2. **音频合成** (Step 2) - AI 声音克隆(KlingAI / MiniMax / Vidu)+ TTS 合成,为每段生成配音音频 -3. **视频生成** (Step 3) - 导入长视频素材并按脚本时长自动切割为片段,或为空镜匹配素材视频 -4. **字幕压制** (Step 4) - 基于音频自动对齐时间轴,ASS 字幕渲染并压制到视频 -5. **封面制作** (Step 5) - 使用 Fabric.js 设计封面,提取视频首帧并叠加标题样式 -6. **视频合成** (Step 6) - FFmpeg 拼接视频片段,替换原声为 TTS 音频,导出成品 +### 技术栈总览 -### 核心设计理念 - -**轻量云账号 + 全本地业务数据**: -- 云端仅存储用户账户信息(`users` 表) -- 业务数据(项目、脚本、分镜、媒体、成品)全部保存在用户本地磁盘 -- 任务调度与状态通过 Redis 管理 - ---- - -## 技术栈 - -### 后端 (python-api) - -| 组件 | 技术 | 版本 | 用途 | -|------|------|------|------| -| Python | - | 3.13+ | 运行环境 | -| Web 框架 | FastAPI | 0.116+ | REST API | -| 数据库 | PostgreSQL | 15+ | 用户认证 | -| ORM | SQLAlchemy | 2.0 (异步) | 数据模型 | -| 缓存/调度 | Redis + Async Engine | 5.2+ / 自定义 | 异步任务槽位调度 | -| AI SDK | OpenAI / volcengine | 1.58+ / 5.0+ | LLM / 字幕调用 | -| TTS/语音 | KlingAI / MiniMax / Vidu | - | 语音合成、声音克隆 | -| 认证 | python-jose + passlib | 3.4+ / 1.7+ | JWT 认证 | -| HTTP 客户端 | httpx + aiohttp | 0.28+ / 3.13+ | 异步 HTTP | -| 对象存储 | qiniu | 7.13+ | 七牛云 Kodo | -| 包管理/构建 | uv | - | 虚拟环境、依赖锁定、Docker 构建 | - -### 前端 (tauri-app) - -| 组件 | 技术 | 版本 | 用途 | -|------|------|------|------| -| 桌面框架 | Tauri | 2.x | 桌面应用壳 | -| UI 框架 | React | 19.1+ | 用户界面 | -| 路由 | 自定义 NavigationContext | - | 页面切换(react-router-dom 已安装但主流程未使用) | -| 状态管理 | Zustand | 5.x | 全局状态 + Immer 中间件 | -| 数据获取 | SWR | 2.x | 请求缓存 | -| 虚拟列表 | @tanstack/react-virtual | 3.x | 大数据列表渲染 | -| 构建工具 | Vite | 7.x | 构建、开发服务器 | -| 测试 | Vitest + @testing-library | 4.x | 单元测试 | -| 类型生成 | openapi-typescript | 7.x | 从 OpenAPI 生成 TS 类型 | -| 字幕渲染 | assjs | 0.1.x | ASS 字幕前端渲染 | -| 封面设计 | fabric | 6.x | Canvas 封面编辑 | - -### Rust 后端 (src-tauri/src) - -| 模块 | 用途 | +| 层级 | 技术 | |------|------| -| `lib.rs` | Tauri 应用入口,命令注册,全局错误处理 | -| `main.rs` | 二进制入口 | -| `ffmpeg_cmd.rs` | FFmpeg 命令封装(首帧提取、字幕压制、封面合成、音频替换/混音/标准化) | -| `video_processing.rs` | 视频合成业务逻辑 | -| `api_proxy.rs` | Python API HTTP 代理转发 | -| `auth.rs` | 认证相关 IPC 命令 | -| `avatar_cache.rs` | 头像视频缓存管理、海报生成 | -| `utils.rs` | 项目目录辅助函数 | -| `storage/engine.rs` | 本地存储引擎(原子写入、文件锁、路径净化) | -| `storage/paths.rs` | 集中化路径计算(`~/Documents/Meijiaka-zy/` 为根目录) | -| `commands/project.rs` | 项目本地存储 IPC 命令 | -| `commands/asset.rs` | 资源文件保存 IPC 命令 | -| `commands/auth_state.rs` | 认证状态文件持久化 | -| `commands/voice.rs` | 音频文件保存/列表/删除 IPC 命令 | -| `commands/product.rs` | 成品视频保存/列表/删除/重命名 IPC 命令 | -| `commands/avatar.rs` | 头像列表本地存储 | -| `commands/video_compose.rs` | 视频拼接、文件上传/下载 | +| 后端框架 | FastAPI (Python 3.13, 异步) | +| 数据库 | PostgreSQL 15+ + SQLAlchemy 2.0 (asyncpg) | +| 缓存/队列 | Redis 7.x | +| 异步调度 | 自研 Async Engine(Slot Scheduler) | +| 桌面壳 | Tauri v2 (Rust) | +| 前端框架 | React 19 + TypeScript | +| 前端构建 | Vite 7 | +| 状态管理 | Zustand 5 + Immer 11 | +| 路由 | `react-router-dom` | +| 数据请求 | 自研智能路由客户端 + SWR | +| 测试(后端) | pytest + pytest-asyncio | +| 测试(前端) | Vitest 4 + jsdom + `@testing-library/react` | +| 部署 | Docker + Docker Compose + Nginx | --- -## 项目结构 +## 仓库结构 + +本仓库为 Monorepo,包含两个主要子项目: ``` -meijiaka-zy/ -├── python-api/ # FastAPI 后端服务(AI 代理 + 认证 + 任务调度) -│ ├── app/ -│ │ ├── api/v1/ # API 路由 -│ │ ├── ai/ # AI 模型路由、Provider、提示词模板 -│ │ ├── core/ # 安全、配置加载、Token管理器、Redis客户端、异常处理 -│ │ ├── crud/ # 数据访问层 -│ │ ├── db/ # 数据库配置(PostgreSQL + asyncpg + SQLAlchemy 2.0) -│ │ ├── models/ # SQLAlchemy 模型 -│ │ ├── schemas/ # Pydantic 校验模型 -│ │ ├── services/ # AI 服务代理、字幕/视频/TTS/声音克隆/ASS生成服务 -│ │ ├── scheduler/ # Async Engine 异步任务调度 -│ │ ├── config.py # Pydantic Settings 配置管理 -│ │ └── main.py # FastAPI 入口(含生命周期管理) -│ ├── config/ # AI 模型配置(ai_models.yaml,支持热重载)、materials.json -│ ├── alembic/ # 数据库迁移 -│ ├── pyproject.toml # Python 依赖和工具配置 -│ ├── requirements.lock # uv 锁定依赖版本 -│ ├── uv.lock # uv 全锁定 -│ ├── Makefile # 常用命令封装 -│ ├── docker-compose.yml -│ ├── docker-compose.test.yml -│ ├── docker-compose.prod.yml -│ ├── docker-compose.dev.yml -│ ├── Dockerfile -│ ├── deploy-test.sh -│ ├── .env.example # 环境变量模板 -│ └── .pre-commit-config.yaml -│ -├── tauri-app/ # Tauri 桌面应用(业务数据本地存储) -│ ├── src/ # React 前端源码 -│ │ ├── api/ -│ │ │ ├── adapters/ # 数据转换层(前后端字段映射) -│ │ │ ├── generated/ # OpenAPI 自动生成类型(只读) -│ │ │ ├── modules/ # API 模块封装(HTTP + IPC) -│ │ │ ├── client.ts # HTTP 客户端(自动 camelCase↔snake_case) -│ │ │ └── types.ts # 手写核心类型 -│ │ ├── components/ # 可复用组件 -│ │ ├── pages/ # 页面组件 -│ │ ├── store/ # Zustand 状态管理(+ Immer + persist) -│ │ ├── hooks/ # 自定义 React Hooks -│ │ ├── styles/ # 全局 CSS 变量、主题 -│ │ └── utils/ # 工具函数 -│ ├── src-tauri/ # Rust 后端源码 -│ │ ├── src/ -│ │ ├── Cargo.toml -│ │ ├── tauri.conf.json -│ │ ├── binaries/ # 嵌入式 FFmpeg / ffprobe(平台特定文件名) -│ │ └── fonts/ # 封面字体(DouyinSansBold.ttf) -│ ├── package.json -│ ├── vite.config.ts -│ ├── vitest.config.ts -│ ├── tsconfig.json -│ ├── tsconfig.node.json -│ ├── eslint.config.js -│ ├── .prettierrc -│ └── .stylelintrc.json -│ -├── docs/ # 项目文档(设计文档、API 参考、实施计划) -├── scripts/ # 实用脚本(如 video-replace-mvp.py) -├── .cursor/ # Cursor IDE 规则 -├── .python-version # 3.13 -├── .gitignore -├── .gitlab-ci.yml # GitLab CI 部署配置 -├── CLAUDE.md # 开发者速查手册 -└── AGENTS.md # 本文件 +. +├── python-api/ # Python 后端 API +├── tauri-app/ # Tauri 桌面前端 +├── docs/ # 项目文档(架构设计、API 对接指南等) +├── scripts/ # 辅助脚本 +├── .gitlab-ci.yml # GitLab CI/CD 配置 +└── AGENTS.md # 本文档 ``` -### 后端目录详细说明 +### python-api/ 目录结构 -- `app/api/v1/`:10 个活跃模块 - - `auth.py` - 认证 - - `system.py` - 系统信息/健康检查 - - `tasks.py` - 任务管理 - - `script.py` - 脚本生成/润色 - - `caption.py` - 火山引擎字幕 - - `voice.py` - 语音合成/TTS - - `upload.py` - 文件上传 - - `vidu.py` - Vidu 对口型 - - `materials.py` - 空镜素材 - - `router.py` - 路由聚合 -- `app/ai/`: - - `model_router.py` - 模型路由器(自动降级、YAML 配置热重载) - - `providers/` - `base.py`, `generic_llm_provider.py`, `volcengine_provider.py`, `klingai_provider.py`, `minimax_provider.py`, `vidu_provider.py` - - `prompts/` - 提示词模板(按 category/subcategory 组织) -- `app/core/`: - - `config_loader.py` - AI 模型配置加载器(支持热重载) - - `exceptions.py` - 全局异常定义 - - `health_checker.py` - 健康检查 - - `redis_client.py` - Redis 客户端 - - `security.py` - 密码/JWT 工具 - - `token_manager.py` - Token 缓存与自动刷新 -- `app/crud/`:`base.py`, `user.py` -- `app/db/`:`session.py` - 异步数据库会话 -- `app/models/`:`base.py`, `user.py` -- `app/schemas/`:`auth.py`, `avatar.py`, `caption.py`, `common.py`, `enums.py`, `job.py`, `materials.py`, `script.py`, `segment.py` -- `app/services/`: - - `ai_response_utils.py` - AI 响应标准化 - - `anytocopy_service.py` - AnyToCopy 文案提取 - - `ass_generator.py` - ASS 字幕生成 - - `kling_video_service.py` - Kling 视频服务 - - `material_service.py` - 空镜素材配置加载 - - `minimax_tts_service.py` - MiniMax TTS - - `qiniu_service.py` - 七牛云上传 - - `script_service.py` - 脚本生成服务 - - `tts_service.py` - TTS 通用服务 - - `vidu_tts_service.py` - Vidu TTS/对口型 - - `voice_clone_service.py` - 声音克隆 - - `volcengine_caption_service.py` - 火山字幕服务 -- `app/scheduler/`: - - `engine.py` - AsyncEngine 主循环 - - `registry.py` - Redis-based 任务 CRUD - - `slot_manager.py` - Redis Lua 原子脚本槽位管理 - - `models.py` - 调度器内部类型 - - `handlers/` - `avatar_handler.py`, `copy_handler.py`, `image_handler.py`, `script_handler.py`, `subtitle_handler.py`, `tts_handler.py`, `video_handler.py` - - `main.py` - 调度器独立进程入口 - -### 前端目录详细说明 - -- `src/api/modules/`: - - `auth.ts`, `script.ts`, `voice.ts`, `video.ts`, `videoComposite.ts`, `videoCompose.ts`, `cover.ts`, `system.ts` - - `avatar.ts`, `caption.ts`, `config.ts`, `localStorage.ts`, `materials.ts`, `project.ts`, `task.ts`, `vidu.ts` -- `src/components/`:`Layout/`, `Modal/`, `Toast/`, `ErrorBoundary/`, `ShotStats/`, `Slider/`, `ProgressModal/`, `VirtualShotList/` -- `src/pages/`: - - `VideoCreation/` - 6 步视频创作流程(ScriptCreation, VoiceDubbing, VideoGeneration, SubtitleBurning, CoverDesign, VideoComposite) - - `ContentManagement/` - VoiceMaterialLibrary, AvatarClone, MyWorks, AvatarCard - - `Settings/` - ThemeSettings, SystemUpdate, AboutUs - - `Profile/` - Profile, UsageDetail - - `Login/` - Login -- `src/store/`:`authStore.ts`, `projectStore.ts`, `taskStore.ts`, `uiStore.ts`, `progressStore.ts`, `settingsStore.ts`, `voiceStore.ts` - - 测试:`src/store/__tests__/authStore.test.tsx`, `settingsStore.test.tsx` -- `src/hooks/`:`useAssJsRenderer.ts`, `useAvatarCache.ts`, `useAvatarLibrary.ts`, `useCoverFabric.ts`, `useLocalImage.ts`, `useLocalVideo.ts`, `useModelHealth.ts`, `usePerformanceMonitor.ts`, `useSubtitleAlignment.ts`, `useTask.ts`, `useVideoGeneration.ts` -- `src/utils/`:`assGenerator.ts`, `audioAlign.ts`, `avatarStorage.ts`, `env.ts`, `fileHash.ts` - ---- - -## 构建与开发命令 - -### Python 后端 - -项目使用 `uv` 进行依赖管理,并通过 `Makefile` 封装常用命令。 - -```bash -cd python-api - -# 依赖管理 -make dev # 安装开发依赖 + pre-commit 钩子 -make install # 使用 requirements.lock 安装生产依赖 -make update-lock # 更新 requirements.lock - -# 代码质量 -make lint # ruff + mypy -make format # black + ruff --fix -make format-check # 检查格式但不修改 -make lint-semantic # 语义层禁词检查 -make security # bandit + pip-audit -make ci # 运行所有 CI 检查(format-check + lint + lint-semantic + security) - -# 测试 -make test # pytest(当前无测试文件) -make test-cov # 覆盖率报告 - -# 开发服务器 -make run # 本地启动 uvicorn(端口 8000) -make scheduler # 本地启动 Async Engine Scheduler - -# Docker(项目名称为 meijiaka-zj) -make docker-run # 启动 api + scheduler(共享外部 db/redis) -make docker-stop # 停止 api + scheduler -make docker-rebuild # 强制重建 api + scheduler -make docker-rm # 删除 api + scheduler 容器 -make docker-logs # 查看所有日志 -make docker-logs-api # 查看 api 日志 -make docker-logs-scheduler # 查看 scheduler 日志 - -# 其他 -make clean # 清理缓存文件 +``` +python-api/ +├── app/ # 主应用代码 +│ ├── api/v1/ # API 路由(按领域拆分:auth, script, voice, vidu, caption, tasks, upload, materials, system) +│ ├── core/ # 核心工具(配置加载、安全、异常、Redis 客户端、健康检查) +│ ├── db/ # 数据库配置与会话管理 +│ ├── models/ # SQLAlchemy ORM 模型(BaseModel 提供 UUID 主键 + 时间戳) +│ ├── schemas/ # Pydantic Schema(请求/响应校验) +│ ├── services/ # 业务逻辑层 +│ ├── scheduler/ # Async Engine 异步任务调度(Slot Manager + Job Registry + Handlers) +│ ├── ai/ # AI 模型封装(providers: volcengine, vidu, generic_llm) +│ ├── crud/ # 数据库 CRUD 封装 +│ ├── config.py # Pydantic Settings 配置管理 +│ └── main.py # FastAPI 应用入口(含 lifespan 管理) +├── config/ # 运行时配置文件(platform-config.yaml, materials.json) +├── alembic/ # 数据库迁移脚本 +├── nginx/ # Nginx 反向代理配置(含 acme.sh SSL 证书脚本) +├── Dockerfile # 多阶段构建镜像(builder + production) +├── docker-compose.yml # 开发环境编排(api + scheduler,共享外部 db/redis) +├── docker-compose.prod.yml # 生产环境编排 +├── docker-compose.test.yml # 测试环境编排 +├── pyproject.toml # Python 依赖与工具配置(black, ruff, mypy, pytest, bandit) +├── requirements.lock # 锁定依赖(uv pip compile 生成) +├── uv.lock # uv 锁定文件 +├── Makefile # 常用开发命令 +└── .pre-commit-config.yaml # Git 钩子配置 ``` -**重要**:`docker-compose` 命令使用 **`-p meijiaka-zj`** 作为项目名称,避免与其他项目冲突。容器实际名称为 `meijiaka-zy-api` 和 `meijiaka-zy-scheduler`。数据库和 Redis 是共享外部基础设施(通过 `meijiaka-network` 外部网络连接),**禁止**在本项目执行 `docker-compose down` 以免破坏共享服务。 +### tauri-app/ 目录结构 -**首次启动后端**: -```bash -# 1. 先启动共享基础设施(只需一次) -cd /Users/0fun/work/docker-infra # 或其他共享基础设施目录 -docker-compose up -d - -# 2. 再启动本项目服务 -cd /Users/0fun/work/meijiaka-zy/python-api -make docker-run ``` - -**导出 OpenAPI 文档到前端**: -```bash -python3 -c " -import logging -logging.disable(logging.WARNING) -from app.main import app -import json -print(json.dumps(app.openapi(), indent=2, ensure_ascii=False)) -" > ../tauri-app/src/api/generated/openapi.json -``` - -### Tauri 前端 - -```bash -cd tauri-app - -# 安装依赖 -npm install - -# 开发 -npm run dev # 纯 Vite 开发(端口 1420,不启动 Tauri) -npm run tauri dev # 完整 Tauri 开发模式 - -# 构建 -npm run build # 前端生产构建(tsc + vite build) -npm run tauri build # 打包桌面应用 - -# 测试 -npm run test # 运行 Vitest -npm run test:ui # UI 模式 -npm run test:coverage # 覆盖率报告 - -# 代码质量 -npm run lint # ESLint 检查 -npm run lint:fix # ESLint 自动修复 -npm run format # Prettier 格式化 -npm run format:check # Prettier 格式检查 -npm run stylelint # CSS 检查 -npm run stylelint:fix # CSS 自动修复 - -# 类型生成 -npm run gen:api # 从 OpenAPI 生成 TypeScript 类型 +tauri-app/ +├── src/ # React 前端源码 +│ ├── api/ # API 客户端与模块 +│ │ ├── client.ts # 智能路由客户端(HTTP / IPC 自动选择,camelCase ↔ snake_case 自动转换) +│ │ ├── generated/ # OpenAPI 生成的 TypeScript 类型 +│ │ └── modules/ # 按领域拆分的 API 模块 +│ ├── components/ # 可复用组件(PascalCase 文件夹) +│ ├── pages/ # 页面级组件(PascalCase 文件夹) +│ ├── store/ # Zustand 状态管理(含 __tests__) +│ ├── hooks/ # 自定义 React Hooks +│ ├── utils/ # 工具函数 +│ ├── styles/ # CSS 变量与全局样式 +│ └── __tests__/setup.ts # Vitest 全局 setup(mock localStorage / Tauri API) +├── src-tauri/ # Rust 后端源码 +│ ├── src/ +│ │ ├── main.rs # 程序入口 +│ │ ├── lib.rs # Tauri Builder、Command 定义、公共类型 +│ │ ├── ffmpeg_cmd.rs # FFmpeg 命令封装 +│ │ ├── video_processing.rs # 视频合成业务逻辑 +│ │ ├── api_proxy.rs # Python API 代理 +│ │ ├── auth.rs # 认证命令 +│ │ ├── avatar_cache.rs # 头像缓存 +│ │ ├── storage/ # 本地存储引擎(项目、认证、配置、头像等) +│ │ ├── commands/ # Tauri IPC 命令(按领域拆分) +│ │ └── utils.rs # 通用工具 +│ ├── Cargo.toml # Rust 依赖 +│ ├── tauri.conf.json # Tauri 应用配置(窗口、CSP、打包、sidecar) +│ └── binaries/ # FFmpeg / ffprobe sidecar 二进制 +├── package.json # Node 依赖与脚本 +├── vite.config.ts # Vite 配置(端口 1420) +├── vitest.config.ts # 测试配置 +├── tsconfig.json # TypeScript 主配置(strict: true) +├── eslint.config.js # ESLint 配置 +├── .prettierrc # Prettier 配置 +└── .stylelintrc.json # Stylelint 配置 ``` --- ## 运行时架构 -### 混合通信架构 +采用**混合通信架构**: -前端 API 调用采用 **智能路由** 策略: +1. **纯数据 API**(脚本生成、配音、视频生成、字幕、任务查询等)→ 前端通过 HTTP **直连 Python 后端**。 +2. **需要本地系统能力**(FFmpeg 视频合成、文件系统读写、项目本地持久化、认证状态存储)→ 走 **Tauri IPC → Rust 层** 处理。 -1. **HTTP 直连 Python**:纯数据 API(脚本生成、模型管理、任务轮询等) -2. **Tauri IPC → Rust**:需要本地能力的 API(FFmpeg、文件系统) +> 新增纯数据 API 时,**无需修改 Rust 代码**,直接在 `tauri-app/src/api/modules/` 下使用 `client.post/get` 调用即可。只有涉及本地系统能力的 API 才需要在 Rust 层新增 `#[tauri::command]`。 -路由决策在 `tauri-app/src/api/client.ts` 中实现。HTTP 客户端会自动处理 `camelCase` ↔ `snake_case` 字段名转换。 - -**走 Rust IPC 的 API 包括但不限于**: -- `video_composite_synthesis` / `compose_video` -- `burn_subtitle` -- `extract_video_first_frame` -- `generate_cover_image` -- `replace_audio_track` / `mix_audio_tracks` / `standardize_audio` -- 项目/资源/成品/音频的本地存取命令 -- 认证状态持久化(`load_auth_state` / `save_auth_state` / `clear_auth_state`) - -**添加新 API 流程**: -1. Python 端实现端点 -2. 前端直接调用(默认 HTTP) -3. 仅当需要本地能力时,在 Rust 中添加命令并在 `lib.rs` 注册 - -### 后端架构说明 - -- 后端为"轻量云账号 + 全本地业务数据"模式 -- 云端仅存储:用户账户信息(`users` 表) -- 业务数据(项目/脚本/媒体/成品)全部本地存储 -- 任务调度使用 **自定义 Async Engine**(基于 Redis 的槽位管理),**非 Celery** -- 当前活跃 API 模块:**10 个**:`auth`, `system`, `tasks`, `script`, `caption`, `voice`, `upload`, `vidu`, `materials` -- 调度器已注册 7 个 Handler:`Video`, `Avatar`, `Image`, `Subtitle`, `Copy`, `Script`, `TTS` - -### 本地存储引擎(Rust) - -Rust 层实现了 defense-in-depth 的本地存储系统:`src-tauri/src/storage/` - -- **`engine.rs`**:核心原子操作 - - `sanitize_id()` — 白名单 `[a-zA-Z0-9_-]+`,防御路径遍历 - - `sanitize_filename()` — 提取纯文件名,拒绝目录组件 - - `atomic_write_json()` / `atomic_write_bytes()` — 先写 `.tmp` 再 `rename` 原子替换 - - `with_file_lock()` — 通过 `fs2` 实现独占文件锁 - - `read_json()` — 安全读取,文件不存在返回 `None` -- **`paths.rs`**:集中路径计算 - - `~/Documents/Meijiaka-zy/projects/{id}/` (meta.json, segments.json, assets/, videos/, images/) - - `~/Documents/Meijiaka-zy/products/` - - `~/Documents/Meijiaka-zy/avatars.json` - - `~/Documents/Meijiaka-zy/voices.json` - - `{app_config_dir}/auth.json` - - `{app_data_dir}/avatars/` (缓存) - -**所有本地 JSON 读写必须经过 StorageEngine,禁止在命令处理器中直接调用 `fs::write`**。 - -### 数据流规范 +### 服务拓扑(生产/测试环境) ``` -用户输入主题 ──→ 后端 AI 生成脚本 ──→ 后端返回分镜列表 ──→ 前端保存到本地 - │ │ - └────────────────── 后端不存储脚本数据 ────────────────────────┘ - -用户导入长视频 ──→ Rust 本地切割 ──→ 生成分段视频 ──→ 前端保存到本地 +┌─────────────┐ HTTP ┌─────────────────┐ +│ Tauri │ ────────────▶ │ Nginx (SSL) │ +│ 桌面端 │ │ 反向代理 │ +└─────────────┘ └────────┬────────┘ + │ │ + │ IPC (Rust) │ + ▼ ▼ +┌─────────────┐ ┌─────────────────┐ +│ FFmpeg │ │ FastAPI (8000) │ +│ Sidecar │ │ - API 路由 │ +│ 本地文件系统 │ │ - 业务逻辑 │ +└─────────────┘ └────────┬────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌────────┐ ┌─────────┐ ┌──────────┐ + │PostgreSQL│ │ Redis │ │ AI 服务商 │ + └────────┘ └─────────┘ └──────────┘ + │ + ▼ + ┌───────────────┐ + │ Async Engine │ + │ Scheduler │ + └───────────────┘ ``` -### 前端导航与状态管理 - -- 主应用壳使用 **自定义 NavigationContext**(React Context)实现页面切换。`react-router-dom` 已安装但主流程不使用 BrowserRouter。 -- 页面结构:`video-creation`, `voice-material`, `avatar-clone`, `my-works`, `profile`, `usage-detail`, `settings-*` - -**Zustand Store 列表**: - -| Store | 职责 | 持久化 | -|-------|------|--------| -| `authStore` | JWT、UserInfo、登录/登出 | Tauri `auth.json`(或 localStorage fallback) | -| `projectStore` | 分镜、currentStep、选题、封面配置 | **仅 UI 标志**通过 `persist`;业务数据显式写入本地 JSON | -| `taskStore` | 异步任务状态/进度/消息 | **无**(内存 only,真相源在后端 Redis) | -| `uiStore` | Toast 通知队列 | 无 | -| `progressStore` | 全局进度模态框 | 无 | -| `settingsStore` | 主题模式、用户偏好 | localStorage | -| `voiceStore` | 语音/形象选择状态、音色素材、头像素材 | 无 | - -`projectStore` **不自动保存**。数据在显式过渡点持久化到磁盘(如离开 Step 1 时触发 `saveMetaToLocalFile`)。`saveMetaToLocalFile()` 通过 Promise 链串行化写入,避免并发覆盖。 - --- -## AI Provider 架构 +## 构建与开发命令 -后端 AI 模块采用 **多 Provider 路由** 设计: - -``` -app/ai/ -├── model_router.py # 模型路由器(自动降级、YAML 配置热重载) -├── providers/ -│ ├── base.py # Provider 抽象基类 -│ ├── generic_llm_provider.py # 通用 OpenAI 兼容 Provider(含 Mock) -│ ├── volcengine_provider.py # 火山方舟官方 SDK -│ ├── klingai_provider.py # KlingAI(可灵 AI) -│ ├── minimax_provider.py # MiniMax -│ └── vidu_provider.py # Vidu -└── prompts/ # 提示词模板(禁止硬编码) - ├── loader.py - ├── system/ # 系统提示词(按 category/subcategory 组织) - ├── user/ # 用户提示词模板 - ├── polish/ # 润色提示词 - └── cover/ # 封面生成提示词 -``` - -支持的 AI 平台: -- **火山方舟** (字节跳动) - LLM、字幕服务 -- **OpenAI** - GPT 系列(可选) -- **可灵 AI** (快手) - TTS 语音合成、声音克隆、视频/图片生成 -- **MiniMax** - TTS 语音合成、声音克隆、视频生成 -- **Vidu** - TTS 语音合成、声音克隆、对口型(lip-sync) -- **文心一言** (百度) - 可选 -- **通义千问** (阿里云) - 可选 - -AI 模型配置位于 `python-api/config/ai_models.yaml`,支持热重载,无需重启服务即可更新模型配置。API Key 不放在 YAML 中,统一通过 `.env` / `Settings` 读取。 - ---- - -## Async Engine(异步任务调度) - -**⚠️ 重要:项目不使用 Celery,使用自定义 Async Engine** - -架构: -``` -API (POST /tasks/{type}) → Redis JobRegistry → AsyncEngine tick loop → Handlers -``` - -组件: -- **`AsyncEngine`** (`app/scheduler/engine.py`):每 ~10s 执行 `tick()`,加载运行中任务,按类型分组,并行分发给 Handler,通过 Pipeline 应用 `StateChange`,清理已完成任务 -- **`JobRegistry`** (`app/scheduler/registry.py`):Redis-based 任务 CRUD,使用 `job:{id}` hash + `scheduler:running_tasks` SET -- **`SlotManager`** (`app/scheduler/slot_manager.py`):Redis Lua 原子脚本实现并发槽位抢占/释放 -- **`JobRecord`** / **`StateChange`** (`app/scheduler/models.py`):调度器内部类型 - -已注册的 Handler(`app/scheduler/main.py`): - -| Handler | 槽位数 | Redis Key | 用途 | -|---------|--------|-----------|------| -| VideoHandler | 18 | `kling:video_slots` | Kling 视频生成(omni + image2video) | -| ImageHandler | 9 | `kling:image_slots` | Kling 图片生成 | -| ScriptHandler | 10 | `script:slots` | LLM 脚本生成(含 AnyToCopy 视频文案提取) | -| SubtitleHandler | 5 | `volc:subtitle_slots` | 火山引擎字幕/自动对齐 | -| CopyHandler | 5 | `anytocopy:slots` | AnyToCopy 视频文案提取 | -| TTSHandler | 10 | `kling:tts_slots` | Kling TTS 语音合成 | -| AvatarHandler | 2 | `kling:avatar_slots` | Kling 形象克隆 | - ---- - -## 代码风格与开发规范 - -### 核心原则 - -1. **后端环境优先使用 Docker Compose**:先启动共享基础设施 `docker-infra`,再启动本项目 `make docker-run`。前端默认连接 `http://127.0.0.1:8081/api/v1`。 -2. **接口契约优先**:后端承诺无论使用什么 AI 模型,输出永远符合同一个 Schema -3. **类型单一来源**:后端 Schema 是权威,前端通过 OpenAPI 生成类型 -4. **Adapter 层隔离**:前后端字段差异只允许在 Adapter 层处理 -5. **数据库分层**:API → Service → CRUD → Model,禁止跨层调用 -6. **提示词文件化**:除前端输入外,后端不允许硬编码任何 Prompt -7. **配置统一管理**:所有配置通过 `get_settings()` 读取,禁止直接使用 `os.getenv()` -8. **本地存储必须经过 StorageEngine**:Rust 层所有文件操作使用 `atomic_write_json` + `with_file_lock` - -### 配置管理规范 - -**架构层级:** -``` -.env (Layer 1) ──→ Settings (Layer 2) ──→ 服务层 (Layer 3) - ↑ - 唯一配置出口 -``` - -**强制规范:** -- **所有服务**必须使用 `from app.config import get_settings` 读取配置 -- **禁止**在服务层使用 `os.getenv()` 或 `os.environ.get()` -- **所有配置项**必须在 `app/config.py` 的 `Settings` 类中定义 -- **敏感信息**(API Keys、Secrets)必须通过环境变量注入 -- **业务默认值**可以硬编码在 `Settings` 中 - -**添加新配置流程:** -1. 在 `app/config.py` 的 `Settings` 类中添加字段定义 -2. 在 `.env` 中添加实际值(敏感信息)或使用默认值 -3. 在服务层通过 `get_settings()` 读取 -4. 更新 `.env.example` 文档 - -### 语义层防护网 - -项目强制执行语义分层,禁止供应商术语泄漏到业务层: - -| 层级 | 职责 | 禁词示例 | -|------|------|----------| -| Layer 6 (Presentation) | API Schema | `element_id`, `kling_task_id` | -| Layer 4 (Orchestration) | Scheduler | `task_id`(应使用 `job_id`) | -| Layer 3 (Domain) | Service | 供应商特定术语 | -| Layer 2 (Adapter) | Provider | 允许使用供应商原生术语 | - -Makefile 提供 `make lint-semantic` 进行自动化检查: -- API 层(除 `klingai.py`)禁止使用 `element_id`(应使用 `provider_element_id` 或 `human_id`) -- Scheduler 层禁止使用 `task_id`(应使用 `job_id`) -- 全局禁止 `kling_task_id`(应使用 `provider_task_id`) -- Scheduler Redis key 必须使用 `job:` 而非 `task:` - -### 后端分层架构 - -``` -API Layer (api/v1/*.py) - ↓ 调用 -Service Layer (services/*.py) - 可选,复杂业务 - ↓ 调用 -CRUD Layer (crud/*.py) - ↓ 调用 -Model Layer (models/*.py) - ↓ 调用 -Database Layer (db/*.py) -``` - -**禁止**: -- API 层直接操作 Model -- CRUD 层返回 Schema(应返回 Model) -- Service 层直接操作数据库(应通过 CRUD) -- 在业务代码中写 SQL - -### 前端类型规范 - -| 层级 | 类型来源 | 说明 | -|------|----------|------| -| 后端 Schema | `python-api/app/schemas/*.py` | Pydantic 模型,OpenAPI 生成源 | -| 前端基础类型 | `tauri-app/src/api/types.ts` | 手写的核心类型,与后端对齐 | -| 前端完整类型 | `tauri-app/src/api/generated/schema.ts` | OpenAPI 自动生成,只读 | -| Store 扩展 | `tauri-app/src/store/*.ts` | `extends` 基础类型添加前端字段 | - -### 间距规范 - -前端使用基于 4px 的网格系统,定义在 `tauri-app/src/styles/variables.css`: - -| 变量 | 值 | 使用场景 | -|------|-----|----------| -| `--spacing-2xs` | 2px | 微调控件、边框线 | -| `--spacing-xs` | 4px | 紧凑间隙、图标边距 | -| `--spacing-sm` | 8px | 小间隙、按钮内边距-y | -| `--spacing-md` | 12px | 标准间隙、卡片内边距 | -| `--spacing-lg` | 16px | 大间隙、区块间距 | -| `--spacing-xl` | 24px | 页面区块、内容分隔 | -| `--spacing-2xl` | 32px | 大区块间距、页面边距 | -| `--spacing-3xl` | 48px | 页面级间距、Hero 区域 | - -### Python 代码风格 - -- **格式化**: Black (line-length: 100, target-version: py313) -- **检查**: Ruff (E, F, I, N, W, UP, B, C4, SIM) -- **类型**: MyPy(非严格模式全局,但 `app.schemas.*`、`app.crud.*`、`app.scheduler.handlers.*` 强制严格模式) -- **文档**: 中文注释,Google Style Docstrings -- **安全**: Bandit + pip-audit -- **Git Hooks**: pre-commit(Black、Ruff、uv lock 同步检查) -- **忽略规则**: Ruff 忽略 `E501, E402, N802, N803, N806, N815, B008, B904` - -### TypeScript/React 代码风格 - -- **类型**: 严格 TypeScript 模式(`strict: true`, `noUnusedLocals: true`, `noUnusedParameters: true`) -- **组件**: 函数组件 + Hooks -- **状态**: Zustand 管理全局状态(配合 Immer 处理不可变更新) -- **样式**: 普通 CSS + CSS 变量(`tauri-app/src/styles/variables.css`) -- **ESLint**: 使用 `eslint.config.js`(Flat Config),含 React Hooks 和 React Refresh 规则 -- **Prettier**: semi=true, singleQuote=true, tabWidth=2, printWidth=100, trailingComma=es5 -- **Stylelint**: `stylelint-config-standard`,禁止 magic px 用于 `border-radius` 和 `font-size` -- **ESLint 规则**: `no-console` 为 warn(允许 warn/error/info),`eqeqeq` 为 warn,`no-var` 为 error - -### Rust 代码风格 - -- **格式化**: rustfmt -- **检查**: cargo clippy -- **注释**: 中文文档注释 - -### 提交规范 - -``` -feat: 新功能 -fix: 修复 -docs: 文档 -refactor: 重构 -test: 测试 -chore: 构建/工具 -``` - -### 快速参考 - -| 场景 | 正确做法 | -|------|---------| -| 后端换 AI 模型 | 修改 `services/ai_response_utils.py` 标准化层,不修改 Schema | -| 后端新增字段 | `Optional[T] = Field(None)`,向后兼容 | -| 后端修改字段 | 保留旧字段,标记 deprecated,逐步迁移 | -| 前端需要新字段 | Store 中 `extends` 基础类型 | -| 数据清洗 | **只在** Adapter 层,禁止在组件层 | -| 新增数据库实体 | 创建 Model → CRUD → API(分层开发)| -| 数据库查询 | 在 CRUD 层封装,API 层调用 | -| 事务管理 | API 层控制,通过 `get_db` 依赖注入 | -| 新增提示词 | 创建 `.txt` 文件,使用 `_load_prompt()` 加载 | -| 新增本地文件操作 | 使用 `storage::engine` 原子写入 + 文件锁 | - ---- - -## 测试策略 - -### 后端测试 +### 后端(python-api/) ```bash cd python-api -# 运行所有测试 -pytest -v +# 安装开发依赖(使用 uv) +make dev -# 覆盖率报告 -pytest --cov=app --cov-report=html --cov-report=term +# 启动开发服务器 +make run # uvicorn --reload --port 8000 + +# 启动异步调度器(另开终端) +make scheduler # python -m app.scheduler.main + +# 代码质量 +make lint # ruff check + mypy +make format # black + ruff --fix +make format-check # 检查格式(不修改) +make lint-semantic # 语义层禁词检查(防止供应商术语泄漏) + +# 测试 +make test # pytest -v +make test-cov # pytest + 覆盖率报告 + +# 安全扫描 +make security # bandit + pip-audit + +# CI 全量检查 +make ci # format-check + lint + lint-semantic + test + security + +# Docker 操作 +make docker-run # 启动 api + scheduler(共享外部 db/redis) +make docker-rebuild # 强制重建 +make docker-stop # 停止(保留 db/redis) +make docker-logs # 查看日志 + +# 数据库迁移 +alembic revision --autogenerate -m "描述" +alembic upgrade head ``` -**测试配置** (`pyproject.toml`): -- asyncio_mode = "auto" -- 测试文件命名: `test_*.py` -- 测试路径: `tests/` - -> **注**:当前后端**暂无测试文件**。`pyproject.toml` 已配置测试环境,但 `tests/` 目录尚未创建。 - -### 前端测试 +### 前端(tauri-app/) ```bash cd tauri-app -# 运行 Vitest -npm run test +# 前端开发服务器(Vite,端口 1420) +npm run dev -# UI 模式 -npm run test:ui +# 生产构建(tsc + vite build) +npm run build -# 覆盖率报告 -npm run test:coverage +# Tauri 开发(启动 Rust + 前端) +npm run tauri dev + +# Tauri 生产打包 +npm run tauri build + +# 测试 +npm run test # Vitest +npm run test:ui # Vitest UI 模式 +npm run test:coverage # 覆盖率 + +# 代码质量 +npm run lint # ESLint +npm run lint:fix # ESLint --fix +npm run format # Prettier +npm run format:check # Prettier --check +npm run stylelint # Stylelint +npm run stylelint:fix # Stylelint --fix + +# OpenAPI 类型生成 +npm run gen:api # 根据 openapi.json 生成 TypeScript 类型 ``` -**测试配置**: -- 测试框架: Vitest 4.x + @testing-library/react 16.3.x + jsdom -- 测试文件: `src/**/*.test.ts(x)` -- Mock 配置: `src/__tests__/setup.ts` -- 自动 Mock: localStorage, Tauri API (`@tauri-apps/api/core`) -- 现有测试: - - `src/store/__tests__/authStore.test.tsx` - - `src/store/__tests__/settingsStore.test.tsx` +--- + +## 代码风格与约定 + +### 命名规范 + +| 类型 | 规范 | 示例 | +|------|------|------| +| 组件/页面文件夹 | PascalCase | `VideoCreation/`, `ErrorBoundary/` | +| Store/Hooks/API 文件 | camelCase | `authStore.ts`, `useProjectData.ts` | +| 类型/接口 | PascalCase | `ApiResponse` | +| Python 模块/函数 | snake_case | `script_handler.py`, `get_settings()` | +| Python 类 | PascalCase | `AsyncEngine`, `BaseModel` | +| 常量 | UPPER_SNAKE_CASE | `PYTHON_API_BASE_URL` | + +### 注释语言 + +- 项目内**统一使用中文注释**。 +- 关键架构决策需在代码中以多行注释说明(参考 `python-api/app/scheduler/engine.py` 和 `tauri-app/src/api/client.ts` 顶部注释)。 + +### Python 代码质量 + +- **格式化**: black (line-length: 100) +- **检查**: ruff (select: E, F, I, N, W, UP, B, C4, SIM) +- **类型检查**: mypy(对 `app.schemas.*`, `app.crud.*`, `app.scheduler.handlers.*` 启用严格模式) +- **安全扫描**: bandit + pip-audit +- **依赖管理**: uv(`requirements.lock` 必须与实际依赖同步,pre-commit 会检查) + +### TypeScript 配置 + +- `strict: true` 已开启。 +- `noUnusedLocals: true`、`noUnusedParameters: true` 已开启。 +- `jsx: "react-jsx"`,无需手动引入 `React`。 +- 路径别名 `@/` 映射到 `./src`。 + +### 状态管理约定(前端) + +- 使用 **Zustand + Immer** 进行不可变更新。 +- `projectStore` 使用自定义 `persist` 存储,将项目数据通过 Tauri IPC 持久化到本地文件系统(`app_config_dir/current_project.json`),而不是 localStorage。 +- 其他 Store(如 `authStore`、`settingsStore`)使用 `localStorage` 做持久化。 + +### API 开发流程 + +1. **判断是否需要本地能力**(FFmpeg、文件系统、系统调用)。 +2. **不需要** → 直接在 `tauri-app/src/api/modules/` 使用 `client.get/post/put/delete` 调用 Python HTTP API。 +3. **需要** → 将 endpoint 加入 `tauri-app/src/api/client.ts` 的 IPC 处理逻辑,并在 `tauri-app/src-tauri/src/commands/` 或 `lib.rs` 中实现对应的 `#[tauri::command]` 处理器。 + +### 语义层防护网(后端) + +Makefile 中 `lint-semantic` 目标会检查以下规则: +- API 层禁止使用 `element_id` 作为字段/参数名(应使用 `provider_element_id` 或 `human_id`)。 +- Scheduler 层禁止使用 `task_id` 作为内部变量/Redis key(应使用 `job_id`)。 +- Scheduler 层 Redis key 必须使用 `job:` 前缀而非 `task:`。 --- -## 安全注意事项 +## 测试说明 -1. **SECRET_KEY**: 生产环境必须修改为强随机密钥(`get_settings()` 会在生产环境校验) -2. **CORS**: 生产环境限制为实际前端域名,开发环境 `DEBUG=true` 时允许所有来源 -3. **API Keys**: 不要提交到 Git,使用 `.env` 文件注入 -4. **FFmpeg**: 嵌入的二进制文件需验证来源 -5. **文件上传**: 限制文件类型和大小,防止攻击 -6. **路径遍历**: Rust StorageEngine 的 `sanitize_id()` 和 `sanitize_filename()` 防御路径遍历攻击 -7. **原子写入**: 所有本地 JSON 使用 `atomic_write_json`(先写 `.tmp` 再 `rename`) -8. **文件锁**: 并发 RMW 操作使用 `with_file_lock` 防止竞态 -9. **日志**: 后端日志写入 `~/Documents/Meijiaka-zy/logs/api_YYYYMMDD.log` +### 后端测试 + +- **框架**: pytest + pytest-asyncio(asyncio_mode: auto) +- **覆盖率**: pytest-cov +- **当前状态**: 后端尚无正式的 `tests/` 目录,测试用例待补充。 + +### 前端测试 + +- **框架**: Vitest(globals: true,environment: `jsdom`) +- **组件测试**: `@testing-library/react` + `@testing-library/jest-dom` +- **文件位置**: + - 全局 setup: `src/__tests__/setup.ts` + - Store 测试: `src/store/__tests__/*.test.tsx` + - 组件/页面测试: 建议放在被测文件同目录或 `__tests__` 子目录中 +- **Mock 策略**: `setup.ts` 中已全局 mock `localStorage`、`@tauri-apps/api/core` 的 `invoke` 方法、`window.__TAURI_INTERNALS__`。每个测试后自动调用 `vi.clearAllMocks()`。 --- -## 部署流程 +## 安全与部署 -### GitLab CI +### 后端安全 -项目使用 `.gitlab-ci.yml` 配置自动化部署: +- **JWT 认证**: `SECRET_KEY` 生产环境必须设置为强随机字符串(至少 32 位),否则应用启动时会抛出 `ValueError`。 +- **CORS**: 生产环境若包含 `localhost` 会触发 `RuntimeWarning`。 +- **依赖安全**: `aiohttp>=3.13.4` 和 `orjson>=3.11.0` 为强制最低版本(修复 CVE)。 +- **输入验证**: 所有 API 入参通过 Pydantic Schema 校验。 +- **数据库**: 使用参数化查询(SQLAlchemy ORM),无直接 SQL 拼接。 -- **触发条件**: `master` 分支推送 -- **部署步骤**: - 1. 在服务器项目目录拉取最新代码 - 2. 使用 `docker-compose.test.yml` 构建 `api` 和 `scheduler` 镜像(`--no-cache`) - 3. 启动容器 - 4. 清理 7 天前的旧镜像 - 5. 健康检查:`curl -sf http://localhost:8081/api/v1/system/health` -- **部署标签**: `deploy` Runner +### 前端安全 -### Docker 环境说明 +- **CSP**: `tauri.conf.json` 中已配置 Content Security Policy。 +- **Asset Protocol**: 已启用,范围限定在 `$APPLOCALDATA/**`、`$APPDATA/**`、`$APPCONFIG/**`。 -- **开发环境** (`docker-compose.yml`): 端口映射 8081:8000,热重载,卷挂载代码 -- **测试环境** (`docker-compose.test.yml`): 用于 CI/CD 部署 -- **生产环境** (`docker-compose.prod.yml`): 生产配置 -- **基础设施**: PostgreSQL 和 Redis 由外部 `docker-infra` 项目管理,通过 `meijiaka-network` 外部网络共享 +### 部署流程 + +#### 测试环境(GitLab CI) + +`.gitlab-ci.yml` 定义了 `deploy-backend` 任务: +1. 在部署服务器拉取代码(`master` 分支触发)。 +2. 构建 api + scheduler 镜像(`docker-compose.test.yml`)。 +3. 启动服务,健康检查 `curl http://localhost:8081/api/v1/system/health`。 +4. 清理 7 天前的旧镜像。 + +#### 生产环境 + +1. 从环境变量或密钥管理注入配置。 +2. `docker compose -f docker-compose.prod.yml up -d --build` +3. 外部提供 PostgreSQL 和 Redis(云数据库或自建集群)。 +4. Nginx 反向代理 + acme.sh SSL 证书。 + +### 外部二进制与 Sidecar + +- **FFmpeg / ffprobe** 作为 **sidecar** 打包到 Tauri 应用(`bundle.externalBin`)。 +- Rust 层通过 `tauri_plugin_shell` 的 `sidecar("ffmpeg")` 调用。 +- 合成过程中解析 FFmpeg stderr 中的 `time=` 字段,通过 Tauri Event 向前端发送进度。 --- -## 配置说明 +## 环境变量(后端) -### Python 后端 (.env) +关键配置项见 `python-api/.env.example`: -关键环境变量(完整模板见 `python-api/.env.example`): - -```bash -# 数据库 (PostgreSQL) -DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka_zy - -# Redis -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_DB=1 - -# JWT 密钥(生产环境必须修改) -SECRET_KEY=your-secret-key-here-change-in-production -ACCESS_TOKEN_EXPIRE_MINUTES=10080 - -# AI API Keys -VOLCENGINE_API_KEY=your-volcengine-key -VOLCENGINE_CAPTION_APPID=your-caption-appid -VOLCENGINE_CAPTION_TOKEN=your-caption-token -KLINGAI_ACCESS_KEY=your-kling-access-key -KLINGAI_SECRET_KEY=your-kling-secret-key -MINIMAX_API_KEY=sk-api-your-minimax-key -VIDU_API_KEY=your-vidu-api-key - -# 七牛云存储 -QINIU_ACCESS_KEY=your-qiniu-ak -QINIU_SECRET_KEY=your-qiniu-sk - -# AnyToCopy 文案提取 -ANYTOCOPY_API_KEY=your-anytocopy-key -ANYTOCOPY_API_SECRET=your-anytocopy-secret -``` - -### Tauri 前端配置 - -- **产品标识**: `cn.meijiaka.ai-jian` -- **版本**: `0.1.0` -- **窗口**: 1440×960,最小 960×640,可调整大小 -- **Vite 端口**: 1420(`strictPort: true`) -- **Python API 地址**: `http://127.0.0.1:8081/api/v1` -- **Asset Protocol**: 已启用,允许访问 `$APPLOCALDATA/**`, `$APPDATA/**`, `$APPCONFIG/**`, `/**` +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `DATABASE_URL` | PostgreSQL 连接字符串 | `postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka_zy` | +| `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` | Redis 连接信息 | `localhost:6379:0` | +| `SECRET_KEY` | JWT 签名密钥 | 必须修改 | +| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:1420,...` | +| `VOLCENGINE_API_KEY` | 火山方舟 API Key | - | +| `VIDU_API_KEY` | Vidu API Key | - | +| `QINIU_ACCESS_KEY` / `QINIU_SECRET_KEY` | 七牛云存储密钥 | - | +| `APP_BASE_URL` | 应用公网地址(第三方回调用) | 按 ENV 自动推断 | --- -## 关键文件速查 +## 数据库与迁移 -| 文件 | 用途 | -|------|------| -| `python-api/app/main.py` | FastAPI 应用入口 | -| `python-api/app/config.py` | Pydantic Settings 配置管理 | -| `python-api/app/api/v1/router.py` | API 路由聚合 | -| `python-api/app/scheduler/main.py` | Async Engine 调度器入口 | -| `python-api/app/core/token_manager.py` | API Token 缓存与自动刷新 | -| `python-api/config/ai_models.yaml` | AI 模型配置(热重载)| -| `tauri-app/src/api/client.ts` | 智能路由 API 客户端 | -| `tauri-app/src/store/projectStore.ts` | 项目状态管理 | -| `tauri-app/src-tauri/src/lib.rs` | Rust 命令注册 | -| `tauri-app/src-tauri/src/storage/engine.rs` | 核心存储引擎 | -| `tauri-app/src-tauri/src/ffmpeg_cmd.rs` | FFmpeg 命令封装 | -| `tauri-app/src-tauri/tauri.conf.json` | Tauri 应用配置 | -| `tauri-app/vite.config.ts` | Vite 构建配置 | +- **ORM**: SQLAlchemy 2.0(异步,asyncpg 驱动) +- **迁移工具**: Alembic +- **基础模型**: `app.models.base.BaseModel` 提供 UUID 主键、`created_at`、`updated_at` +- **当前模型**: `User`(用户/设备认证) +- **迁移注意**: Alembic 使用同步连接(psycopg2),会自动将 `+asyncpg` 替换掉。 --- -## 附加文档 +## 异步任务调度(Async Engine) -项目 `docs/` 目录包含详细的深度开发文档: +后端采用自研的**统一异步作业调度引擎**,核心概念: -| 文档 | 主题 | -|------|------| -| `docs/unified-async-scheduler.md` | 统一异步调度器设计 | -| `docs/volcengine-video-caption-api.md` | 火山引擎字幕 API 对接 | -| `docs/kling-api-dev.md` | Kling AI API 开发文档 | -| `docs/vidu-tts-api.md` | Vidu TTS API 集成 | -| `docs/minimax-api-dev.md` | MiniMax API 开发文档 | -| `docs/anytocopy-api.md` | AnyToCopy API 集成 | -| `docs/anytocopy-integration.md` | AnyToCopy 服务集成说明 | -| `docs/qiniu-kodo-python-sdk-guide.md` | 七牛云 Kodo Python SDK 指南 | -| `docs/app-update-system.md` | 应用更新系统设计 | -| `docs/database-design.md` | 数据库设计文档 | -| `docs/mvp-lip-sync-replacement.md` | MVP 对口型替换方案 | -| `docs/video-generation-data-flow.md` | 视频生成数据流 | +- **JobRegistry**: 基于 Redis 的作业注册表,维护 running / pending / finished 状态。 +- **SlotManager**: 基于 Redis 的并发槽位管理,按 job_type 限制最大并发数。 +- **AsyncHandler**: 各领域的异步任务处理器(如 `script_handler.py`, `subtitle_handler.py`)。 +- **Engine Tick**: 定时轮询所有 running 作业,批量查询状态、批量更新。 + +调度器需单独启动:`make scheduler` 或 `python -m app.scheduler.main`。 + +--- + +## 给 Agent 的快速检查清单 + +在修改代码前,建议确认以下事项: + +1. **新增 API 是否需要 Rust 层?** 不需要则只改前端 `src/api/modules/` 和后端 `app/api/v1/`。 +2. **修改 Store 后是否影响持久化?** `projectStore` 的 `partialize` 字段决定哪些状态会被保存到本地文件。 +3. **新增组件是否遵循 PascalCase 文件夹约定?** +4. **后端代码是否触发语义层禁词?** 运行 `make lint-semantic`。 +5. **依赖变更后是否更新了 lock 文件?** 运行 `uv pip compile pyproject.toml -o requirements.lock`。 +6. **测试是否通过?** 前端运行 `npm run test`,后端运行 `make test`。 +7. **Tauri 配置变更后是否需要重新 `tauri dev`?** 是的,`tauri.conf.json` 或 `Cargo.toml` 变更后需重启 Tauri 进程。 +8. **修改 pyproject.toml 后 pre-commit 是否通过?** `requirements.lock` 必须与 pyproject.toml 同步。 diff --git a/docs/third-party-integration-architecture.md b/docs/third-party-integration-architecture.md new file mode 100644 index 0000000..c5f4d34 --- /dev/null +++ b/docs/third-party-integration-architecture.md @@ -0,0 +1,704 @@ +# 第三方平台接入架构设计方案(最终版) + +> 版本:v1.0 Final +> 适用范围:`python-api/` 所有第三方服务接入层 +> 生效日期:2026-05-02 + +--- + +## 一、设计目标 + +| 目标 | 验收标准 | +|------|---------| +| 新增平台接入成本 < 30 分钟 | 提供 Adapter 模板,复制粘贴后填充 4 个方法即可 | +| 第三方故障不拖垮用户 | 单点故障时,用户 100ms 内收到明确错误,而非超时 30 秒 | +| 多用户同时使用无冲突 | 5 个用户同时生成 TTS/脚本时,不触发第三方 429 限流 | +| 任务状态可追踪 | 用户关闭应用后重开,能恢复进行中的对口型/字幕任务 | +| 未来换平台无感知 | 换 TTS 供应商时,前端接口、存储层、用户历史记录全部无感知 | + +--- + +## 二、整体架构 + +``` +┌──────────────────────────────────────────────┐ +│ Router(FastAPI) │ +│ - 校验输入、序列化输出 │ +│ - 统一错误中间件 │ +│ - 不处理重试、不限流、不直接调第三方 │ +│ - 回调入口:/webhooks/{platform} │ +├──────────────────────────────────────────────┤ +│ Application Service │ +│ - ScriptService:编排脚本→TTS→对口型 │ +│ - VideoService:编排字幕→合成 │ +│ - 只操作领域对象,不感知平台差异 │ +├──────────────────────────────────────────────┤ +│ Gateway Layer │ +│ ┌─────────────────┐ ┌──────────────────┐ │ +│ │ LLM Gateway │ │ Task Gateway │ │ +│ │ - 模型路由 │ │ - 任务状态机 │ │ +│ │ - Fallback │ │ - 轮询调度 │ │ +│ │ - 流式代理 │ │ - 回调处理 │ │ +│ └────────┬────────┘ └────────┬─────────┘ │ +│ │ │ │ +│ └────────────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ Shared Infra │ │ +│ │ - Token Bucket │ │ +│ │ - CircuitBreaker│ │ +│ │ - stamina retry │ │ +│ │ - Structured Log│ │ +│ └────────┬────────┘ │ +├────────────────────┼─────────────────────────┤ +│ Adapter Layer │ │ +│ - VolcengineArkAdapter │ +│ - OpenAIAdapter │ +│ - ViduAdapter │ +│ - VolcengineCaptionAdapter │ +│ - MockAdapter │ +│ 每个 Adapter:Protocol 约定,无状态,可替换 │ +├──────────────────────────────────────────────┤ +│ Transport Layer │ +│ - httpx.AsyncClient(所有 Raw HTTP) │ +│ - 官方 SDK(仅 LLM 层:AsyncArk/AsyncOpenAI)│ +│ - lifespan 显式创建、显式关闭 │ +└────────────────────────────────────────────────┘ +``` + +--- + +## 三、分层设计 + +### 3.1 Adapter 层 + +**职责**:纯翻译。把内部标准请求 ↔ 供应商特定请求,内部标准响应 ↔ 供应商特定响应。 + +**不职责**:重试、限流、业务逻辑、状态管理。 + +**Protocol 约定**: + +```python +class LLMAdapter(Protocol): + platform_id: str + async def chat(self, messages, model, **params) -> AdapterResponse: ... + async def chat_stream(self, messages, model, **params): ... # AsyncIterator + async def health(self) -> AdapterResponse: ... + async def close(self) -> None: ... + +class TaskAdapter(Protocol): + platform_id: str + async def submit(self, task_type, payload, callback_url) -> AdapterResponse: ... + async def query(self, platform_task_id) -> TaskStatus: ... + async def parse_callback(self, body) -> TaskStatus: ... + async def verify_signature(self, headers, body, secret) -> bool: ... + async def extract_nonce(self, headers) -> str | None: ... + async def health(self) -> AdapterResponse: ... + async def close(self) -> None: ... +``` + +**AdapterResponse 标准格式**: + +```python +@dataclass(frozen=True) +class AdapterResponse: + success: bool + data: dict | None = None + error_code: str | None = None + error_message: str | None = None + retryable: bool = False # Gateway 据此决定是否重试 +``` + +**TaskStatus 标准格式**: + +```python +@dataclass(frozen=True) +class TaskStatus: + task_id: str # 供应商 task_id + state: str # "pending" | "processing" | "completed" | "failed" + result: dict | None = None + error_message: str | None = None +``` + +**Client 统一**: +- 所有 Raw HTTP 用 `httpx.AsyncClient`。 +- LLM 官方 SDK(AsyncArk、AsyncOpenAI)保留,但 lifespan shutdown 时显式 `close()`。 +- 每个 Adapter 独立 Client,独立连接池,互不干扰。 + +--- + +### 3.2 Gateway 层 + +#### 3.2.1 LLM Gateway + +```python +class LLMGateway: + def __init__(self, adapters: dict[str, LLMAdapter], runtime_config: GatewayRuntimeConfig): + self.adapters = adapters + self.config = runtime_config + + async def chat(self, model_id, messages, **params) -> dict: + # 1. 路由到 Adapter + # 2. 主模型失败时 Fallback + # 3. 流式中途失败不再 Fallback +``` + +**Fallback 规则**: +- 配置驱动,`runtime_config.ark_fallback_chain` +- 流式中途失败 → 立即抛异常,不降级(避免内容混合) +- 同步调用失败 → 按链降级,对用户透明 + +#### 3.2.2 Task Gateway + +```python +class TaskGateway: + def __init__(self, adapters, storage, runtime_config): + self.adapters = adapters + self.storage = storage # Redis + self.config = runtime_config + self.circuit = CircuitBreaker() + + async def submit(self, platform_id, task_type, payload, callback_url=None) -> str: + # 1. 限流检查 + # 2. 熔断检查 + # 3. Adapter.submit() → 获取 platform_task_id + # 4. 生成 internal_task_id (UUID) + # 5. Redis 存储映射 + # 6. 返回 internal_task_id + + async def query(self, internal_task_id) -> TaskStatus: + # 1. 查 Redis 映射 + # 2. 非终态时穿透供应商查询(可选) + # 3. 更新 Redis + + async def handle_webhook(self, platform, headers, body, query): + # 1. nonce 防重放检查 + # 2. Adapter.verify_signature() + # 3. Adapter.parse_callback() + # 4. 更新任务状态 +``` + +**内部 ID 隔离**: + +```python +# Redis 存储结构 +task:{internal_task_id} -> { + "platform_id": "vidu", + "platform_task_id": "vidu_abc123", + "task_type": "lip_sync", + "state": "processing", + "submitted_at": "2026-05-02T12:00:00Z" +} +TTL: 3600 秒(1 小时) +``` + +**轮询调度器**(火山字幕示例): + +```python +async def poll_until_complete(self, internal_task_id, max_wait=120): + intervals = [0, 1, 2, 4, 8, 8, 10] # 非阻塞阶段 + + for interval in intervals: + await asyncio.sleep(interval) + status = await self.query(internal_task_id) + if status.state == "completed": + return status + if status.state == "failed": + raise TaskError(status.error_message) + + # 切换 blocking 阶段 + while elapsed < max_wait: + status = await self._query_with_blocking(internal_task_id) + if status.state in ("completed", "failed"): + return status + + raise TaskError("任务超时") +``` + +#### 3.2.3 Shared Infra + +**Token Bucket**(内存级,`aiolimiter`): + +```python +vidu_limiter = AsyncLimiter(max_rate=20, time_period=1.0) # 20/s +caption_limiter = AsyncLimiter(max_rate=2, time_period=1.0) # 2/s +ark_limiter = AsyncLimiter(max_rate=50, time_period=1.0) # 50/s +``` + +**CircuitBreaker**: + +```python +class CircuitBreaker: + failure_threshold: int = 5 # 连续失败 5 次熔断 + recovery_timeout: float = 60.0 # 60 秒后探测恢复 +``` + +**Retry Policy**(`stamina`): + +```python +with stamina.retry_context( + on=(httpx.NetworkError, httpx.TimeoutException), + attempts=3, + timeout=30.0, + wait_initial=1.0, + wait_max=10.0, +): + await adapter.submit(...) +``` + +--- + +### 3.3 Application Service 层 + +**职责**:编排业务流程,不感知平台差异。 + +```python +class ScriptService: + async def generate_script(self, category, subcategory, duration): + # 调用 LLM Gateway,不关心底层是火山方舟还是 OpenAI + result = await llm_gateway.chat( + model_id="doubao-seed-2-0-pro", + messages=[...], + temperature=0.7, + ) + return self._parse_shots(result.data["content"]) + +class VideoService: + async def submit_lip_sync(self, video_url, audio_url): + # 调用 Task Gateway,不关心底层是 Vidu 还是 HeyGen + task_id = await task_gateway.submit( + platform_id="vidu", + task_type="lip_sync", + payload={"video_url": video_url, "audio_url": audio_url}, + callback_url=f"{settings.app_base_url}/webhooks/vidu", + ) + return task_id +``` + +--- + +### 3.4 Router 层 + +**职责**:HTTP 语义转换,参数校验,统一返回格式。 + +**统一错误中间件**: + +```python +@app.exception_handler(PlatformError) +async def platform_error_handler(request, exc: PlatformError): + status = 502 if exc.retryable else 400 + return JSONResponse( + status_code=status, + content={ + "code": exc.status_code or 500, + "message": str(exc), + "data": None, + "detail": { + "platform": exc.platform, + "retryable": exc.retryable, + } if settings.DEBUG else None, + }, + ) +``` + +**回调入口**: + +```python +@router.post("/webhooks/{platform}") +async def universal_webhook(platform: str, request: Request): + raw_headers = dict(request.headers) + raw_body = await request.body() + query_params = dict(request.query_params) + + await task_gateway.handle_webhook( + platform=platform, + headers=raw_headers, + body=raw_body, + query=query_params, + original_path=request.url.path, + ) + return {"received": True} +``` + +--- + +## 四、核心数据流 + +### 4.1 TTS 语音合成(同步调用) + +``` +用户点击"生成配音" + ↓ +POST /voice/synthesize + ↓ +Router 校验参数 + ↓ +ViduService.synthesize(text, voice_id...) + ↓ +LLM Gateway.call_sync(platform="vidu", method="tts", ...) + ↓ +Token Bucket 取令牌(rate=20/s) + ↓ +stamina 重试网络错误(最多3次) + ↓ +ViduAdapter.call(method="tts", ...) + ↓ +httpx.AsyncClient → Vidu API + ↓ +返回音频 URL +``` + +**异常路径**: +- 网络错误 → stamina 重试 → 3 次失败后抛 PlatformError(retryable=True) → 502 +- Vidu 返回 400 → PlatformError(retryable=False) → 400 +- Vidu 返回 500 → PlatformError(retryable=True) → 502 + +### 4.2 脚本生成 SSE(流式调用) + +``` +用户点击"生成脚本" + ↓ +POST /script/generate/stream + ↓ +ScriptService.generate_script_stream(...) + ↓ +LLM Gateway.chat_stream(model_id="doubao-seed-2-0-pro", ...) + ↓ +VolcengineArkAdapter.chat_stream(...) + ↓ +SSE 流式输出 +``` + +**关键约束**:流式中途失败**不降级**。已输出内容保持不变,前端收到 error 事件后自行处理。 + +### 4.3 对口型任务提交(异步任务) + +``` +用户点击"生成对口型" + ↓ +POST /vidu/lip-sync + ↓ +VideoService.submit_lip_sync(...) + ↓ +Task Gateway.submit(platform="vidu", task_type="lip_sync", ...) + ↓ +Token Bucket 取令牌(rate=5/s) + ↓ +CircuitBreaker 检查 + ↓ +ViduAdapter.submit(method="lip_sync", ...) + ↓ +返回 platform_task_id + ↓ +生成 internal_task_id (UUID) + ↓ +Redis 存储映射 + ↓ +返回 {task_id: internal_task_id} +``` + +### 4.4 字幕生成轮询(异步任务 + 轮询) + +``` +用户点击"生成字幕" + ↓ +POST /caption/generate + ↓ +CaptionService.generate_caption(...) + ↓ +Task Gateway.submit(platform="volcengine_caption", ...) + ↓ +返回 internal_task_id + ↓ +前端轮询 /tasks/{id}/status + ↓ +Gateway.query → Redis 命中 → 返回 + ↓ +Redis 未命中或 state=processing → 穿透供应商查询 → 更新 Redis +``` + +**轮询策略**(火山字幕): +- 第 1 次:t=0s,blocking=0 +- 第 2 次:t=1s,blocking=0 +- 第 3 次:t=3s,blocking=0 +- 第 4 次起:t=7s, 12s, 17s...,blocking=1 + +### 4.5 回调处理(Vid 对口型完成) + +``` +Vidu 服务器 POST /webhooks/vidu + ↓ +Router 提取 headers / body / query + ↓ +Task Gateway.handle_webhook(...) + ↓ +1. ViduAdapter.extract_nonce(headers) → nonce + → Redis 查 nonce 是否已用 + → 已用 → 401 +2. ViduAdapter.verify_signature(headers, body, secret) + → 失败 → 401 +3. Redis 标记 nonce 已用(TTL 300s) +4. ViduAdapter.parse_callback(body) → TaskStatus +5. Redis 更新任务状态 +``` + +--- + +## 五、并发控制 + +### 5.1 三层隔离模型 + +``` +┌─────────────────────────────────────────┐ +│ 第一层:任务层(Slot Scheduler) │ +│ 控制"同时有多少个异步任务在执行" │ +│ - 火山字幕:max 5 │ +│ - 对口型:按 Vidu 配额配置 │ +│ - 脚本生成:max 10 │ +├─────────────────────────────────────────┤ +│ 第二层:请求层(Gateway Token Bucket) │ +│ 控制"每秒向某平台发多少请求" │ +│ - Vidu TTS:20/s │ +│ - Vidu 对口型提交:5/s │ +│ - 火山方舟:50/s │ +│ - 火山字幕提交:2/s │ +├─────────────────────────────────────────┤ +│ 第三层:连接层(HTTP Client Pool) │ +│ 控制"同时保持多少条 TCP 连接" │ +│ - Vidu:max 20 │ +│ - 火山字幕:max 10 │ +│ - 火山方舟:SDK 内部管理 │ +└─────────────────────────────────────────┘ +``` + +### 5.2 流式连接单独计数 + +```python +# LLM Gateway 内 +active_streams: dict[str, int] = {} # {platform: count} + +# 流式上限 +MAX_STREAMS = { + "volcengine_ark": 30, + "openai": 30, +} +``` + +--- + +## 六、错误处理 + +### 6.1 异常类 + +```python +class PlatformError(Exception): + """第三方平台调用失败""" + def __init__(self, message, *, platform: str, retryable: bool = False, status_code: int | None = None): + super().__init__(message) + self.platform = platform + self.retryable = retryable + self.status_code = status_code + +class TaskError(Exception): + """任务生命周期错误""" + pass + +class LLMError(Exception): + """LLM 调用失败(含 Fallback 耗尽)""" + pass +``` + +### 6.2 HTTP 状态码映射 + +| 场景 | PlatformError 属性 | HTTP 状态码 | +|------|-------------------|------------| +| 网络超时、DNS 失败、5xx | `retryable=True` | 502 Bad Gateway | +| 供应商限流 429 | `retryable=True` | 429 Too Many Requests | +| 认证失败 401/403 | `retryable=False` | 401 Unauthorized | +| 参数错误 400 | `retryable=False` | 400 Bad Request | +| 业务逻辑错误(state=failed) | `retryable=False` | 400 Bad Request | + +### 6.3 全局响应格式 + +```json +{ + "code": 0, + "message": "成功", + "data": {} +} +``` + +错误时: + +```json +{ + "code": 500, + "message": "Vidu TTS 服务暂不可用", + "data": null, + "detail": { + "platform": "vidu", + "retryable": true + } +} +``` + +--- + +## 七、配置规范 + +### 7.1 嵌套配置模型 + +```python +class ViduConfig(BaseModel): + api_key: str = "" + base_url: str = "https://api.vidu.com" + max_connections: int = 20 + timeout: float = 30.0 + +class RuntimeConfig(BaseModel): + """运行时配置,支持热重载""" + vidu_qps: float = 20.0 + vidu_burst: int = 30 + ark_fallback_chain: list[str] = ["doubao-seed-2-0-lite"] + caption_poll_intervals: list[float] = [1.0, 1.0, 2.0, 2.0, 4.0, 4.0, 8.0, 8.0, 10.0] + circuit_failure_threshold: int = 5 + circuit_recovery_timeout: float = 60.0 + +class Settings(BaseSettings): + vidu: ViduConfig = Field(default_factory=ViduConfig) + volcengine_ark: VolcengineArkConfig = Field(default_factory=VolcengineArkConfig) + volcengine_caption: VolcengineCaptionConfig = Field(default_factory=VolcengineCaptionConfig) + openai: OpenAIConfig = Field(default_factory=OpenAIConfig) + runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) + + model_config = SettingsConfigDict( + env_nested_delimiter="__", + ) +``` + +### 7.2 `.env` 示例 + +```bash +# === 启动配置(改后需重启)=== +VIDU__API_KEY=sk-xxx +VIDU__BASE_URL=https://api.vidu.com +VIDU__MAX_CONNECTIONS=20 + +VOLCENGINE_ARK__API_KEY=ak-xxx +VOLCENGINE_CAPTION__APPID=app-xxx +VOLCENGINE_CAPTION__TOKEN=tk-xxx + +OPENAI__API_KEY=sk-xxx + +# === 运行时配置(改后可热载)=== +RUNTIME__VIDU__QPS=20 +RUNTIME__VIDU__BURST=30 +RUNTIME__ARK__FALLBACK_CHAIN=doubao-seed-2-0-lite,doubao-lite-32k +RUNTIME__CAPTION__POLL_INTERVALS=1,1,2,2,4,4,8,8,10 +RUNTIME__CIRCUIT__FAILURE_THRESHOLD=5 +``` + +### 7.3 热重载 API + +```python +@router.post("/admin/runtime-config") +async def reload_runtime_config(updates: dict): + gateway_registry.update_runtime_config(**updates) + return {"updated": list(updates.keys())} +``` + +--- + +## 八、日志与可观测性 + +### 8.1 结构化日志字段 + +```python +{ + "event": "platform_call", + "platform": "vidu", + "method": "tts_sync", + "task_type": "tts", + "duration_ms": 1250, + "success": true, + "http_status": 200, + "retry_count": 0, +} +``` + +### 8.2 脱敏规则 + +| 级别 | 字段 | 生产环境处理 | +|------|------|------------| +| P1 | `api_key`, `authorization`, `x-hmac-signature` | `[REDACTED]` | +| P2 | `audio_url`, `video_url`, `text` | URL 去签名参数 / 文案截断前 30 字 | +| P3 | `platform_task_id`, `internal_task_id` | 前缀保留 8 字符 | +| P4 | `duration_ms`, `http_status`, `retry_count` | 完整保留 | + +### 8.3 健康检查端点 + +```python +@router.get("/system/platform-health") +async def platform_health(): + results = {} + for pid, adapter in registry.adapters.items(): + resp = await adapter.health() + results[pid] = { + "available": resp.success, + "error": resp.error_message, + } + return results +``` + +--- + +## 九、迁移策略 + +### 9.1 迁移原则 + +- **新旧代码并行**:通过 flag 切换,可随时回滚 +- **逐个平台迁移**:Vidu → 火山字幕 → LLM +- **前端无感知**:Router URL、请求体、响应体不变 + +### 9.2 Flag 切换机制 + +```python +# Router 层 +USE_NEW_VIDU = settings.FEATURE_FLAGS.get("new_vidu_adapter", False) + +@router.post("/voice/synthesize") +async def synthesize(request: TTSSynthesizeRequest): + if USE_NEW_VIDU: + service = get_vidu_service_v2() # 新架构 + else: + service = get_vidu_service() # 旧代码 + ... +``` + +### 9.3 迁移 Checklist + +| 步骤 | 动作 | 验证 | +|------|------|------| +| 1 | 新建 `ViduAdapterV2`,实现 Protocol | 单元测试通过 | +| 2 | 注册到 Gateway,flag 关闭 | 不影响线上 | +| 3 | 测试环境开启 flag,全量回归 | 所有 Vidu 接口正常 | +| 4 | 生产灰度 10% → 50% → 100% | 监控 error rate | +| 5 | 旧代码保留 1 周后删除 | 无回滚需求 | + +--- + +## 十、附录:最终决策清单 + +| # | 决策项 | 结论 | +|---|--------|------| +| 1 | 回调验签位置 | **C**:Router 提取纯数据 → Gateway 调度 → Adapter 验签 | +| 2 | 任务结果保留 | 实时反映,Redis 映射 TTL = 1h | +| 3 | 七牛云 | 不纳入新架构 | +| 4 | SSE 断线 | 不支持续传 | +| 5 | MockAdapter | 仅 `DEBUG=True` 时注册 | +| 6 | 配置热重载 | **B**:限流参数 + Fallback 链可热载,Adapter 需重启 | +| 7 | 日志脱敏 | 四级分级(P1/P2/P3/P4)+ 四档环境 + URL 智能剥离 | +| 8 | 火山字幕轮询 | **B**:前 3 次非阻塞(0→1→3s)+ 后切换 `blocking=1` | +| 9 | 迁移策略 | **C**:适配器层先行,flag 切换 | +| 10 | API Key | 手动维护 | +| 11 | 任务状态持久化 | **C**:Redis 开启 AOF 持久化 | diff --git a/docs/third-party-integration-implementation-plan.md b/docs/third-party-integration-implementation-plan.md new file mode 100644 index 0000000..e4de091 --- /dev/null +++ b/docs/third-party-integration-implementation-plan.md @@ -0,0 +1,532 @@ +# 第三方平台接入架构标准化实施计划 + +> 版本:v1.0 +> 依据:third-party-integration-architecture.md(架构设计最终版) +> 目标:统一异常体系、Adapter 契约、HTTP Client 生命周期、异步任务状态机、配置分层 +> 生效日期:2026-05-03 + +--- + +## 一、总则 + +### 1.1 实施原则 + +- **标准优先**:以行业主流做法为准,不因"改动小"而妥协 +- **文档先行**:所有变更必须在此文档中登记,实施完成后逐项核对 +- **标准优先**:存量代码直接按标准改造,不做兼容包装层 +- **渐进验证**:每阶段完成后运行测试,确认无回归再进入下一阶段 + +### 1.2 不适用本标准的例外 + +- 七牛云存储(纯上传下载,不纳入 Adapter 体系) + +--- + +## 二、五条铁律规范(实施标准) + +### 铁律 1:异常出口唯一 + +**规范内容**:所有 `app/services/`、`app/ai/` 下的代码,对外抛出的异常必须是 `PlatformError`。Router 只 `except PlatformError` 和 `AppException`(业务错误)。 + +**具体标准**: + +```python +# app/core/exceptions.py —— 唯一的第三方异常类 +class PlatformErrorType: + RATE_LIMIT = "rate_limit" + AUTH_FAILED = "auth_failed" + TIMEOUT = "timeout" + SERVER_ERROR = "server_error" + BAD_REQUEST = "bad_request" + QUOTA_EXHAUSTED = "quota_exhausted" + NOT_FOUND = "not_found" + UNKNOWN = "unknown" + +class PlatformError(Exception): + def __init__( + self, + message: str, + *, + platform: str, + retryable: bool = False, + error_type: str = PlatformErrorType.UNKNOWN, + status_code: int | None = None, + ): + super().__init__(message) + self.platform = platform + self.retryable = retryable + self.error_type = error_type + self.status_code = status_code +``` + +**HTTP 状态码映射**(全局中间件): + +| error_type | retryable | HTTP 状态码 | +|-----------|-----------|------------| +| rate_limit | True | 429 | +| timeout | True | 504 | +| server_error | True | 502 | +| auth_failed | False | 401 | +| bad_request | False | 400 | +| quota_exhausted | False | 429(带 Retry-After) | +| unknown | False | 400 | + +**禁止事项**: +- [ ] `app/services/` 和 `app/ai/` 中禁止 `raise HTTPException` +- [ ] `app/services/` 和 `app/ai/` 中禁止裸 `raise Exception(...)` +- [ ] 各 Router 中禁止 `except Exception: raise HTTPException(500, ...)` 处理第三方错误 + +--- + +### 铁律 2:Adapter 最小契约 + +**规范内容**:每个新平台必须实现 `PlatformAdapter`(`platform_id` + `health()` + `close()`)。按需实现 `SyncCapable`(同步调用)或 `TaskCapable`(异步任务)。 + +**Protocol 定义**: + +```python +# app/ai/adapters/base.py + +@runtime_checkable +class PlatformAdapter(Protocol): + """所有 Adapter 的准入门槛""" + platform_id: str + async def health(self) -> AdapterResponse: ... + async def close(self) -> None: ... + +@runtime_checkable +class SyncCapable(Protocol): + """同步调用能力(TTS、Chat、图片生成等)""" + async def call(self, method: str, payload: dict) -> AdapterResponse: ... + +@runtime_checkable +class TaskCapable(Protocol): + """异步任务能力(对口型、字幕、视频生成等)""" + async def submit(self, task_type: str, payload: dict, callback_url: str | None) -> AdapterResponse: ... + async def query(self, platform_job_id: str) -> TaskStatus: ... + +@runtime_checkable +class CallbackCapable(Protocol): + """回调验签能力(可选)""" + async def verify_signature(self, headers: dict, body: bytes, secret: str) -> bool: ... + async def parse_callback(self, body: bytes) -> TaskStatus: ... +``` + +**统一返回值**: + +```python +@dataclass(frozen=True) +class AdapterResponse: + success: bool + data: dict | None = None + error_code: str | None = None + error_message: str | None = None + retryable: bool = False + +@dataclass(frozen=True) +class TaskStatus: + state: str # "pending" | "processing" | "completed" | "failed" + result: dict | None = None + error_message: str | None = None +``` + +**方法标识常量**: + +```python +# app/ai/adapters/constants.py +class Method: + TTS = "tts" + CHAT = "chat" + CHAT_STREAM = "chat_stream" + IMAGE_GENERATE = "image_generate" + LIP_SYNC = "lip_sync" + CAPTION = "caption" + AUTO_ALIGN = "auto_align" +``` + +**各平台对号入座**: + +| 平台 | 必须实现 | 当前状态 | +|-----|---------|---------| +| 火山方舟 | `PlatformAdapter + SyncCapable` | ❌ 缺失,需新建 `VolcengineArkAdapter` | +| Vidu | `PlatformAdapter + SyncCapable + TaskCapable + CallbackCapable` | ❌ 缺失,需新建 `ViduAdapter` | +| 火山字幕 | `PlatformAdapter + TaskCapable` | ❌ 缺失,需新建 `VolcengineCaptionAdapter` | + +**禁止事项**: +- [ ] 新增平台不实现 `PlatformAdapter` 直接接入 +- [ ] Adapter 内部抛出的异常不是 `PlatformError` +- [ ] `call()` 方法返回裸 `dict` 而不是 `AdapterResponse` + +--- + +### 铁律 3:HTTP Client 统一关闭 + +**规范内容**:所有对外 HTTP 连接(`httpx.AsyncClient` 或 SDK 内部)必须在 `lifespan` 中创建和销毁。禁止在方法内临时创建 `httpx.AsyncClient()`。 + +**具体标准**: + +```python +# lifespan 中的标准写法 +@asynccontextmanager +async def lifespan(app: FastAPI): + # 各平台独立 Client(故障隔离) + app.state.http_clients = { + "vidu": httpx.AsyncClient(timeout=30, limits=httpx.Limits(max_connections=20)), + "volcengine_caption": httpx.AsyncClient(timeout=60, limits=httpx.Limits(max_connections=10)), + "default": httpx.AsyncClient(timeout=30, limits=httpx.Limits(max_connections=50)), + } + + # SDK 客户端 + app.state.ark_client = AsyncArk(api_key=..., timeout=1800) + + yield + + # 统一关闭 + for client in app.state.http_clients.values(): + await client.aclose() + + if hasattr(app.state, "ark_client") and not app.state.ark_client.is_closed(): + await app.state.ark_client.close() +``` + +**迁移清单**: + +| 文件 | 当前 Client | 整改方式 | +|-----|------------|---------| +| `app/ai/providers/vidu_provider.py` | `aiohttp.ClientSession` | 迁移为 `httpx.AsyncClient`,由 lifespan 注入 | +| `app/services/volcengine_caption_service.py` | `httpx.AsyncClient`(懒加载,永不关闭) | 改为 lifespan 注入,删除 `_get_client()` 懒加载 | +| `app/api/v1/voice.py` | 临时 `httpx.AsyncClient()` | 改为 `app.state.http_clients["default"]` | + +**禁止事项**: +- [ ] 禁止 import `aiohttp`(项目统一使用 `httpx`) +- [ ] 禁止在方法/路由内 `httpx.AsyncClient()` 临时创建(下载大文件例外,需注释说明) +- [ ] 禁止 Provider `__init__` 中创建 Client 而不在 lifespan 中关闭 + +--- + +### 铁律 4:异步任务状态唯一 + +**规范内容**:所有"提交后等待"的任务,状态必须写入统一的状态机。第三方回调走统一入口 `/webhooks/{platform}`。 + +> **注意**:本铁律涉及数据库设计,当前文档中 SQLAlchemy `Job` model 相关设计已挂起,待数据库方案确定后补充。本章只规定接口和状态机标准。 + +**统一状态枚举**: + +```python +class JobStatus(str, Enum): + PENDING = "pending" # 已提交,等待调度 + QUEUED = "queued" # 已进入队列,等待槽位 + RUNNING = "running" # 正在执行 + SUCCEEDED = "succeeded" # 成功完成 + FAILED = "failed" # 失败 + CANCELLED = "cancelled" # 用户取消或超时取消 +``` + +**统一任务 API**: + +```python +# 提交任务 +POST /jobs +Request: {platform: str, task_type: str, payload: dict, idempotency_key: str | None} +Response: {job_id: UUID, status: "pending"} + +# 查询任务 +GET /jobs/{job_id} +Response: {job_id, status, progress, message, result, error, created_at, updated_at} + +# 统一回调入口 +POST /webhooks/{platform} +``` + +**第三方状态映射**(Adapter 层负责): + +| 第三方状态 | 内部状态 | +|-----------|---------| +| Vidu: `pending` / `processing` | `RUNNING` | +| Vidu: `success` | `SUCCEEDED` | +| Vidu: `failed` | `FAILED` | +| 火山字幕: `code=2000` | `RUNNING` | +| 火山字幕: `code=0` | `SUCCEEDED` | +| 火山字幕: `code=1001/1002/1012` | `FAILED`(不可重试) | +| 火山字幕: `code=1003`(超频) | `FAILED`(可重试) | + +**禁止事项**: +- [ ] 禁止 Router/Service 私设 Redis key(如 `vidu:lipsync:xxx`) +- [ ] 禁止在 Router 中直接处理回调验签(必须由 Adapter 处理) +- [ ] 禁止各平台使用自己的状态字符串返回给前端 + +--- + +### 铁律 5:配置与密钥分离 + +**规范内容**:非敏感配置走 `config/platform-config.yaml`,支持热重载。密钥走 `.env`,修改需重启。 + +**文件结构**: + +```yaml +# config/platform-config.yaml +platforms: + : + provider: + base_url: + models: # 原 ai_models.yaml 内容合并至此 + - id: + model_name: <实际模型ID> + capabilities: [] + default_params: + rate_limit: + qps: + burst: + methods: + : + timeout: + max_connections: + rate_limit: + qps: + burst: + +runtime: + fallback_chains: + : + - + - + task_timeouts: + : + task_ttl: + : +``` + +**热重载实现**: + +```python +class RuntimeConfig: + """运行时配置,轮询检查 mtime(10秒间隔)+ Admin API 手动触发""" + + async def get(self, key: str, default=None): + await self._reload_if_changed() + return self._config.get(key, default) + + async def force_reload(self) -> bool: + """Admin API 调用""" + ... +``` + +**Admin API**: + +```python +@router.post("/admin/runtime-config/reload") +async def reload_runtime_config(): + success = await runtime_config.force_reload() + return {"reloaded": success, "version": runtime_config.version} + +@router.get("/admin/runtime-config") +async def get_runtime_config(): + return runtime_config.get_raw() +``` + +**迁移清单**: + +| `.env` 中的配置项 | 迁移目标 | 状态 | +|------------------|---------|------| +| `VIDU_BASE_URL` | `platforms.vidu.base_url` | 待迁移 | +| `VOLCENGINE_BASE_URL` | `platforms.volcengine_ark.base_url` | 待迁移 | +| `VOLC_SUBTITLE_MAX_CONCURRENT` | `platforms.volcengine_caption.methods.caption.max_connections` | 待迁移 | +| `VOLC_SUBTITLE_TIMEOUT` | `runtime.task_timeouts.caption` | 待迁移 | + +**禁止事项**: +- [ ] 禁止在 `.env` 中存放非敏感配置(URL、超时、限流) +- [ ] 禁止代码中硬编码配置(如 `timeout=30`、`max_rate=20`) +- [ ] 禁止 `Settings` 类超过 150 行(逐步瘦身) + +--- + +## 三、分阶段实施计划 + +### Phase 0:准备(0.5 天) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 0.1 | 新建 `app/ai/adapters/` 目录结构 | `app/ai/adapters/__init__.py` | 目录存在 | +| 0.2 | 新建 `app/platform_gateway.py` 骨架 | `app/platform_gateway.py` | 文件存在,类定义完整 | +| 0.3 | 安装/确认 `importlinter` 可用 | `pyproject.toml` 依赖 | `pip show importlinter` | +| 0.4 | 备份现有 `exceptions.py` | git stash / 分支 | 可回滚 | + +### Phase 1:异常体系(0.5 天) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 1.1 | 重构 `PlatformError` + `PlatformErrorType` | `app/core/exceptions.py` | 类型定义完整,含所有字段 | +| 1.2 | 保留 `AppException` 体系(业务错误) | `app/core/exceptions.py` | 原有类不删除 | +| 1.3 | `main.py` 注册 `PlatformError` 全局中间件 | `app/main.py` | 启动无报错,异常测试返回正确 HTTP 码 | +| 1.4 | `VolcengineArkAdapter._wrap_error()` 实现异常映射 | `app/ai/adapters/volcengine_ark.py` | 单元测试覆盖 | +| 1.5 | `ViduAdapter._wrap_error()` 实现异常映射 | `app/ai/adapters/vidu.py` | 单元测试覆盖 | +| 1.6 | `make lint-semantic` 增加异常规则 | `Makefile` | 提交时自动检查 | + +**验收标准**: +- [ ] 任意第三方调用失败,Router 返回的 JSON 中 `detail.retryable` 正确 +- [ ] 网络超时返回 504,限流返回 429,认证失败返回 401 +- [ ] 业务错误(如参数校验失败)仍走 `AppException` → 400/422 + +### Phase 2:Adapter Protocol + 配置合并(1 天) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 2.1 | `PlatformAdapter` / `SyncCapable` / `TaskCapable` / `CallbackCapable` Protocol | `app/ai/adapters/base.py` | `isinstance` 校验通过 | +| 2.2 | `AdapterResponse` / `TaskStatus` dataclass | `app/ai/adapters/base.py` | frozen=True,字段完整 | +| 2.3 | `Method` 常量定义 | `app/ai/adapters/constants.py` | 覆盖所有现有方法 | +| 2.4 | 合并 `ai_models.yaml` → `platform-config.yaml` | `config/platform-config.yaml` | 原有模型列表完整迁移 | +| 2.5 | `RuntimeConfig` 热重载实现 | `app/core/runtime_config.py` | mtime 轮询 + force_reload 均工作 | +| 2.6 | Admin API `/admin/runtime-config/*` | `app/api/v1/system.py` | GET/POST 返回正确 | +| 2.7 | `Settings` 类清理非敏感配置 | `app/config.py` | 只保留密钥,行数 < 150 | + +**验收标准**: +- [ ] 新增一个 MockAdapter 实现 Protocol,IDE 自动提示缺失方法 +- [ ] 修改 `runtime.yaml` 中的 qps,10 秒内新请求生效 +- [ ] Admin API 手动触发 reload,返回最新配置 + +### Phase 3:HTTP Client 统一(1 天) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 3.1 | `ViduProvider` 从 `aiohttp` 迁移到 `httpx` | `app/ai/providers/vidu_provider.py` | 功能测试通过 | +| 3.2 | `VolcengineCaptionService` 删除懒加载,改为注入 Client | `app/services/volcengine_caption_service.py` | 功能测试通过 | +| 3.3 | `voice.py` 中临时 `httpx.AsyncClient()` 改为共享 Client | `app/api/v1/voice.py` | 代码审查 | +| 3.4 | lifespan 统一管理所有 Client 生命周期 | `app/main.py` | 启动/关闭无泄漏日志 | +| 3.5 | `ViduAdapter.close()` / `VolcengineCaptionAdapter.close()` 实现 | 对应 Adapter 文件 | lifespan shutdown 时调用 | +| 3.6 | `make lint` 增加 `aiohttp` import 禁止规则 | `pyproject.toml` 或 pre-commit | import aiohttp 报 error | + +**验收标准**: +- [ ] `pip list | grep aiohttp` 无输出(或确认仅作为间接依赖) +- [ ] `python -m app.main` 启动后,关闭时无 `unclosed client session` 警告 +- [ ] 所有 `AsyncClient` 创建都在 lifespan 中 + +### Phase 4:Gateway 骨架 + Adapter 包装层(1 天) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 4.1 | `PlatformGateway` 骨架(`call_sync` / `submit_task` / `query_task` / `handle_webhook`) | `app/platform_gateway.py` | 类方法签名完整 | +| 4.2 | `VolcengineArkAdapter` 改造现有 Provider 实现 Protocol | `app/ai/adapters/volcengine_ark.py` | 单元测试通过 | +| 4.3 | `ViduAdapter` 改造现有 Provider 实现 Protocol | `app/ai/adapters/vidu.py` | 单元测试通过 | +| 4.4 | `VolcengineCaptionAdapter` 改造现有 Service 实现 Protocol | `app/ai/adapters/volcengine_caption.py` | 单元测试通过 | +| 4.5 | `LLMGateway` 实现(模型选择、Fallback、流式路由) | `app/ai/gateways/llm_gateway.py` | 脚本生成功能测试通过 | +| 4.6 | lifespan 中初始化所有 Adapter 并注册到 Gateway | `app/main.py` | 启动日志显示各平台初始化成功 | + +**验收标准**: +- [ ] 新增一个 `MockAdapter` 实现 Protocol,5 分钟内完成注册并可用 +- [ ] `LLMGateway.chat()` 主模型失败时自动 Fallback 到备用模型 +- [ ] 健康检查 `/system/platform-health` 返回所有平台状态 + +### Phase 5:异步任务统一(2 天,数据库方案确定后实施) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 5.1 | SQLAlchemy `Job` model(独立设计) | `app/models/job.py` | Alembic 迁移成功 | +| 5.2 | Pydantic `JobResponse` Schema | `app/schemas/job.py` | 覆盖所有字段 | +| 5.3 | `JobRegistry` 改为先写数据库、再写 Redis | `app/scheduler/registry.py` | 数据库有数据 | +| 5.4 | `JobStatus` 扩展为 6 种状态 | `app/schemas/enums.py` | 覆盖所有场景 | +| 5.5 | `ViduHandler` 接入 Async Engine | `app/scheduler/handlers/vidu_handler.py` | 对口型任务走 Engine | +| 5.6 | `SubtitleHandler` 改为通过 Gateway 调用 | `app/scheduler/handlers/subtitle_handler.py` | 字幕任务走 Gateway | +| 5.7 | 统一回调入口 `/webhooks/{platform}` | `app/api/v1/webhooks.py` | Vidu 回调正常 | +| 5.8 | 删除 Router 中私设 Redis key 的代码 | `app/api/v1/vidu.py` | 无 `vidu:lipsync:` 字样 | +| 5.9 | 统一任务 API `/jobs/{job_id}` | `app/api/v1/jobs.py` | GET 返回标准格式 | +| 5.10 | 脚本生成从 SSE 改为异步任务 | `app/api/v1/script.py` / `app/services/script_service.py` | POST /jobs 提交,轮询 /jobs/{id} | +| 5.11 | 删除 `/script/generate/stream` SSE 端点 | `app/api/v1/script.py` | 端点不存在 | + +**验收标准**: +- [ ] Vidu 对口型任务提交后,Redis 中只有 `job:{uuid}` 格式的 key +- [ ] 应用重启后,从数据库恢复 running 任务继续执行 +- [ ] 前端轮询 `/jobs/{id}` 获取所有异步任务状态 + +### Phase 6:清理与验证(0.5 天) + +| # | 任务 | 输出文件 | 检查方式 | +|---|------|---------|---------| +| 6.1 | `importlinter` 配置(禁止 Router 直接 import Provider) | `.importlinter` | CI 中运行通过 | +| 6.2 | 删除废弃的 `ai_models.yaml`(确认合并完成后) | — | 文件不存在 | +| 6.3 | 删除 `ViduService` / `VolcengineCaptionService` 中的重复异常处理 | 对应文件 | 代码审查 | +| 6.4 | 全量回归测试(所有现有 API 调用一遍) | — | 测试脚本通过 | +| 6.5 | 更新本文档,标记各阶段完成状态 | 本文档 | 所有 checkbox 打勾 | + +--- + +## 四、检查清单汇总 + +### 4.1 新增文件清单 + +| 文件路径 | 说明 | 所属阶段 | +|---------|------|---------| +| `app/ai/adapters/__init__.py` | Adapter 包 | Phase 0 | +| `app/ai/adapters/base.py` | Protocol + dataclass | Phase 2 | +| `app/ai/adapters/constants.py` | Method 常量 | Phase 2 | +| `app/ai/adapters/volcengine_ark.py` | 火山方舟 Adapter | Phase 4 | +| `app/ai/adapters/vidu.py` | Vidu Adapter | Phase 4 | +| `app/ai/adapters/volcengine_caption.py` | 火山字幕 Adapter | Phase 4 | +| `app/ai/gateways/llm_gateway.py` | LLM 网关 | Phase 4 | +| `app/platform_gateway.py` | 统一平台网关 | Phase 0/4 | +| `app/core/runtime_config.py` | 运行时配置 + 热重载 | Phase 2 | +| `config/platform-config.yaml` | 合并后的平台配置 | Phase 2 | +| `app/models/job.py` | 异步任务数据库模型 | Phase 5 | +| `app/api/v1/jobs.py` | 统一任务 API | Phase 5 | +| `app/api/v1/webhooks.py` | 统一回调入口 | Phase 5 | +| `app/scheduler/handlers/vidu_handler.py` | Vidu 任务处理器 | Phase 5 | +| `.importlinter` | 架构约束配置 | Phase 6 | + +### 4.2 修改文件清单 + +| 文件路径 | 修改内容 | 所属阶段 | +|---------|---------|---------| +| `app/core/exceptions.py` | 新增 `PlatformError` / `PlatformErrorType` | Phase 1 | +| `app/main.py` | 注册异常中间件、lifespan Client 管理 | Phase 1/3/4 | +| `app/config.py` | 清理非敏感配置,只保留密钥 | Phase 2 | +| `app/ai/providers/vidu_provider.py` | aiohttp → httpx | Phase 3 | +| `app/services/volcengine_caption_service.py` | 删除懒加载,改为注入 Client | Phase 3 | +| `app/api/v1/voice.py` | 临时 Client → 共享 Client | Phase 3 | +| `app/api/v1/script.py` | SSE → 异步任务 + 删除 stream 端点 | Phase 5 | +| `app/services/script_service.py` | 删除 generate_script_stream | Phase 5 | +| `app/api/v1/system.py` | 新增 Admin API | Phase 2 | +| `app/scheduler/registry.py` | 先写数据库再写 Redis | Phase 5 | +| `app/scheduler/handlers/subtitle_handler.py` | 通过 Gateway 调用 | Phase 5 | +| `app/api/v1/vidu.py` | 删除私设 Redis key | Phase 5 | +| `app/schemas/enums.py` | 扩展 `JobStatus` | Phase 5 | +| `Makefile` / `pyproject.toml` | lint 规则 | Phase 1/3/6 | + +### 4.3 废弃文件清单 + +| 文件路径 | 废弃原因 | 处理时间 | +|---------|---------|---------| +| `config/ai_models.yaml` | 合并到 `platform-config.yaml` | Phase 6 | + +--- + +## 五、风险项与应对 + +| 风险 | 影响 | 概率 | 应对 | +|-----|------|------|------| +| `aiohttp` 迁移到 `httpx` 导致 Vidu 某些边缘场景行为不一致 | 功能回归 | 中 | 迁移后全量测试 Vidu TTS/对口型/克隆 | +| `PlatformError` 未覆盖所有异常路径,仍有裸 Exception 漏出 | 前端收到 500 无法处理 | 低 | `make lint-semantic` 强制检查 + Code Review | +| 配置热重载导致运行时行为突变 | 线上限流突然变更 | 低 | Admin API 加操作日志,变更前确认 | +| Phase 5 数据库改造影响现有 Async Engine | 字幕/脚本任务异常 | 中 | 数据库方案评审后再实施,分步迁移 | +| 前端轮询改造工作量超预期 | 延期 | 中 | 提前与前端同步接口变更,预留 2 天 | + +--- + +## 六、验收标准(最终 Checklist) + +实施全部完成后,按以下清单逐项核对: + +- [ ] `PlatformError` 是 `app/services/` 和 `app/ai/` 中唯一的第三方异常类型 +- [ ] Router 中不存在 `except Exception: raise HTTPException(500)` 处理第三方错误 +- [ ] 新增 MockAdapter 实现 Protocol,30 分钟内完成注册并可用 +- [ ] `aiohttp` 不在项目直接依赖中(`pip show aiohttp` 不显示或仅为间接依赖) +- [ ] 所有 `AsyncClient` 在 lifespan 中创建和销毁 +- [ ] 关闭应用时无 `unclosed client session` 警告 +- [ ] `config/platform-config.yaml` 存在且包含所有平台配置 +- [ ] 修改 `platform-config.yaml` 中的限流参数,10 秒内新请求生效 +- [ ] Admin API `/admin/runtime-config/reload` 手动触发重载成功 +- [ ] 健康检查 `/system/platform-health` 返回所有平台状态 +- [ ] `importlinter` CI 检查通过(Router 不直接 import Provider) +- [ ] 全量 API 回归测试通过 + +--- + +> 本文档为实施的唯一依据。任何偏离文档的变更必须在此文档中登记并说明理由。 diff --git a/python-api/app/ai/adapters/__init__.py b/python-api/app/ai/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/ai/adapters/base.py b/python-api/app/ai/adapters/base.py new file mode 100644 index 0000000..c622aae --- /dev/null +++ b/python-api/app/ai/adapters/base.py @@ -0,0 +1,115 @@ +""" +Adapter 基础定义 +=============== + +所有第三方平台 Adapter 的统一契约。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class AdapterResponse: + """Adapter 统一响应格式""" + + success: bool + data: dict[str, Any] | None = None + error_code: str | None = None + error_message: str | None = None + retryable: bool = False + + +@dataclass(frozen=True) +class TaskStatus: + """异步任务状态统一格式""" + + state: str # "pending" | "processing" | "completed" | "failed" + result: dict[str, Any] | None = None + error_message: str | None = None + + +@runtime_checkable +class PlatformAdapter(Protocol): + """所有 Adapter 的准入门槛 + + 每个新平台必须实现 platform_id + health() + close()。 + """ + + platform_id: str + + async def health(self) -> AdapterResponse: + """健康检查,返回是否可用""" + ... + + async def close(self) -> None: + """清理资源(关闭 HTTP Client、释放连接池)""" + ... + + +@runtime_checkable +class SyncCapable(Protocol): + """同步调用能力(TTS、Chat、图片生成等)""" + + async def call(self, method: str, payload: dict[str, Any]) -> AdapterResponse: + """同步调用统一入口 + + Args: + method: 方法标识,如 "tts", "chat", "image_generate" + payload: 请求体字典 + + Returns: + AdapterResponse: 统一响应格式 + + 各方法返回结构(docstring 约定): + - "tts": data={"audio_url": str} + - "chat": data={"content": str, "usage": dict, "model": str} + - "image_generate": data={"images": [{"url": str, "b64_json": str}]} + """ + ... + + +@runtime_checkable +class TaskCapable(Protocol): + """异步任务能力(对口型、字幕、视频生成等)""" + + async def submit( + self, + task_type: str, + payload: dict[str, Any], + callback_url: str | None = None, + ) -> AdapterResponse: + """提交任务,返回 platform_task_id""" + ... + + async def query(self, platform_job_id: str) -> TaskStatus: + """查询任务状态""" + ... + + +@runtime_checkable +class CallbackCapable(Protocol): + """回调验签能力(可选,只有需要验签的平台才实现)""" + + async def verify_signature( + self, + headers: dict[str, str], + body: bytes, + secret: str, + callback_url: str | None = None, + ) -> bool: + """验证回调签名 + + Args: + callback_url: 回调请求完整 URL(用于构建 signingString,部分平台需要) + """ + ... + + async def parse_callback(self, body: bytes) -> TaskStatus: + """解析回调体为统一任务状态""" + ... diff --git a/python-api/app/ai/adapters/constants.py b/python-api/app/ai/adapters/constants.py new file mode 100644 index 0000000..2ca474b --- /dev/null +++ b/python-api/app/ai/adapters/constants.py @@ -0,0 +1,23 @@ +""" +Adapter 方法常量 +=============== + +统一的方法标识,避免字符串硬编码。 +""" + + +class Method: + """同步/异步方法标识常量""" + + # 同步方法 + TTS = "tts" + CHAT = "chat" + CHAT_STREAM = "chat_stream" + IMAGE_GENERATE = "image_generate" + EMBEDDING = "embedding" + + # 异步任务方法 + LIP_SYNC = "lip_sync" + CAPTION = "caption" + AUTO_ALIGN = "auto_align" + VIDEO_GENERATE = "video_generate" diff --git a/python-api/app/ai/adapters/vidu_adapter.py b/python-api/app/ai/adapters/vidu_adapter.py new file mode 100644 index 0000000..a064c78 --- /dev/null +++ b/python-api/app/ai/adapters/vidu_adapter.py @@ -0,0 +1,265 @@ +""" +Vidu Adapter +============ + +实现 PlatformAdapter + SyncCapable + TaskCapable + CallbackCapable。 +直接接入 ViduProvider,提供标准 Protocol 接口。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging + +from app.ai.adapters.base import ( + AdapterResponse, + CallbackCapable, + PlatformAdapter, + SyncCapable, + TaskCapable, + TaskStatus, +) +from app.ai.adapters.constants import Method +from app.ai.providers.vidu_provider import ViduProvider +from app.core.exceptions import PlatformError, PlatformErrorType + +logger = logging.getLogger(__name__) + + +class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable): + """Vidu 平台标准 Adapter""" + + platform_id = "vidu" + + def __init__(self, provider: ViduProvider): + self.provider = provider + + # ── PlatformAdapter ── + + async def health(self) -> AdapterResponse: + try: + # Vidu 没有专门的健康检查接口,用查询一个空任务测试连通性 + # 实际上会 404,但只要网络通就说明服务可用 + await self.provider.query_task("health-check") + return AdapterResponse(success=True) + except PlatformError as e: + if e.error_type == PlatformErrorType.NOT_FOUND: + return AdapterResponse(success=True) + return AdapterResponse( + success=False, + error_message=str(e), + retryable=e.retryable, + ) + except Exception as e: + return AdapterResponse( + success=False, + error_message=str(e), + retryable=False, + ) + + async def close(self) -> None: + await self.provider.close() + + # ── SyncCapable ── + + async def call(self, method: str, payload: dict) -> AdapterResponse: + try: + if method == Method.TTS: + result = await self.provider.tts_sync( + text=payload["text"], + voice_id=payload.get("voice_id", "tianxin_xiaoling"), + speed=payload.get("speed", 1.0), + volume=payload.get("volume", 0), + pitch=payload.get("pitch", 0), + emotion=payload.get("emotion"), + ) + return AdapterResponse( + success=True, + data={"audio_url": result.get("file_url")}, + ) + + elif method == Method.CLONE_VOICE: + result = await self.provider.clone_voice( + audio_url=payload["audio_url"], + voice_id=payload["voice_id"], + text=payload.get("text"), + ) + return AdapterResponse( + success=True, + data={ + "voice_id": result.get("voice_id"), + "demo_audio": result.get("demo_audio"), + }, + ) + + else: + return AdapterResponse( + success=False, + error_message=f"不支持的方法: {method}", + retryable=False, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"Vidu {method} 调用失败: {e}", + platform="vidu", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e + + # ── TaskCapable ── + + async def submit( + self, + task_type: str, + payload: dict, + callback_url: str | None = None, + ) -> AdapterResponse: + try: + if task_type == Method.LIP_SYNC: + result = await self.provider.lip_sync( + video_url=payload["video_url"], + audio_url=payload.get("audio_url"), + text=payload.get("text"), + voice_id=payload.get("voice_id"), + speed=payload.get("speed", 1.0), + volume=payload.get("volume", 0), + ref_photo_url=payload.get("ref_photo_url"), + callback_url=callback_url, + ) + return AdapterResponse( + success=True, + data={"task_id": result.get("task_id")}, + ) + + else: + return AdapterResponse( + success=False, + error_message=f"不支持的任务类型: {task_type}", + retryable=False, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"Vidu {task_type} 提交失败: {e}", + platform="vidu", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e + + async def query(self, platform_job_id: str) -> TaskStatus: + try: + result = await self.provider.query_task(platform_job_id) + state = result.get("state", "unknown") + + # Vidu 状态映射到标准状态 + state_mapping = { + "pending": "processing", + "processing": "processing", + "success": "completed", + "failed": "failed", + } + + creations = result.get("creations", []) + video_url = None + if state == "success" and creations: + video_url = creations[0].get("url") + + return TaskStatus( + state=state_mapping.get(state, "failed"), + result={"video_url": video_url, "creations": creations} if video_url else None, + error_message=result.get("message") if state == "failed" else None, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"Vidu 任务查询失败: {e}", + platform="vidu", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e + + # ── CallbackCapable ── + + async def verify_signature( + self, + headers: dict[str, str], + body: bytes, + secret: str, + callback_url: str | None = None, + ) -> bool: + """验证 Vidu 回调 HMAC-SHA256 签名""" + signature = headers.get("X-HMAC-SIGNATURE") + algorithm = headers.get("X-HMAC-ALGORITHM") + access_key = headers.get("X-HMAC-ACCESS-KEY") + signed_headers_str = headers.get("X-HMAC-SIGNED-HEADERS") + date = headers.get("Date") + + if not all([signature, algorithm, access_key, signed_headers_str, date]): + return False + if algorithm != "hmac-sha256": + return False + if access_key != "vidu": + return False + + header_names = [h.strip() for h in signed_headers_str.split(";") if h.strip()] + header_values: dict[str, str] = {} + for name in header_names: + value = headers.get(name) + if value is None: + return False + header_values[name] = value + + # 构建 signingString(使用 callback_url 动态解析 path/query) + parsed = urlparse(callback_url or "") + http_uri = parsed.path or "/" + canonical_query_string = parsed.query or "" + + signing_string = ( + f"POST\n" + f"{http_uri}\n" + f"{canonical_query_string}\n" + f"vidu\n" + f"{date}\n" + ) + for name in header_names: + signing_string += f"{name}:{header_values[name]}\n" + + expected = base64.b64encode( + hmac.new(secret.encode("utf-8"), signing_string.encode("utf-8"), hashlib.sha256).digest() + ).decode("utf-8") + + return hmac.compare_digest(signature, expected) + + async def parse_callback(self, body: bytes) -> TaskStatus: + """解析 Vidu 回调体""" + data = json.loads(body) + task_id = data.get("id") or data.get("task_id") + state = data.get("state") + creations = data.get("creations", []) + + state_mapping = { + "pending": "processing", + "processing": "processing", + "success": "completed", + "failed": "failed", + } + + video_url = None + if state == "success" and creations: + video_url = creations[0].get("url") + + return TaskStatus( + state=state_mapping.get(state, "failed"), + result={"video_url": video_url, "creations": creations, "task_id": task_id} if video_url else {"task_id": task_id}, + error_message=data.get("message") if state == "failed" else None, + ) diff --git a/python-api/app/ai/adapters/volcengine_ark_adapter.py b/python-api/app/ai/adapters/volcengine_ark_adapter.py new file mode 100644 index 0000000..047ecb8 --- /dev/null +++ b/python-api/app/ai/adapters/volcengine_ark_adapter.py @@ -0,0 +1,101 @@ +""" +火山方舟 Adapter +================ + +实现 PlatformAdapter + SyncCapable。 +直接接入 VolcengineProvider,提供标准 Protocol 接口。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.ai.adapters.base import AdapterResponse, PlatformAdapter, SyncCapable +from app.ai.adapters.constants import Method +from app.ai.providers.volcengine_provider import VolcengineProvider +from app.core.exceptions import PlatformError, PlatformErrorType + +logger = logging.getLogger(__name__) + + +class VolcengineArkAdapter(PlatformAdapter, SyncCapable): + """火山方舟 LLM 平台标准 Adapter""" + + platform_id = "volcengine_ark" + + def __init__(self, provider: VolcengineProvider): + self.provider = provider + + # ── PlatformAdapter ── + + async def health(self) -> AdapterResponse: + try: + health = await self.provider.health_check() + return AdapterResponse( + success=health.is_available, + data={"response_time_ms": health.response_time}, + ) + except Exception as e: + return AdapterResponse( + success=False, + error_message=str(e), + retryable=False, + ) + + async def close(self) -> None: + if hasattr(self.provider.client, "close"): + await self.provider.client.close() + + # ── SyncCapable ── + + async def call(self, method: str, payload: dict[str, Any]) -> AdapterResponse: + try: + if method == Method.CHAT: + result = await self.provider.generate( + prompt=payload["prompt"], + model=payload.get("model"), + temperature=payload.get("temperature", 0.7), + max_tokens=payload.get("max_tokens"), + system_prompt=payload.get("system_prompt"), + ) + return AdapterResponse( + success=True, + data={ + "content": result.content, + "usage": result.usage, + "model": result.model, + }, + ) + + elif method == Method.IMAGE_GENERATE: + result = await self.provider.generate_image( + prompt=payload["prompt"], + model=payload.get("model"), + size=payload.get("size", "1024x1024"), + ) + return AdapterResponse(success=True, data=result) + + elif method == Method.EMBEDDING: + result = await self.provider.create_embeddings( + texts=payload["texts"], + model=payload.get("model"), + ) + return AdapterResponse(success=True, data=result) + + else: + return AdapterResponse( + success=False, + error_message=f"不支持的方法: {method}", + retryable=False, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"火山方舟 {method} 调用失败: {e}", + platform="volcengine_ark", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e diff --git a/python-api/app/ai/adapters/volcengine_caption_adapter.py b/python-api/app/ai/adapters/volcengine_caption_adapter.py new file mode 100644 index 0000000..18b9334 --- /dev/null +++ b/python-api/app/ai/adapters/volcengine_caption_adapter.py @@ -0,0 +1,142 @@ +""" +火山引擎字幕 Adapter +==================== + +实现 PlatformAdapter + TaskCapable。 +直接接入 VolcengineCaptionService,提供标准 Protocol 接口。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.ai.adapters.base import AdapterResponse, PlatformAdapter, TaskCapable, TaskStatus +from app.ai.adapters.constants import Method +from app.core.exceptions import PlatformError, PlatformErrorType +from app.services.volcengine_caption_service import VolcengineCaptionService + +logger = logging.getLogger(__name__) + + +class VolcengineCaptionAdapter(PlatformAdapter, TaskCapable): + """火山引擎字幕平台标准 Adapter""" + + platform_id = "volcengine_caption" + + def __init__(self, service: VolcengineCaptionService): + self.service = service + + # ── PlatformAdapter ── + + async def health(self) -> AdapterResponse: + try: + # 火山字幕没有专门的健康检查,用提交一个无效任务测试连通性 + # 401/403 说明网络通但认证问题,也算"可用" + await self.service.submit_caption_task(audio_url="https://example.com/test.mp3") + return AdapterResponse(success=True) + except PlatformError as e: + if e.error_type in (PlatformErrorType.AUTH_FAILED, PlatformErrorType.BAD_REQUEST): + return AdapterResponse(success=True) + return AdapterResponse( + success=False, + error_message=str(e), + retryable=e.retryable, + ) + except Exception as e: + return AdapterResponse( + success=False, + error_message=str(e), + retryable=False, + ) + + async def close(self) -> None: + await self.service.close() + + # ── TaskCapable ── + + async def submit( + self, + task_type: str, + payload: dict[str, Any], + callback_url: str | None = None, + ) -> AdapterResponse: + try: + if task_type == Method.CAPTION: + task_id = await self.service.submit_caption_task( + audio_url=payload["audio_url"], + language=payload.get("language", "zh-CN"), + caption_type=payload.get("caption_type", "auto"), + use_punc=payload.get("use_punc", True), + use_itn=payload.get("use_itn", True), + words_per_line=payload.get("words_per_line", 46), + max_lines=payload.get("max_lines", 1), + ) + return AdapterResponse(success=True, data={"task_id": task_id}) + + elif task_type == Method.AUTO_ALIGN: + task_id = await self.service.submit_auto_align_task( + audio_url=payload["audio_url"], + audio_text=payload["audio_text"], + caption_type=payload.get("caption_type", "speech"), + sta_punc_mode=payload.get("sta_punc_mode", 3), + ) + return AdapterResponse(success=True, data={"task_id": task_id}) + + else: + return AdapterResponse( + success=False, + error_message=f"不支持的任务类型: {task_type}", + retryable=False, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"火山字幕 {task_type} 提交失败: {e}", + platform="volcengine_caption", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e + + async def query(self, platform_job_id: str) -> TaskStatus: + try: + result = await self.service.query_caption_task( + task_id=platform_job_id, + blocking=False, + ) + + # 火山字幕状态映射 + if result.code == 0: + return TaskStatus( + state="completed", + result={ + "duration": result.duration, + "utterances": [ + { + "text": u.text, + "start_time": u.start_time, + "end_time": u.end_time, + } + for u in (result.utterances or []) + ], + }, + ) + elif result.code == 2000: + return TaskStatus(state="processing") + else: + return TaskStatus( + state="failed", + error_message=result.message, + ) + + except PlatformError: + raise + except Exception as e: + raise PlatformError( + f"火山字幕任务查询失败: {e}", + platform="volcengine_caption", + retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) from e diff --git a/python-api/app/ai/gateways/.gitkeep b/python-api/app/ai/gateways/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/ai/gateways/__init__.py b/python-api/app/ai/gateways/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/ai/gateways/llm_gateway.py b/python-api/app/ai/gateways/llm_gateway.py new file mode 100644 index 0000000..e255d69 --- /dev/null +++ b/python-api/app/ai/gateways/llm_gateway.py @@ -0,0 +1,150 @@ +""" +LLM 调用网关 +============ + +职责: +1. 按 task_type 选择模型 +2. Fallback 降级链 +3. 调用各平台 Adapter +4. 流式/非流式统一封装 +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from typing import Any + +from app.ai.adapters.base import SyncCapable +from app.ai.adapters.constants import Method +from app.core.exceptions import PlatformError, PlatformErrorType + +logger = logging.getLogger(__name__) + + +class LLMGateway: + """LLM 调用网关""" + + def __init__(self, adapters: dict[str, SyncCapable], fallback_chains: dict[str, list[str]] | None = None): + self.adapters = adapters + self.fallback_chains = fallback_chains or {} + + def _get_adapter(self, platform: str) -> SyncCapable: + adapter = self.adapters.get(platform) + if adapter is None: + raise ValueError(f"未注册的 LLM 平台: {platform}") + return adapter + + async def chat( + self, + model_id: str, + prompt: str, + platform: str = "volcengine_ark", + **kwargs, + ) -> dict[str, Any]: + """同步聊天,带 Fallback + + Args: + model_id: 模型别名(如 doubao-seed-2-0-pro) + prompt: 用户提示词 + platform: 平台 ID + **kwargs: temperature, max_tokens, system_prompt 等 + """ + models_to_try = [model_id] + self.fallback_chains.get(model_id, []) + + last_error = None + for mid in models_to_try: + adapter = self._get_adapter(platform) + try: + result = await adapter.call(Method.CHAT, { + "prompt": prompt, + "model": mid, + **kwargs, + }) + if result.success: + if mid != model_id: + logger.warning(f"[LLMGateway] 模型降级成功: {model_id} → {mid}") + return result.data + else: + last_error = PlatformError( + result.error_message or f"模型 {mid} 调用失败", + platform=platform, + retryable=result.retryable, + ) + except PlatformError as e: + last_error = e + if not e.retryable: + raise # 不可重试的错误直接抛,不再 Fallback + logger.warning(f"[LLMGateway] 模型 {mid} 失败,尝试下一个: {e}") + continue + + raise last_error or PlatformError( + f"所有模型均失败: {model_id}", + platform=platform, + retryable=False, + ) + + async def chat_stream( + self, + model_id: str, + prompt: str, + platform: str = "volcengine_ark", + **kwargs, + ) -> AsyncIterator[dict[str, Any]]: + """流式聊天 + + 流式不支持 Fallback(中途切换模型会导致内容混乱)。 + """ + adapter = self._get_adapter(platform) + + # 检查 Adapter 是否支持流式 + if not hasattr(adapter, "call_stream"): + raise PlatformError( + "平台不支持流式输出", + platform=platform, + retryable=False, + error_type=PlatformErrorType.BAD_REQUEST, + ) + + yielded_any = False + try: + async for chunk in adapter.call_stream(Method.CHAT_STREAM, { + "prompt": prompt, + "model": model_id, + **kwargs, + }): + yielded_any = True + yield chunk + except Exception as e: + if yielded_any: + # 已经输出内容,不再降级,直接抛 + logger.error(f"[LLMGateway] 流式生成中途失败: {e}") + raise + raise PlatformError( + f"流式生成失败: {e}", + platform=platform, + retryable=True, + error_type=PlatformErrorType.UNKNOWN, + ) from e + + async def generate_image( + self, + prompt: str, + model_id: str | None = None, + platform: str = "volcengine_ark", + **kwargs, + ) -> dict[str, Any]: + """图片生成""" + adapter = self._get_adapter(platform) + result = await adapter.call(Method.IMAGE_GENERATE, { + "prompt": prompt, + "model": model_id, + **kwargs, + }) + if not result.success: + raise PlatformError( + result.error_message or "图片生成失败", + platform=platform, + retryable=result.retryable, + ) + return result.data diff --git a/python-api/app/ai/model_router.py b/python-api/app/ai/model_router.py index 3d229ed..de3a27b 100644 --- a/python-api/app/ai/model_router.py +++ b/python-api/app/ai/model_router.py @@ -45,9 +45,15 @@ class PlatformInstance: raise ProviderError( "Volcengine API Key 未配置,请在 .env 中设置 VOLCENGINE_API_KEY" ) + base_url = self.config.get("base_url") + if not base_url: + from app.core.platform_config import get_platform_config_loader + + platform_config = get_platform_config_loader().get_platform("volcengine_ark") + base_url = platform_config.base_url if platform_config else "https://ark.cn-beijing.volces.com/api/v3" return VolcengineProvider( api_key=api_key, - base_url=self.config.get("base_url") or settings.VOLCENGINE_BASE_URL, + base_url=base_url, ) elif provider_type == "mock": return MockProvider() diff --git a/python-api/app/ai/providers/vidu_provider.py b/python-api/app/ai/providers/vidu_provider.py index 21b4e10..37cfd04 100644 --- a/python-api/app/ai/providers/vidu_provider.py +++ b/python-api/app/ai/providers/vidu_provider.py @@ -8,10 +8,7 @@ Vidu API Provider - 对口型(/ent/v2/lip-sync) - 查询任务(/ent/v2/tasks/{id}/creations) -2024 工程实践: -- 单一 ClientSession 实例,全局复用 -- 连接池大小对齐第三方并发限制 -- 显式分层超时配置 +统一使用 httpx.AsyncClient,由 lifespan 统一管理生命周期。 """ from __future__ import annotations @@ -19,52 +16,79 @@ from __future__ import annotations import logging from typing import Any -import aiohttp +import httpx from app.config import get_settings +from app.core.exceptions import PlatformError, PlatformErrorType logger = logging.getLogger(__name__) +def _map_vidu_error(status: int, message: str) -> PlatformError: + """把 Vidu HTTP 错误映射为标准 PlatformError""" + mapping = { + 429: (PlatformErrorType.RATE_LIMIT, True), + 401: (PlatformErrorType.AUTH_FAILED, False), + 403: (PlatformErrorType.AUTH_FAILED, False), + 400: (PlatformErrorType.BAD_REQUEST, False), + 404: (PlatformErrorType.NOT_FOUND, False), + 500: (PlatformErrorType.SERVER_ERROR, True), + 502: (PlatformErrorType.SERVER_ERROR, True), + 503: (PlatformErrorType.SERVER_ERROR, True), + } + error_type, retryable = mapping.get(status, (PlatformErrorType.UNKNOWN, False)) + return PlatformError( + message=message, + platform="vidu", + retryable=retryable, + error_type=error_type, + status_code=status, + ) + + class ViduProvider: """Vidu API 客户端封装 - 单一 ClientSession 实例,应用生命周期内复用。 - 由 FastAPI lifespan 负责创建和关闭。 + 使用 httpx.AsyncClient,支持外部注入(由 lifespan 管理生命周期)。 """ - def __init__(self, api_key: str | None = None, base_url: str | None = None): + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + client: httpx.AsyncClient | None = None, + ): settings = get_settings() self.api_key = api_key or settings.VIDU_API_KEY - self.base_url = (base_url or settings.VIDU_BASE_URL).rstrip("/") + if base_url: + self.base_url = base_url.rstrip("/") + else: + from app.core.platform_config import get_platform_config_loader + + platform_config = get_platform_config_loader().get_platform("vidu") + self.base_url = (platform_config.base_url if platform_config else "https://api.vidu.cn").rstrip("/") if not self.api_key: raise ValueError("Vidu API Key 未配置,请在 .env 中设置 VIDU_API_KEY") - connector = aiohttp.TCPConnector( - limit=20, - limit_per_host=20, - enable_cleanup_closed=True, - ) - - timeout = aiohttp.ClientTimeout( - total=30, - connect=5, - sock_read=10, - ) - - self.session = aiohttp.ClientSession( - connector=connector, - timeout=timeout, - headers={ - "Authorization": f"Token {self.api_key}", - "Content-Type": "application/json", - }, - ) + if client is not None: + self.client = client + self._owns_client = False + else: + self.client = httpx.AsyncClient( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=20, max_keepalive_connections=20), + headers={ + "Authorization": f"Token {self.api_key}", + "Content-Type": "application/json", + }, + ) + self._owns_client = True async def close(self) -> None: - """关闭 HTTP Session,释放连接池。""" - await self.session.close() + """关闭 HTTP Client,释放连接池。仅在自己创建 Client 时关闭。""" + if self._owns_client and not self.client.is_closed: + await self.client.aclose() # ==================== TTS 语音合成 ==================== @@ -101,13 +125,22 @@ class ViduProvider: logger.info(f"[Vidu TTS] 请求参数: text_length={len(text)}") - async with self.session.post(url, json=body) as resp: - data = await resp.json() - if resp.status != 200 or data.get("state") == "failed": - msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status}" - logger.error(f"[Vidu TTS] 请求失败: url={url}, status={resp.status}, response={data}") - raise Exception(f"Vidu TTS error: {msg}") + try: + resp = await self.client.post(url, json=body) + data = resp.json() + if resp.status_code != 200 or data.get("state") == "failed": + msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}" + logger.error(f"[Vidu TTS] 请求失败: url={url}, status={resp.status_code}, response={data}") + raise _map_vidu_error(resp.status_code, f"Vidu TTS error: {msg}") return data + except (httpx.NetworkError, httpx.TimeoutException) as e: + logger.error(f"[Vidu TTS] 网络错误: {e}") + raise PlatformError( + f"Vidu TTS 网络错误: {e}", + platform="vidu", + retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) from e # ==================== 声音复刻 ==================== @@ -138,13 +171,22 @@ class ViduProvider: if payload: body["payload"] = payload - async with self.session.post(url, json=body) as resp: - data = await resp.json() - if resp.status != 200 or data.get("state") == "failed": - msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status}" - logger.error(f"[Vidu Clone] 请求失败: url={url}, status={resp.status}, response={data}") - raise Exception(f"Vidu clone error: {msg}") + try: + resp = await self.client.post(url, json=body) + data = resp.json() + if resp.status_code != 200 or data.get("state") == "failed": + msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}" + logger.error(f"[Vidu Clone] 请求失败: url={url}, status={resp.status_code}, response={data}") + raise _map_vidu_error(resp.status_code, f"Vidu clone error: {msg}") return data + except (httpx.NetworkError, httpx.TimeoutException) as e: + logger.error(f"[Vidu Clone] 网络错误: {e}") + raise PlatformError( + f"Vidu Clone 网络错误: {e}", + platform="vidu", + retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) from e # ==================== 对口型 ==================== @@ -185,13 +227,22 @@ class ViduProvider: if payload: body["payload"] = payload - async with self.session.post(url, json=body) as resp: - data = await resp.json() - if resp.status != 200 or data.get("state") == "failed": - msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status}" - logger.error(f"[Vidu LipSync] 请求失败: url={url}, status={resp.status}, response={data}") - raise Exception(f"Vidu lip-sync error: {msg}") + try: + resp = await self.client.post(url, json=body) + data = resp.json() + if resp.status_code != 200 or data.get("state") == "failed": + msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}" + logger.error(f"[Vidu LipSync] 请求失败: url={url}, status={resp.status_code}, response={data}") + raise _map_vidu_error(resp.status_code, f"Vidu lip-sync error: {msg}") return data + except (httpx.NetworkError, httpx.TimeoutException) as e: + logger.error(f"[Vidu LipSync] 网络错误: {e}") + raise PlatformError( + f"Vidu LipSync 网络错误: {e}", + platform="vidu", + retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) from e # ==================== 查询任务 ==================== @@ -202,10 +253,19 @@ class ViduProvider: """ url = f"{self.base_url}/ent/v2/tasks/{task_id}/creations" - async with self.session.get(url) as resp: - data = await resp.json() - if resp.status != 200: - msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status}" - logger.error(f"[Vidu Query] 请求失败: url={url}, status={resp.status}, response={data}") - raise Exception(f"Vidu query task error: {msg}") + try: + resp = await self.client.get(url) + data = resp.json() + if resp.status_code != 200: + msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}" + logger.error(f"[Vidu Query] 请求失败: url={url}, status={resp.status_code}, response={data}") + raise _map_vidu_error(resp.status_code, f"Vidu query task error: {msg}") return data + except (httpx.NetworkError, httpx.TimeoutException) as e: + logger.error(f"[Vidu Query] 网络错误: {e}") + raise PlatformError( + f"Vidu Query 网络错误: {e}", + platform="vidu", + retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) from e diff --git a/python-api/app/ai/providers/volcengine_provider.py b/python-api/app/ai/providers/volcengine_provider.py index 8064fa2..293f109 100644 --- a/python-api/app/ai/providers/volcengine_provider.py +++ b/python-api/app/ai/providers/volcengine_provider.py @@ -30,6 +30,7 @@ from app.ai.providers.base import ( ModelHealth, ProviderError, ) +from app.core.exceptions import PlatformError, PlatformErrorType logger = logging.getLogger(__name__) @@ -208,9 +209,7 @@ class VolcengineProvider(LLMProvider): ) except Exception as e: - raise ProviderError( - f"火山方舟生成失败: {str(e)}", provider_id=self.provider_id, original_error=e - ) + raise self._wrap_error(e) async def generate_stream( self, @@ -262,9 +261,7 @@ class VolcengineProvider(LLMProvider): yield chunk.choices[0].delta.content except Exception as e: - raise ProviderError( - f"火山方舟流式生成失败: {str(e)}", provider_id=self.provider_id, original_error=e - ) + raise self._wrap_error(e) async def generate_stream_with_progress( self, @@ -349,9 +346,7 @@ class VolcengineProvider(LLMProvider): } except Exception as e: - raise ProviderError( - f"火山方舟流式生成失败: {str(e)}", provider_id=self.provider_id, original_error=e - ) + raise self._wrap_error(e) async def generate_image( self, prompt: str, model: str | None = None, size: str = "1024x1024", **kwargs @@ -392,9 +387,7 @@ class VolcengineProvider(LLMProvider): } except Exception as e: - raise ProviderError( - f"火山方舟图片生成失败: {str(e)}", provider_id=self.provider_id, original_error=e - ) + raise self._wrap_error(e) async def create_embeddings(self, texts: list[str], model: str | None = None, **kwargs) -> dict: """ @@ -431,9 +424,7 @@ class VolcengineProvider(LLMProvider): } except Exception as e: - raise ProviderError( - f"火山方舟向量化失败: {str(e)}", provider_id=self.provider_id, original_error=e - ) + raise self._wrap_error(e) async def health_check(self, model: str | None = None) -> ModelHealth: """健康检查""" @@ -466,9 +457,40 @@ class VolcengineProvider(LLMProvider): last_error=str(e), ) + def _wrap_error(self, e: Exception) -> PlatformError: + """把 SDK 异常翻译为标准 PlatformError""" + message = str(e) + status = getattr(e, "status_code", None) or getattr(e, "code", None) + + if status == 429 or "rate limit" in message.lower(): + return PlatformError( + message, platform="volcengine_ark", retryable=True, + error_type=PlatformErrorType.RATE_LIMIT, status_code=status, + ) + elif status in (401, 403) or "authentication" in message.lower(): + return PlatformError( + message, platform="volcengine_ark", retryable=False, + error_type=PlatformErrorType.AUTH_FAILED, status_code=status, + ) + elif status and status >= 500: + return PlatformError( + message, platform="volcengine_ark", retryable=True, + error_type=PlatformErrorType.SERVER_ERROR, status_code=status, + ) + elif "timeout" in message.lower() or isinstance(e, TimeoutError): + return PlatformError( + message, platform="volcengine_ark", retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) + else: + return PlatformError( + message, platform="volcengine_ark", retryable=False, + error_type=PlatformErrorType.UNKNOWN, + ) + @property def available_models(self) -> list[str]: - """返回可用模型列表(与 ai_models.yaml 配置保持一致)""" + """返回可用模型列表(与 platform-config.yaml 配置保持一致)""" return [ "doubao-seed-2-0-pro", "deepseek-v3-2", diff --git a/python-api/app/api/v1/caption.py b/python-api/app/api/v1/caption.py index 8acbaf7..97c59d7 100644 --- a/python-api/app/api/v1/caption.py +++ b/python-api/app/api/v1/caption.py @@ -7,8 +7,9 @@ import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request +from app.core.exceptions import PlatformError from app.schemas.caption import ( AutoAlignResult, AutoAlignSubmitRequest, @@ -19,7 +20,6 @@ from app.schemas.caption import ( ) from app.schemas.common import ApiResponse, success_response from app.services.volcengine_caption_service import ( - VolcengineCaptionError, VolcengineCaptionService, get_caption_service, ) @@ -29,22 +29,22 @@ router = APIRouter(prefix="/caption", tags=["Caption"]) @router.post("/submit", response_model=ApiResponse[CaptionTaskResponse]) -async def submit_caption_task(request: CaptionSubmitRequest): +async def submit_caption_task(request_body: CaptionSubmitRequest, request: Request): """ 提交字幕生成任务 提交音频/视频文件URL,生成带时间轴的字幕。 """ try: - service = await get_caption_service() + service = await get_caption_service(request) task_id = await service.submit_caption_task( - audio_url=request.audio_url, - language=request.language, - caption_type=request.caption_type, - use_punc=request.use_punc, - use_itn=request.use_itn, - words_per_line=request.words_per_line, - max_lines=request.max_lines, + audio_url=request_body.audio_url, + language=request_body.language, + caption_type=request_body.caption_type, + use_punc=request_body.use_punc, + use_itn=request_body.use_itn, + words_per_line=request_body.words_per_line, + max_lines=request_body.max_lines, ) return success_response( @@ -55,16 +55,16 @@ async def submit_caption_task(request: CaptionSubmitRequest): message="字幕任务已提交", ) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"提交字幕任务失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"提交字幕任务异常: {e}") raise HTTPException(status_code=500, detail=f"提交失败: {str(e)}") @router.get("/query/{task_id}", response_model=ApiResponse[CaptionResult]) -async def query_caption_task(task_id: str, blocking: bool = True): +async def query_caption_task(task_id: str, request: Request, blocking: bool = True): """ 查询字幕任务结果 @@ -73,21 +73,21 @@ async def query_caption_task(task_id: str, blocking: bool = True): blocking: 是否阻塞等待结果 (默认True) """ try: - service = await get_caption_service() + service = await get_caption_service(request) result = await service.query_caption_task(task_id, blocking=blocking) return success_response(data=result) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"查询字幕任务失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"查询字幕任务异常: {e}") raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") @router.post("/generate", response_model=ApiResponse[CaptionResult]) -async def generate_caption(request: CaptionSubmitRequest, max_wait_time: int = 120): +async def generate_caption(request_body: CaptionSubmitRequest, request: Request, max_wait_time: int = 120): """ 生成字幕(完整流程) @@ -95,23 +95,23 @@ async def generate_caption(request: CaptionSubmitRequest, max_wait_time: int = 1 适用于不需要异步处理的场景。 """ try: - service = await get_caption_service() + service = await get_caption_service(request) result = await service.generate_caption( - audio_url=request.audio_url, - language=request.language, - caption_type=request.caption_type, - use_punc=request.use_punc, - use_itn=request.use_itn, - words_per_line=request.words_per_line, - max_lines=request.max_lines, + audio_url=request_body.audio_url, + language=request_body.language, + caption_type=request_body.caption_type, + use_punc=request_body.use_punc, + use_itn=request_body.use_itn, + words_per_line=request_body.words_per_line, + max_lines=request_body.max_lines, max_wait_time=max_wait_time, ) return success_response(data=result) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"生成字幕失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"生成字幕异常: {e}") raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") @@ -119,7 +119,8 @@ async def generate_caption(request: CaptionSubmitRequest, max_wait_time: int = 1 @router.post("/generate-ass", response_model=ApiResponse[dict]) async def generate_ass( - request: CaptionSubmitRequest, + request_body: CaptionSubmitRequest, + request: Request, video_width: int = 1080, video_height: int = 1920, max_wait_time: int = 120, @@ -132,15 +133,15 @@ async def generate_ass( video_height: 视频高度(默认 1920) """ try: - service = await get_caption_service() + service = await get_caption_service(request) result = await service.generate_caption( - audio_url=request.audio_url, - language=request.language, - caption_type=request.caption_type, - use_punc=request.use_punc, - use_itn=request.use_itn, - words_per_line=request.words_per_line, - max_lines=request.max_lines, + audio_url=request_body.audio_url, + language=request_body.language, + caption_type=request_body.caption_type, + use_punc=request_body.use_punc, + use_itn=request_body.use_itn, + words_per_line=request_body.words_per_line, + max_lines=request_body.max_lines, max_wait_time=max_wait_time, ) @@ -165,22 +166,22 @@ async def generate_ass( @router.post("/generate-srt", response_model=ApiResponse[SrtSubtitleResponse]) -async def generate_srt(request: CaptionSubmitRequest, max_wait_time: int = 120): +async def generate_srt(request_body: CaptionSubmitRequest, request: Request, max_wait_time: int = 120): """ 生成 SRT 格式字幕(完整流程) 直接返回 SRT 格式字幕文件内容。 """ try: - service = await get_caption_service() + service = await get_caption_service(request) result = await service.generate_caption( - audio_url=request.audio_url, - language=request.language, - caption_type=request.caption_type, - use_punc=request.use_punc, - use_itn=request.use_itn, - words_per_line=request.words_per_line, - max_lines=request.max_lines, + audio_url=request_body.audio_url, + language=request_body.language, + caption_type=request_body.caption_type, + use_punc=request_body.use_punc, + use_itn=request_body.use_itn, + words_per_line=request_body.words_per_line, + max_lines=request_body.max_lines, max_wait_time=max_wait_time, ) @@ -193,28 +194,28 @@ async def generate_srt(request: CaptionSubmitRequest, max_wait_time: int = 120): ) ) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"生成SRT字幕失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"生成SRT字幕异常: {e}") raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") @router.post("/ata/submit", response_model=ApiResponse[CaptionTaskResponse]) -async def submit_auto_align_task(request: AutoAlignSubmitRequest): +async def submit_auto_align_task(request_body: AutoAlignSubmitRequest, request: Request): """ 提交自动字幕打轴任务 为已有字幕文本自动配上时间轴。 """ try: - service = await get_caption_service() + service = await get_caption_service(request) task_id = await service.submit_auto_align_task( - audio_url=request.audio_url, - audio_text=request.audio_text, - caption_type=request.caption_type, - sta_punc_mode=request.sta_punc_mode, + audio_url=request_body.audio_url, + audio_text=request_body.audio_text, + caption_type=request_body.caption_type, + sta_punc_mode=request_body.sta_punc_mode, ) return success_response( @@ -225,48 +226,48 @@ async def submit_auto_align_task(request: AutoAlignSubmitRequest): message="打轴任务已提交", ) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"提交打轴任务失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"提交打轴任务异常: {e}") raise HTTPException(status_code=500, detail=f"提交失败: {str(e)}") @router.get("/ata/query/{task_id}", response_model=ApiResponse[AutoAlignResult]) -async def query_auto_align_task(task_id: str, blocking: bool = True): +async def query_auto_align_task(task_id: str, request: Request, blocking: bool = True): """ 查询打轴任务结果 """ try: - service = await get_caption_service() + service = await get_caption_service(request) result = await service.query_auto_align_task(task_id, blocking=blocking) return success_response(data=result) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"查询打轴任务失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"查询打轴任务异常: {e}") raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") @router.post("/ata/align") -async def auto_align_caption(request: AutoAlignSubmitRequest, max_wait_time: int = 120): +async def auto_align_caption(request_body: AutoAlignSubmitRequest, request: Request, max_wait_time: int = 120): """ 自动字幕打轴(完整流程) 提交打轴任务并轮询结果,直接返回最终数据。 """ try: - logger.info(f"[Caption API] Auto align request: audio_url={request.audio_url[:50]}...") - service = await get_caption_service() + logger.info(f"[Caption API] Auto align request: audio_url={request_body.audio_url[:50]}...") + service = await get_caption_service(request) result = await service.auto_align_caption( - audio_url=request.audio_url, - audio_text=request.audio_text, - caption_type=request.caption_type, - sta_punc_mode=request.sta_punc_mode, + audio_url=request_body.audio_url, + audio_text=request_body.audio_text, + caption_type=request_body.caption_type, + sta_punc_mode=request_body.sta_punc_mode, max_wait_time=max_wait_time, ) logger.info( @@ -292,9 +293,9 @@ async def auto_align_caption(request: AutoAlignSubmitRequest, max_wait_time: int logger.info(f"[Caption API] Response data: {response_data}") return success_response(data=response_data) - except VolcengineCaptionError as e: + except PlatformError as e: logger.error(f"自动打轴失败: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise except Exception as e: logger.error(f"自动打轴异常: {e}") raise HTTPException(status_code=500, detail=f"打轴失败: {str(e)}") diff --git a/python-api/app/api/v1/vidu.py b/python-api/app/api/v1/vidu.py index 6129c4f..1025b5f 100644 --- a/python-api/app/api/v1/vidu.py +++ b/python-api/app/api/v1/vidu.py @@ -7,8 +7,8 @@ Vidu API 代理路由 """ import base64 -import hmac import hashlib +import hmac import json import logging import time @@ -19,11 +19,18 @@ from pydantic import BaseModel, Field from app.api.deps import get_current_user from app.config import get_settings +from app.core.exceptions import PlatformError from app.core.redis_client import get_redis_client from app.models.user import User +from app.platform_gateway import PlatformGateway from app.schemas.common import ApiResponse, success_response from app.services.vidu_service import ViduService, get_vidu_service + +def get_platform_gateway(request: Request) -> PlatformGateway: + """从 app.state 获取 PlatformGateway""" + return request.app.state.platform_gateway + logger = logging.getLogger(__name__) router = APIRouter(prefix="/vidu", tags=["Vidu"]) @@ -282,6 +289,8 @@ async def create_lip_sync_task( except HTTPException: raise + except PlatformError: + raise except Exception as e: logger.error(f"[Vidu] 提交对口型任务失败: {e}") raise HTTPException(status_code=500, detail=f"提交对口型任务失败: {e}") @@ -293,44 +302,72 @@ async def vidu_callback(request: Request): Vidu 对口型任务完成回调 Vidu 任务完成后主动 POST 通知此接口。 - 无需登录校验(Vidu 外部调用),但需通过 HMAC-SHA256 签名验证。 + 无需登录校验(Vidu 外部调用),但需通过 HMAC-SHA256 签名验证 + nonce 防重放。 """ - # 验证回调签名 - if not await _verify_vidu_callback(request): + settings = get_settings() + secret_key = settings.VIDU_API_KEY + + # 1. 基础 header 校验 + signature = request.headers.get("X-HMAC-SIGNATURE") + algorithm = request.headers.get("X-HMAC-ALGORITHM") + access_key = request.headers.get("X-HMAC-ACCESS-KEY") + signed_headers_str = request.headers.get("X-HMAC-SIGNED-HEADERS") + date = request.headers.get("Date") + nonce = request.headers.get("x-request-nonce") + + if not all([signature, algorithm, access_key, signed_headers_str, date, nonce]): + logger.warning("[Vidu] 回调缺少必要签名头") raise HTTPException(status_code=401, detail="回调签名验证失败") + if algorithm != "hmac-sha256" or access_key != "vidu": + logger.warning("[Vidu] 回调签名算法或 access_key 不匹配") + raise HTTPException(status_code=401, detail="回调签名验证失败") + + # 2. nonce 防重放检查 + redis = get_redis_client() + nonce_key = f"vidu:callback_nonce:{nonce}" + if await redis.exists(nonce_key): + logger.warning(f"[Vidu] 回调 nonce 已使用,可能为重放攻击: {nonce}") + raise HTTPException(status_code=401, detail="回调签名验证失败") + + # 3. HMAC 签名验证(统一走 Adapter) + if secret_key: + adapter = request.app.state.vidu_adapter + headers_dict = dict(request.headers) + body_bytes = await request.body() + if not await adapter.verify_signature( + headers_dict, body_bytes, secret_key, callback_url=str(request.url) + ): + logger.warning("[Vidu] 回调 HMAC 签名验证失败") + raise HTTPException(status_code=401, detail="回调签名验证失败") + + # 4. 标记 nonce 已使用(TTL 5 分钟) + await redis.setex(nonce_key, 300, "1") + + # 5. 解析回调体(统一走 Adapter) try: - body = await request.json() - # Vidu 回调用 "id" 作为任务标识(查询接口也用 id),不是 "task_id" - task_id = body.get("id") or body.get("task_id") - state = body.get("state") - creations = body.get("creations", []) - - # 提取视频 URL - video_url = None - if state == "success" and creations: - first = creations[0] if creations else {} - video_url = first.get("url") - - # 更新 Redis 状态 - redis = get_redis_client() - await redis.setex( - f"vidu:lipsync:{task_id}", - 3600, - json.dumps({ - "state": state, - "video_url": video_url, - "message": body.get("message"), - "updated_at": time.time(), - }), - ) - - logger.info(f"[Vidu] 回调接收: task_id={task_id}, state={state}") - return success_response(message="回调已接收") - + task_status = await request.app.state.vidu_adapter.parse_callback(body_bytes) except Exception as e: - logger.error(f"[Vidu] 回调处理失败: {e}") - raise HTTPException(status_code=500, detail=f"回调处理失败: {e}") + logger.error(f"[Vidu] 回调解析失败: {e}") + raise HTTPException(status_code=500, detail=f"回调解析失败: {e}") + + # 6. 更新 Redis 状态 + task_id = task_status.result.get("task_id") if task_status.result else None + video_url = task_status.result.get("video_url") if task_status.result else None + + await redis.setex( + f"vidu:lipsync:{task_id}", + 3600, + json.dumps({ + "state": task_status.state, + "video_url": video_url, + "message": task_status.error_message, + "updated_at": time.time(), + }), + ) + + logger.info(f"[Vidu] 回调接收: task_id={task_id}, state={task_status.state}") + return success_response(message="回调已接收") @router.get("/tasks/{task_id}/status", response_model=ApiResponse[LipSyncStatusResponse]) @@ -381,6 +418,8 @@ async def query_lip_sync_status( ) ) + except PlatformError: + raise except Exception as e: logger.error(f"[Vidu] 查询任务状态失败: {e}") raise HTTPException(status_code=500, detail=f"查询任务状态失败: {e}") @@ -423,6 +462,8 @@ async def query_lip_sync_task( ) ) + except PlatformError: + raise except Exception as e: logger.error(f"[Vidu] 查询对口型任务失败: {e}") raise HTTPException(status_code=500, detail=f"查询任务失败: {e}") diff --git a/python-api/app/api/v1/voice.py b/python-api/app/api/v1/voice.py index 6713836..dcc6aa1 100644 --- a/python-api/app/api/v1/voice.py +++ b/python-api/app/api/v1/voice.py @@ -11,9 +11,11 @@ import re import uuid from pathlib import Path -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +import httpx +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from pydantic import BaseModel, Field +from app.core.exceptions import PlatformError from app.schemas.common import ApiResponse, success_response from app.services.qiniu_service import QiniuService from app.services.vidu_service import ( @@ -252,6 +254,8 @@ async def synthesize_speech( except ValueError as e: logger.warning(f"[Voice] TTS 参数错误: {e}") raise HTTPException(status_code=422, detail=str(e)) + except PlatformError: + raise except Exception as e: logger.error(f"[Voice] TTS 合成失败: {e}") raise HTTPException(status_code=500, detail=f"合成失败: {str(e)}") @@ -288,10 +292,14 @@ async def synthesize_batch( "filename": seg.get("filename"), }) except Exception as e: + # 批量处理:单个 segment 失败记录到结果中,不阻断其他 segment + error_msg = str(e) + if isinstance(e, PlatformError): + error_msg = f"[{e.platform}] {e.error_type}: {e}" results.append({ "index": seg.get("index", 0), "success": False, - "error": str(e), + "error": error_msg, "filename": seg.get("filename"), }) @@ -308,6 +316,8 @@ async def synthesize_batch( message=f"批量合成完成:成功 {success_count} 段,失败 {failed_count} 段", ) + except PlatformError: + raise except Exception as e: logger.error(f"[Voice] 批量 TTS 失败: {e}") raise HTTPException(status_code=500, detail=f"批量合成失败: {str(e)}") @@ -315,9 +325,10 @@ async def synthesize_batch( @router.post("/synthesize-file", response_model=ApiResponse[dict]) async def synthesize_to_file( - request: TTSSynthesizeRequest, + request_body: TTSSynthesizeRequest, output_path: str, service: ViduService = Depends(get_vidu_service), + request: Request = None, ): """ TTS 合成并保存到指定路径 @@ -326,20 +337,23 @@ async def synthesize_to_file( """ try: audio_url = await service.synthesize( - text=request.text, - voice_id=request.voice_id, - speed=request.speed, - volume=request.volume, - pitch=request.pitch, + text=request_body.text, + voice_id=request_body.voice_id, + speed=request_body.speed, + volume=request_body.volume, + pitch=request_body.pitch, ) # 下载音频并保存到指定路径 - import httpx - async with httpx.AsyncClient() as client: + client = request.app.state.http_clients["default"] if request else httpx.AsyncClient(timeout=30.0) + try: response = await client.get(audio_url) response.raise_for_status() Path(output_path).parent.mkdir(parents=True, exist_ok=True) Path(output_path).write_bytes(response.content) + finally: + if not request: + await client.aclose() return success_response( data={ @@ -353,6 +367,8 @@ async def synthesize_to_file( except ValueError as e: logger.warning(f"[Voice] TTS 参数错误: {e}") raise HTTPException(status_code=422, detail=str(e)) + except PlatformError: + raise except Exception as e: logger.error(f"[Voice] TTS 文件保存失败: {e}") raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}") @@ -418,6 +434,8 @@ async def submit_clone_task( except ValueError as e: logger.warning(f"[Voice] 克隆参数错误: {e}") raise HTTPException(status_code=422, detail=str(e)) + except PlatformError: + raise except Exception as e: logger.error(f"[Voice] 提交克隆任务失败: {e}") raise HTTPException(status_code=500, detail=f"提交失败: {str(e)}") @@ -471,6 +489,8 @@ async def clone_and_wait( except ValueError as e: logger.warning(f"[Voice] 克隆参数错误: {e}") raise HTTPException(status_code=422, detail=str(e)) + except PlatformError: + raise except Exception as e: logger.error(f"[Voice] 克隆失败: {e}") raise HTTPException(status_code=500, detail=f"克隆失败: {str(e)}") @@ -512,6 +532,8 @@ async def create_lip_sync( except ValueError as e: logger.warning(f"[Voice] 对口型参数错误: {e}") raise HTTPException(status_code=422, detail=str(e)) + except PlatformError: + raise except Exception as e: logger.error(f"[Voice] 对口型任务创建失败: {e}") raise HTTPException(status_code=500, detail=f"创建失败: {str(e)}") @@ -545,6 +567,5 @@ async def query_lip_sync( message=f"任务状态: {state}", ) - except Exception as e: - logger.error(f"[Voice] 查询对口型任务失败: {e}") - raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") + except PlatformError: + raise diff --git a/python-api/app/config.py b/python-api/app/config.py index 63e7328..c1d34f2 100644 --- a/python-api/app/config.py +++ b/python-api/app/config.py @@ -85,17 +85,14 @@ class Settings(BaseSettings): # AI 模型配置 # 字节跳动 - 火山方舟 # 文档:https://www.volcengine.com/docs/82379/1399009 + # base_url 已从 Settings 移除,改用 config/platform-config.yaml 配置 VOLCENGINE_API_KEY: str | None = Field(default=None, description="火山方舟 API Key") - VOLCENGINE_BASE_URL: str = Field( - default="https://ark.cn-beijing.volces.com/api/v3", - description="火山方舟 Base URL", - ) VOLCENGINE_MODEL: str = Field( default="doubao-seed-2-0-lite-260215", description="火山方舟默认模型(Model ID)", ) - # 火山引擎音视频字幕服务(字幕功能仍使用火山引擎) + # 火山引擎音视频字幕服务 VOLCENGINE_CAPTION_APPID: str | None = Field(default=None, description="火山字幕 AppID") VOLCENGINE_CAPTION_TOKEN: str | None = Field(default=None, description="火山字幕 Token") @@ -119,12 +116,8 @@ class Settings(BaseSettings): HEYGEN_API_KEY: str | None = Field(default=None, description="HeyGen API Key") DID_API_KEY: str | None = Field(default=None, description="D-ID API Key") - # Vidu 配置 + # Vidu 密钥(base_url 已从 Settings 移除,改用 config/platform-config.yaml 配置) VIDU_API_KEY: str | None = Field(default=None, description="Vidu API Key") - VIDU_BASE_URL: str = Field( - default="https://api.vidu.cn", - description="Vidu Base URL", - ) # 七牛云存储配置 QINIU_ACCESS_KEY: str | None = Field(default=None, description="七牛云 Access Key") @@ -134,24 +127,7 @@ class Settings(BaseSettings): QINIU_IMAGE_BUCKET: str = Field(default="img-liche", description="图片存储 Bucket") QINIU_IMAGE_DOMAIN: str = Field(default="img.liche.cn", description="图片存储域名") - # AnyToCopy 文案提取服务 - ANYTOCOPY_API_KEY: str | None = Field(default=None, description="AnyToCopy API Key") - ANYTOCOPY_API_SECRET: str | None = Field(default=None, description="AnyToCopy API Secret") - ANYTOCOPY_BASE_URL: str = Field( - default="https://api.anytocopy.com/vip/open-api/v1", - description="AnyToCopy Base URL", - ) - # Async Engine 槽位配置 - ANYTOCOPY_MAX_CONCURRENT: int = Field(default=5, description="AnyToCopy文案提取最大并发数") - VOLC_SUBTITLE_MAX_CONCURRENT: int = Field(default=5, description="火山字幕生成最大并发数") - - # 任务超时配置(秒) - VOLC_SUBTITLE_TIMEOUT: int = Field(default=600, description="火山字幕生成超时时间(秒)") - - # AnyToCopy 轮询配置 - ANYTOCOPY_POLL_INTERVAL: float = Field(default=3.0, description="AnyToCopy轮询间隔(秒)") - ANYTOCOPY_MAX_POLL: int = Field(default=60, description="AnyToCopy最大轮询次数") # 日志配置 LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field( diff --git a/python-api/app/core/config_loader.py b/python-api/app/core/config_loader.py index 77fe2cc..ee8c247 100644 --- a/python-api/app/core/config_loader.py +++ b/python-api/app/core/config_loader.py @@ -58,7 +58,7 @@ class AIModelConfigLoader: """ DEFAULT_CONFIG_PATH = ( - Path(__file__).parent.parent.parent / "config" / "ai_models.yaml" + Path(__file__).parent.parent.parent / "config" / "platform-config.yaml" ) def __init__(self, config_path: str | None = None): @@ -111,21 +111,40 @@ class AIModelConfigLoader: base_url=pdata.get("base_url", ""), ) - # 解析模型 + # 从平台内嵌的 models 列表中提取模型(platform-config.yaml 格式) + for mdata in pdata.get("models", []): + mid = mdata.get("id") + if not mid: + continue + self._models[mid] = ModelConfig( + id=mid, + platform_id=pid, + model_name=mdata.get("model_name", mid), + display_name=mdata.get("display_name", mid), + capabilities=mdata.get("capabilities", []), + default_params=mdata.get("default_params", {}), + is_enabled=mdata.get("is_enabled", True), + cost_per_1k_input=mdata.get("cost_per_1k_input", 0.0), + cost_per_1k_output=mdata.get("cost_per_1k_output", 0.0), + max_tokens_limit=mdata.get("max_tokens_limit", 4096), + ) + + # 兼容旧格式:顶层的 models 字典 models_data = config.get("models", {}) for mid, mdata in models_data.items(): - self._models[mid] = ModelConfig( - id=mid, - platform_id=mdata.get("platform_id", ""), - model_name=mdata.get("model_name", mid), - display_name=mdata.get("display_name", mid), - capabilities=mdata.get("capabilities", []), - default_params=mdata.get("default_params", {}), - is_enabled=mdata.get("is_enabled", True), - cost_per_1k_input=mdata.get("cost_per_1k_input", 0.0), - cost_per_1k_output=mdata.get("cost_per_1k_output", 0.0), - max_tokens_limit=mdata.get("max_tokens_limit", 4096), - ) + if mid not in self._models: + self._models[mid] = ModelConfig( + id=mid, + platform_id=mdata.get("platform_id", ""), + model_name=mdata.get("model_name", mid), + display_name=mdata.get("display_name", mid), + capabilities=mdata.get("capabilities", []), + default_params=mdata.get("default_params", {}), + is_enabled=mdata.get("is_enabled", True), + cost_per_1k_input=mdata.get("cost_per_1k_input", 0.0), + cost_per_1k_output=mdata.get("cost_per_1k_output", 0.0), + max_tokens_limit=mdata.get("max_tokens_limit", 4096), + ) # 解析任务默认映射 self._task_defaults = config.get("task_defaults", {}) diff --git a/python-api/app/core/exceptions.py b/python-api/app/core/exceptions.py index 22b9f2e..374261e 100644 --- a/python-api/app/core/exceptions.py +++ b/python-api/app/core/exceptions.py @@ -1,11 +1,22 @@ """ 自定义异常类 ============ + +分层异常体系: +- AppException: 业务层错误(参数校验、权限、资源不存在等) +- PlatformError: 第三方平台调用错误(网络、限流、认证、服务异常等) + +Router 层只处理这两类异常,其余全部兜底为 500。 """ from fastapi import HTTPException, status +# ═══════════════════════════════════════════════════════════════ +# 业务层异常(AppException 体系) +# ═══════════════════════════════════════════════════════════════ + + class AppException(HTTPException): """应用基础异常""" @@ -87,3 +98,71 @@ class TaskFailedException(AppException): status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message=message, ) + + +# ═══════════════════════════════════════════════════════════════ +# 第三方平台异常(PlatformError 体系) +# ═══════════════════════════════════════════════════════════════ + + +class PlatformErrorType: + """第三方错误类型标准枚举 + + 所有 Adapter 必须将供应商异常映射为以下标准类型之一, + 确保前端和网关能够统一处理。 + """ + + RATE_LIMIT = "rate_limit" # 429,可重试 + AUTH_FAILED = "auth_failed" # 401/403,不可重试 + TIMEOUT = "timeout" # 连接/读取超时,可重试 + SERVER_ERROR = "server_error" # 第三方 5xx,可重试 + BAD_REQUEST = "bad_request" # 参数错误,不可重试 + QUOTA_EXHAUSTED = "quota_exhausted" # 额度用完,不可重试(或延迟重试) + NOT_FOUND = "not_found" # 资源不存在,不可重试 + UNKNOWN = "unknown" # 兜底 + + +class PlatformError(Exception): + """第三方平台调用失败的唯一异常类 + + Router 层只需 except PlatformError,即可返回标准 HTTP 状态码。 + 所有 app/services/ 和 app/ai/ 下的代码,对外抛出的异常必须是此类。 + + Example: + raise PlatformError( + "Vidu 限流", + platform="vidu", + retryable=True, + error_type=PlatformErrorType.RATE_LIMIT, + status_code=429, + ) + """ + + def __init__( + self, + message: str, + *, + platform: str, + retryable: bool = False, + error_type: str = PlatformErrorType.UNKNOWN, + status_code: int | None = None, + ): + super().__init__(message) + self.platform = platform + self.retryable = retryable + self.error_type = error_type + self.status_code = status_code + + def to_http_status(self) -> int: + """根据 error_type 和 retryable 返回标准 HTTP 状态码""" + mapping = { + PlatformErrorType.RATE_LIMIT: 429, + PlatformErrorType.QUOTA_EXHAUSTED: 429, + PlatformErrorType.TIMEOUT: 504, + PlatformErrorType.AUTH_FAILED: 401, + PlatformErrorType.BAD_REQUEST: 400, + PlatformErrorType.NOT_FOUND: 404, + } + if self.error_type in mapping: + return mapping[self.error_type] + return 502 if self.retryable else 400 diff --git a/python-api/app/core/platform_config.py b/python-api/app/core/platform_config.py new file mode 100644 index 0000000..01b2609 --- /dev/null +++ b/python-api/app/core/platform_config.py @@ -0,0 +1,135 @@ +""" +平台配置加载器 +============== + +从 config/platform-config.yaml 加载平台配置, +包含:平台连接信息、模型列表、方法配置、限流参数。 + +运行时策略(fallback_chains、timeouts、ttl)通过 RuntimeConfig 读取。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +try: + import yaml + + YAML_AVAILABLE = True +except ImportError: + YAML_AVAILABLE = False + + +@dataclass +class MethodConfig: + """方法级配置""" + + name: str + timeout: int = 30 + max_connections: int = 20 + rate_limit_qps: float = 10.0 + rate_limit_burst: int = 20 + + +@dataclass +class PlatformConfig: + """平台配置""" + + id: str + name: str + provider: str + base_url: str = "" + rate_limit_qps: float = 10.0 + rate_limit_burst: int = 20 + models: list[dict[str, Any]] = field(default_factory=list) + methods: dict[str, MethodConfig] = field(default_factory=dict) + + +class PlatformConfigLoader: + """平台配置加载器 + + 从 platform-config.yaml 的 platforms section 加载配置。 + """ + + DEFAULT_CONFIG_PATH = ( + Path(__file__).parent.parent.parent / "config" / "platform-config.yaml" + ) + + def __init__(self, config_path: str | None = None): + self.config_path = Path(config_path) if config_path else self.DEFAULT_CONFIG_PATH + self._platforms: dict[str, PlatformConfig] = {} + self._load() + + def _load(self) -> bool: + if not self.config_path.exists(): + logger.warning(f"平台配置文件不存在: {self.config_path}") + return False + + try: + with open(self.config_path, encoding="utf-8") as f: + if YAML_AVAILABLE: + config = yaml.safe_load(f) + else: + import json + + config = json.load(f) + + platforms_data = config.get("platforms", {}) + for pid, pdata in platforms_data.items(): + methods: dict[str, MethodConfig] = {} + for mname, mdata in pdata.get("methods", {}).items(): + rl = mdata.get("rate_limit", {}) + methods[mname] = MethodConfig( + name=mname, + timeout=mdata.get("timeout", 30), + max_connections=mdata.get("max_connections", 20), + rate_limit_qps=rl.get("qps", 10.0), + rate_limit_burst=rl.get("burst", 20), + ) + + prl = pdata.get("rate_limit", {}) + self._platforms[pid] = PlatformConfig( + id=pid, + name=pdata.get("name", pid), + provider=pdata.get("provider", pid), + base_url=pdata.get("base_url", ""), + rate_limit_qps=prl.get("qps", 10.0), + rate_limit_burst=prl.get("burst", 20), + models=pdata.get("models", []), + methods=methods, + ) + + logger.info(f"Platform config loaded: {len(self._platforms)} platforms") + return True + + except Exception as e: + logger.error(f"Platform config load failed: {e}") + return False + + def get_platform(self, platform_id: str) -> PlatformConfig | None: + return self._platforms.get(platform_id) + + def get_all_platforms(self) -> list[PlatformConfig]: + return list(self._platforms.values()) + + def get_method_config(self, platform_id: str, method: str) -> MethodConfig | None: + platform = self._platforms.get(platform_id) + if platform: + return platform.methods.get(method) + return None + + +# 全局单例 +_platform_config_loader: PlatformConfigLoader | None = None + + +def get_platform_config_loader() -> PlatformConfigLoader: + global _platform_config_loader + if _platform_config_loader is None: + _platform_config_loader = PlatformConfigLoader() + return _platform_config_loader diff --git a/python-api/app/core/runtime_config.py b/python-api/app/core/runtime_config.py new file mode 100644 index 0000000..75fc72e --- /dev/null +++ b/python-api/app/core/runtime_config.py @@ -0,0 +1,114 @@ +""" +运行时配置管理 +============== + +支持热重载的运行时配置,轮询检查文件 mtime(10秒间隔) ++ Admin API 手动触发 reload。 + +配置来源:config/platform-config.yaml 中的 runtime section +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +try: + import yaml + + YAML_AVAILABLE = True +except ImportError: + YAML_AVAILABLE = False + logger.warning("PyYAML 未安装") + + +class RuntimeConfig: + """运行时配置管理器 + + 从 platform-config.yaml 的 runtime section 加载配置, + 支持自动热重载(轮询检查 mtime)和手动强制重载。 + """ + + DEFAULT_CONFIG_PATH = ( + Path(__file__).parent.parent.parent / "config" / "platform-config.yaml" + ) + DEFAULT_POLL_INTERVAL = 10 # 秒 + + def __init__(self, config_path: str | None = None): + self.config_path = Path(config_path) if config_path else self.DEFAULT_CONFIG_PATH + self._config: dict[str, Any] = {} + self._last_modified: float = 0 + self._version: str = "" + self._load() + + def _load(self) -> bool: + """加载配置文件中的 runtime section""" + if not self.config_path.exists(): + logger.warning(f"配置文件不存在: {self.config_path}") + return False + + try: + with open(self.config_path, encoding="utf-8") as f: + if YAML_AVAILABLE: + config = yaml.safe_load(f) + else: + import json + + config = json.load(f) + + self._config = config.get("runtime", {}) + self._last_modified = self.config_path.stat().st_mtime + self._version = config.get("version", "") + logger.info( + f"Runtime config loaded: {len(self._config)} sections, " + f"version={self._version or 'none'}" + ) + return True + + except Exception as e: + logger.error(f"Runtime config load failed: {e}") + return False + + async def _reload_if_changed(self) -> bool: + """检查文件是否更新,如有则重载""" + try: + mtime = os.path.getmtime(self.config_path) + if mtime > self._last_modified: + logger.info(f"Runtime config changed, reloading: {self.config_path}") + return self._load() + except Exception as e: + logger.warning(f"Runtime config reload check failed: {e}") + return False + + async def get(self, key: str, default: Any = None) -> Any: + """获取配置项,自动检查是否需要重载""" + await self._reload_if_changed() + return self._config.get(key, default) + + async def force_reload(self) -> bool: + """Admin API 调用:手动强制重载""" + return self._load() + + @property + def version(self) -> str: + return self._version + + def get_raw(self) -> dict[str, Any]: + """获取原始配置(用于 Admin API 展示)""" + return self._config.copy() + + +# 全局运行时配置实例 +_runtime_config: RuntimeConfig | None = None + + +def get_runtime_config() -> RuntimeConfig: + """获取全局运行时配置单例""" + global _runtime_config + if _runtime_config is None: + _runtime_config = RuntimeConfig() + return _runtime_config diff --git a/python-api/app/main.py b/python-api/app/main.py index d011b4e..f76a20d 100644 --- a/python-api/app/main.py +++ b/python-api/app/main.py @@ -16,6 +16,7 @@ from fastapi.responses import JSONResponse from app.api.v1.router import api_router from app.config import get_settings +from app.core.exceptions import PlatformError from app.db.session import close_db, init_db from app.schemas.common import ApiResponse @@ -88,27 +89,114 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"Failed to load material config: {e}") - # 初始化 Vidu Provider & Service(全局复用) - # 初始化失败直接 raise,避免应用带着残缺状态运行,导致后续请求 500 + # 初始化 HTTP Client 池(各平台独立,故障隔离) + import httpx + + app.state.http_clients = { + "vidu": httpx.AsyncClient( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=20, max_keepalive_connections=20), + ), + "volcengine_caption": httpx.AsyncClient( + timeout=httpx.Timeout(60.0, connect=5.0), + limits=httpx.Limits(max_connections=10, max_keepalive_connections=10), + ), + "default": httpx.AsyncClient( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=50, max_keepalive_connections=20), + ), + } + logger.info("HTTP Client pool initialized") + + # 初始化各平台 Provider(注入共享 Client) from app.ai.providers.vidu_provider import ViduProvider from app.services.vidu_service import ViduService + from app.services.volcengine_caption_service import VolcengineCaptionService - app.state.vidu_provider = ViduProvider() + app.state.vidu_provider = ViduProvider(client=app.state.http_clients["vidu"]) app.state.vidu_service = ViduService(app.state.vidu_provider) logger.info("Vidu Provider & Service initialized") + # 火山字幕服务(始终初始化,因为 APPID/TOKEN 可能在后续配置) + try: + app.state.volcengine_caption_service = VolcengineCaptionService( + client=app.state.http_clients["volcengine_caption"] + ) + logger.info("Volcengine Caption Service initialized") + except Exception as e: + logger.warning(f"Volcengine Caption Service 初始化跳过: {e}") + app.state.volcengine_caption_service = None + + # 火山方舟 Provider(可选,需要 API Key) + try: + from app.ai.providers.volcengine_provider import VolcengineProvider + + app.state.volcengine_provider = VolcengineProvider() + logger.info("Volcengine Provider initialized") + except Exception as e: + logger.warning(f"Volcengine Provider 初始化跳过: {e}") + app.state.volcengine_provider = None + + # 初始化 Adapter(包装 Provider) + from app.ai.adapters.vidu_adapter import ViduAdapter + from app.ai.adapters.volcengine_caption_adapter import VolcengineCaptionAdapter + from app.platform_gateway import PlatformGateway + + app.state.vidu_adapter = ViduAdapter(app.state.vidu_provider) + logger.info("ViduAdapter initialized") + + # 初始化 Gateway + app.state.platform_gateway = PlatformGateway() + app.state.platform_gateway.register("vidu", app.state.vidu_adapter) + + if app.state.volcengine_caption_service: + app.state.volcengine_caption_adapter = VolcengineCaptionAdapter( + app.state.volcengine_caption_service + ) + app.state.platform_gateway.register( + "volcengine_caption", app.state.volcengine_caption_adapter + ) + logger.info("VolcengineCaptionAdapter initialized") + + logger.info("PlatformGateway initialized") + + # LLM Gateway(可选,需要 Volcengine Provider) + if app.state.volcengine_provider: + from app.ai.adapters.volcengine_ark_adapter import VolcengineArkAdapter + from app.ai.gateways.llm_gateway import LLMGateway + + app.state.volcengine_ark_adapter = VolcengineArkAdapter( + app.state.volcengine_provider + ) + app.state.llm_gateway = LLMGateway( + adapters={"volcengine_ark": app.state.volcengine_ark_adapter}, + fallback_chains={}, + ) + logger.info("LLMGateway initialized") + else: + app.state.llm_gateway = None + yield # 关闭时清理 logger.info("Shutting down...") - # 关闭 Vidu Provider(释放连接池) - if hasattr(app.state, "vidu_provider"): + # 关闭所有 HTTP Client + for name, client in app.state.http_clients.items(): try: - await app.state.vidu_provider.close() - logger.info("Vidu Provider closed") + if not client.is_closed: + await client.aclose() + logger.info(f"HTTP Client closed: {name}") except Exception as e: - logger.warning(f"Vidu Provider close error: {e}") + logger.warning(f"HTTP Client close error: {name}: {e}") + + # 关闭 Gateway(内部会关闭所有 Adapter) + if hasattr(app.state, "platform_gateway"): + try: + await app.state.platform_gateway.close_all() + logger.info("PlatformGateway closed") + except Exception as e: + logger.warning(f"PlatformGateway close error: {e}") await close_db() logger.info("Cleanup complete") @@ -140,6 +228,24 @@ def create_app() -> FastAPI: # 注册路由 app.include_router(api_router, prefix="/api/v1") + # PlatformError 全局处理器(第三方平台错误) + @app.exception_handler(PlatformError) + async def platform_error_handler(request, exc: PlatformError): + """第三方平台调用错误统一处理""" + http_status = exc.to_http_status() + content = { + "code": exc.status_code or http_status, + "message": str(exc), + "data": None, + } + if settings.DEBUG: + content["detail"] = { + "platform": exc.platform, + "error_type": exc.error_type, + "retryable": exc.retryable, + } + return JSONResponse(status_code=http_status, content=content) + # 全局异常处理(统一返回 ApiResponse 格式) @app.exception_handler(Exception) async def global_exception_handler(request, exc): diff --git a/python-api/app/models/__init__.py b/python-api/app/models/__init__.py index 574fb1b..06d9ac4 100644 --- a/python-api/app/models/__init__.py +++ b/python-api/app/models/__init__.py @@ -3,7 +3,7 @@ 所有 SQLAlchemy 模型定义。 -注意:AIModel/AIPlatform 已迁移到 YAML 配置 (config/ai_models.yaml) +注意:AIModel/AIPlatform 已迁移到 YAML 配置 (config/platform-config.yaml) """ from app.models.base import BaseModel diff --git a/python-api/app/platform_gateway.py b/python-api/app/platform_gateway.py new file mode 100644 index 0000000..9eb72dd --- /dev/null +++ b/python-api/app/platform_gateway.py @@ -0,0 +1,162 @@ +""" +第三方平台统一调用网关 +======================== + +所有第三方平台调用的唯一入口。 +- 同步调用:call_sync() +- 异步任务提交:submit_task() +- 任务状态查询:query_task() +- 回调处理:handle_webhook() +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.ai.adapters.base import ( + AdapterResponse, + CallbackCapable, + PlatformAdapter, + SyncCapable, + TaskCapable, + TaskStatus, +) +from app.core.exceptions import PlatformError, PlatformErrorType + +logger = logging.getLogger(__name__) + + +class PlatformGateway: + """第三方平台统一调用网关""" + + def __init__(self, adapters: dict[str, PlatformAdapter] | None = None): + self.adapters: dict[str, PlatformAdapter] = adapters or {} + + def register(self, platform_id: str, adapter: PlatformAdapter) -> None: + """注册平台 Adapter""" + self.adapters[platform_id] = adapter + logger.info(f"PlatformGateway 注册平台: {platform_id}") + + def _get_sync_adapter(self, platform: str, method: str) -> SyncCapable: + """获取支持同步调用的 Adapter""" + adapter = self.adapters.get(platform) + if adapter is None: + raise ValueError(f"未注册的平台: {platform}") + if not isinstance(adapter, SyncCapable): + raise ValueError(f"平台 {platform} 不支持同步调用") + return adapter + + def _get_task_adapter(self, platform: str, task_type: str) -> TaskCapable: + """获取支持异步任务的 Adapter""" + adapter = self.adapters.get(platform) + if adapter is None: + raise ValueError(f"未注册的平台: {platform}") + if not isinstance(adapter, TaskCapable): + raise ValueError(f"平台 {platform} 不支持异步任务") + return adapter + + def _get_callback_adapter(self, platform: str) -> CallbackCapable: + """获取支持回调的 Adapter""" + adapter = self.adapters.get(platform) + if adapter is None: + raise ValueError(f"未注册的平台: {platform}") + if not isinstance(adapter, CallbackCapable): + raise ValueError(f"平台 {platform} 不支持回调") + return adapter + + # ── 同步调用 ── + + async def call_sync( + self, + platform: str, + method: str, + payload: dict[str, Any], + ) -> AdapterResponse: + """同步调用统一入口""" + adapter = self._get_sync_adapter(platform, method) + return await adapter.call(method, payload) + + # ── 异步任务 ── + + async def submit_task( + self, + platform: str, + task_type: str, + payload: dict[str, Any], + callback_url: str | None = None, + idempotency_key: str | None = None, + ) -> str: + """异步任务提交统一入口,返回 internal_job_id + + TODO: 接入 Async Engine 后,生成 internal_job_id 并写入 JobRegistry + """ + adapter = self._get_task_adapter(platform, task_type) + result = await adapter.submit(task_type, payload, callback_url) + + if not result.success: + raise PlatformError( + result.error_message or "任务提交失败", + platform=platform, + retryable=result.retryable, + error_type=PlatformErrorType.UNKNOWN, + ) + + # 当前直接返回 platform_task_id,后续接入 Async Engine 后返回 internal_job_id + return result.data.get("task_id", "") + + async def query_task(self, platform: str, platform_job_id: str) -> TaskStatus: + """任务状态查询统一入口""" + adapter = self._get_task_adapter(platform, "") + return await adapter.query(platform_job_id) + + # ── 回调处理 ── + + async def handle_webhook( + self, + platform: str, + headers: dict[str, str], + body: bytes, + secret: str | None = None, + callback_url: str | None = None, + ) -> TaskStatus: + """统一回调处理入口""" + adapter = self._get_callback_adapter(platform) + + if secret and not await adapter.verify_signature( + headers, body, secret, callback_url=callback_url + ): + raise PlatformError( + "回调签名验证失败", + platform=platform, + retryable=False, + error_type=PlatformErrorType.AUTH_FAILED, + ) + + return await adapter.parse_callback(body) + + # ── 生命周期 ── + + async def close_all(self) -> None: + """关闭所有 Adapter""" + for platform_id, adapter in self.adapters.items(): + try: + await adapter.close() + logger.info(f"Adapter 关闭: {platform_id}") + except Exception as e: + logger.warning(f"Adapter 关闭失败: {platform_id}: {e}") + + # ── 健康检查 ── + + async def health_check_all(self) -> dict[str, AdapterResponse]: + """检查所有平台健康状态""" + results = {} + for platform_id, adapter in self.adapters.items(): + try: + results[platform_id] = await adapter.health() + except Exception as e: + results[platform_id] = AdapterResponse( + success=False, + error_message=str(e), + ) + return results diff --git a/python-api/app/scheduler/handlers/subtitle_handler.py b/python-api/app/scheduler/handlers/subtitle_handler.py index 204208d..f518fce 100644 --- a/python-api/app/scheduler/handlers/subtitle_handler.py +++ b/python-api/app/scheduler/handlers/subtitle_handler.py @@ -28,6 +28,14 @@ class SubtitleHandler(AsyncHandler): slot_key = SLOT_KEY max_slots = MAX_SLOTS + def __init__(self, service: VolcengineCaptionService | None = None): + self.service = service + + def _get_service(self) -> VolcengineCaptionService: + if self.service is None: + self.service = VolcengineCaptionService() + return self.service + async def tick( self, jobs: list[Any], registry: JobRegistry, slots: SlotManager ) -> list[StateChange]: @@ -45,7 +53,7 @@ class SubtitleHandler(AsyncHandler): if volc_task_id: # 轮询 try: - service = VolcengineCaptionService() + service = self._get_service() if mode == "auto_align": result = await service.query_auto_align_task(volc_task_id, blocking=False) else: diff --git a/python-api/app/scheduler/main.py b/python-api/app/scheduler/main.py index 640ba20..972bb88 100644 --- a/python-api/app/scheduler/main.py +++ b/python-api/app/scheduler/main.py @@ -10,7 +10,6 @@ import logging import sys from app.scheduler.engine import AsyncEngine -from app.scheduler.handlers.copy_handler import CopyHandler from app.scheduler.handlers.script_handler import ScriptHandler from app.scheduler.handlers.subtitle_handler import SubtitleHandler @@ -28,11 +27,35 @@ def setup_logging() -> None: async def main() -> None: setup_logging() + + # 初始化共享 HTTP Client + import httpx + + http_client = httpx.AsyncClient( + timeout=httpx.Timeout(60.0, connect=5.0), + limits=httpx.Limits(max_connections=10, max_keepalive_connections=10), + ) + + # 初始化字幕服务(注入共享 Client) + from app.services.volcengine_caption_service import VolcengineCaptionService + + try: + caption_service = VolcengineCaptionService(client=http_client) + logger.info("字幕服务初始化完成") + except Exception as e: + logger.warning(f"字幕服务初始化失败: {e}") + caption_service = None + engine = AsyncEngine() - engine.register(SubtitleHandler()) - engine.register(CopyHandler()) + engine.register(SubtitleHandler(service=caption_service)) engine.register(ScriptHandler()) - await engine.run_forever(interval=10.0, min_interval=2.0) + + try: + await engine.run_forever(interval=10.0, min_interval=2.0) + finally: + if caption_service: + await caption_service.close() + await http_client.aclose() if __name__ == "__main__": diff --git a/python-api/app/services/vidu_service.py b/python-api/app/services/vidu_service.py index 162f00f..92bdac0 100644 --- a/python-api/app/services/vidu_service.py +++ b/python-api/app/services/vidu_service.py @@ -14,7 +14,7 @@ import asyncio import logging from typing import Any -import aiohttp +import httpx from fastapi import Request from tenacity import ( retry, @@ -117,7 +117,7 @@ class ViduService: @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5), - retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)), + retry=retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException)), reraise=True, ) async def synthesize( diff --git a/python-api/app/services/volcengine_caption_service.py b/python-api/app/services/volcengine_caption_service.py index 44af413..a724570 100644 --- a/python-api/app/services/volcengine_caption_service.py +++ b/python-api/app/services/volcengine_caption_service.py @@ -14,8 +14,10 @@ import json import logging import httpx +from fastapi import Request from app.config import get_settings +from app.core.exceptions import PlatformError, PlatformErrorType from app.schemas.caption import ( AutoAlignResult, CaptionResult, @@ -26,13 +28,43 @@ from app.schemas.caption import ( logger = logging.getLogger(__name__) -class VolcengineCaptionError(Exception): - """火山引擎字幕服务异常""" +def _map_caption_error(status: int, message: str, code: int | None = None) -> PlatformError: + """把火山字幕错误映射为标准 PlatformError""" + # 火山字幕业务错误码映射 + error_mapping = { + 1001: (PlatformErrorType.BAD_REQUEST, False), # 参数无效 + 1002: (PlatformErrorType.AUTH_FAILED, False), # 无权限 + 1003: (PlatformErrorType.RATE_LIMIT, True), # 超频(可重试) + 1010: (PlatformErrorType.BAD_REQUEST, False), # 音频过长 + 1011: (PlatformErrorType.BAD_REQUEST, False), # 音频过大 + 1012: (PlatformErrorType.BAD_REQUEST, False), # 格式无效 + 1013: (PlatformErrorType.BAD_REQUEST, False), # 音频静音 + } - def __init__(self, message: str, code: int = None, original_error: Exception = None): - super().__init__(message) - self.code = code - self.original_error = original_error + if code is not None and code in error_mapping: + error_type, retryable = error_mapping[code] + return PlatformError( + message, platform="volcengine_caption", + retryable=retryable, error_type=error_type, + status_code=status, + ) + + # HTTP 状态码映射 + http_mapping = { + 429: (PlatformErrorType.RATE_LIMIT, True), + 401: (PlatformErrorType.AUTH_FAILED, False), + 403: (PlatformErrorType.AUTH_FAILED, False), + 400: (PlatformErrorType.BAD_REQUEST, False), + 500: (PlatformErrorType.SERVER_ERROR, True), + 502: (PlatformErrorType.SERVER_ERROR, True), + 503: (PlatformErrorType.SERVER_ERROR, True), + } + error_type, retryable = http_mapping.get(status, (PlatformErrorType.UNKNOWN, False)) + return PlatformError( + message, platform="volcengine_caption", + retryable=retryable, error_type=error_type, + status_code=status, + ) class VolcengineCaptionService: @@ -59,30 +91,45 @@ class VolcengineCaptionService: 1013: "音频静音", } - def __init__(self, appid: str | None = None, token: str | None = None): + def __init__( + self, + appid: str | None = None, + token: str | None = None, + client: httpx.AsyncClient | None = None, + ): """ 初始化字幕服务 Args: appid: 应用ID,默认从 Settings 读取 token: 鉴权Token,默认从 Settings 读取 + client: 外部注入的 httpx.AsyncClient(由 lifespan 管理生命周期) """ settings = get_settings() self.appid = appid or settings.VOLCENGINE_CAPTION_APPID or "" self.token = token or settings.VOLCENGINE_CAPTION_TOKEN or "" if not self.appid: - raise VolcengineCaptionError("VOLCENGINE_CAPTION_APPID 未配置") + raise PlatformError( + "VOLCENGINE_CAPTION_APPID 未配置", + platform="volcengine_caption", + retryable=False, + error_type=PlatformErrorType.BAD_REQUEST, + ) if not self.token: - raise VolcengineCaptionError("VOLCENGINE_CAPTION_TOKEN 未配置") + raise PlatformError( + "VOLCENGINE_CAPTION_TOKEN 未配置", + platform="volcengine_caption", + retryable=False, + error_type=PlatformErrorType.BAD_REQUEST, + ) - self._client: httpx.AsyncClient | None = None - - async def _get_client(self) -> httpx.AsyncClient: - """获取 HTTP 客户端""" - if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) - return self._client + if client is not None: + self.client = client + self._owns_client = False + else: + self.client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) + self._owns_client = True def _get_headers(self) -> dict: """获取请求头""" @@ -119,7 +166,7 @@ class VolcengineCaptionService: Raises: VolcengineCaptionError: 提交失败 """ - client = await self._get_client() + client = self.client params = { "appid": self.appid, @@ -144,19 +191,31 @@ class VolcengineCaptionService: data = response.json() if "id" not in data: - raise VolcengineCaptionError(f"提交任务失败: {data.get('message', '未知错误')}") + raise _map_caption_error( + 500, + f"提交任务失败: {data.get('message', '未知错误')}", + ) task_id = data["id"] logger.info(f"字幕任务已提交: {task_id}") return task_id + except PlatformError: + raise except httpx.HTTPStatusError as e: - raise VolcengineCaptionError( + raise _map_caption_error( + e.response.status_code, f"HTTP错误: {e.response.status_code}", - original_error=e, - ) + ) from e + except (httpx.NetworkError, httpx.TimeoutException) as e: + raise PlatformError( + f"字幕服务网络错误: {e}", + platform="volcengine_caption", + retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) from e except Exception as e: - raise VolcengineCaptionError(f"提交任务失败: {str(e)}", original_error=e) + raise _map_caption_error(500, f"提交任务失败: {str(e)}") from e async def query_caption_task( self, @@ -176,7 +235,7 @@ class VolcengineCaptionService: Raises: VolcengineCaptionError: 查询失败 """ - client = await self._get_client() + client = self.client params = { "appid": self.appid, @@ -195,13 +254,22 @@ class VolcengineCaptionService: return self._parse_caption_result(data) + except PlatformError: + raise except httpx.HTTPStatusError as e: - raise VolcengineCaptionError( + raise _map_caption_error( + e.response.status_code, f"HTTP错误: {e.response.status_code}", - original_error=e, - ) + ) from e + except (httpx.NetworkError, httpx.TimeoutException) as e: + raise PlatformError( + f"字幕服务网络错误: {e}", + platform="volcengine_caption", + retryable=True, + error_type=PlatformErrorType.TIMEOUT, + ) from e except Exception as e: - raise VolcengineCaptionError(f"查询任务失败: {str(e)}", original_error=e) + raise _map_caption_error(500, f"查询任务失败: {str(e)}") from e async def generate_caption( self, @@ -258,17 +326,21 @@ class VolcengineCaptionService: # 仍在处理中 elapsed = asyncio.get_event_loop().time() - start_time if elapsed > max_wait_time: - raise VolcengineCaptionError(f"字幕生成超时: 已等待 {max_wait_time}s") + raise _map_caption_error( + 504, f"字幕生成超时: 已等待 {max_wait_time}s", + ) await asyncio.sleep(self.DEFAULT_POLL_INTERVAL) retries += 1 else: # 其他错误 error_msg = self.ERROR_CODES.get(result.code, f"未知错误: {result.code}") - raise VolcengineCaptionError( - f"字幕生成失败: {error_msg} ({result.message})", code=result.code + raise _map_caption_error( + 500, + f"字幕生成失败: {error_msg} ({result.message})", + code=result.code, ) - raise VolcengineCaptionError("字幕生成超时: 超过最大重试次数") + raise _map_caption_error(504, "字幕生成超时: 超过最大重试次数") async def submit_auto_align_task( self, @@ -289,7 +361,7 @@ class VolcengineCaptionService: Returns: 任务ID """ - client = await self._get_client() + client = self.client params = { "appid": self.appid, @@ -313,14 +385,19 @@ class VolcengineCaptionService: data = response.json() if "id" not in data: - raise VolcengineCaptionError(f"提交打轴任务失败: {data.get('message', '未知错误')}") + raise _map_caption_error( + 500, + f"提交打轴任务失败: {data.get('message', '未知错误')}", + ) task_id = data["id"] logger.info(f"打轴任务已提交: {task_id}") return task_id + except PlatformError: + raise except Exception as e: - raise VolcengineCaptionError(f"提交打轴任务失败: {str(e)}", original_error=e) + raise _map_caption_error(500, f"提交打轴任务失败: {str(e)}") from e async def query_auto_align_task( self, @@ -337,7 +414,7 @@ class VolcengineCaptionService: Returns: 打轴结果 """ - client = await self._get_client() + client = self.client params = { "appid": self.appid, @@ -370,8 +447,10 @@ class VolcengineCaptionService: utterances=caption_result.utterances, ) + except PlatformError: + raise except Exception as e: - raise VolcengineCaptionError(f"查询打轴任务失败: {str(e)}", original_error=e) + raise _map_caption_error(500, f"查询打轴任务失败: {str(e)}") from e async def auto_align_caption( self, @@ -413,16 +492,20 @@ class VolcengineCaptionService: elif result.code == 2000: elapsed = asyncio.get_event_loop().time() - start_time if elapsed > max_wait_time: - raise VolcengineCaptionError(f"打轴超时: 已等待 {max_wait_time}s") + raise _map_caption_error( + 504, f"打轴超时: 已等待 {max_wait_time}s", + ) await asyncio.sleep(self.DEFAULT_POLL_INTERVAL) retries += 1 else: error_msg = self.ERROR_CODES.get(result.code, f"未知错误: {result.code}") - raise VolcengineCaptionError( - f"打轴失败: {error_msg} ({result.message})", code=result.code + raise _map_caption_error( + 500, + f"打轴失败: {error_msg} ({result.message})", + code=result.code, ) - raise VolcengineCaptionError("打轴超时: 超过最大重试次数") + raise _map_caption_error(504, "打轴超时: 超过最大重试次数") def _parse_caption_result(self, data: dict) -> CaptionResult: """解析 API 响应为 CaptionResult""" @@ -543,24 +626,12 @@ class VolcengineCaptionService: return "\n".join(lines).strip() async def close(self): - """关闭 HTTP 客户端""" - if self._client and not self._client.is_closed: - await self._client.aclose() + """关闭 HTTP 客户端。仅在自己创建 Client 时关闭。""" + if self._owns_client and self.client and not self.client.is_closed: + await self.client.aclose() -# 全局服务单例 -_caption_service: VolcengineCaptionService | None = None - -async def get_caption_service() -> VolcengineCaptionService: - """获取字幕服务单例""" - global _caption_service - if _caption_service is None: - _caption_service = VolcengineCaptionService() - return _caption_service - - -def reset_caption_service(): - """重置字幕服务单例(用于测试)""" - global _caption_service - _caption_service = None +async def get_caption_service(request: Request) -> VolcengineCaptionService: + """FastAPI Depends:从 app.state 获取全局字幕服务实例。""" + return request.app.state.volcengine_caption_service diff --git a/python-api/config/platform-config.yaml b/python-api/config/platform-config.yaml new file mode 100644 index 0000000..733eabf --- /dev/null +++ b/python-api/config/platform-config.yaml @@ -0,0 +1,180 @@ +# 第三方平台统一配置文件 +# ======================= +# 包含:平台连接配置 + 模型列表 + 运行时策略 +# +# ⚠️ 密钥不在此文件配置!请通过 .env 文件设置: +# - Vidu: VIDU_API_KEY +# - 火山方舟: VOLCENGINE_API_KEY +# - 火山字幕: VOLCENGINE_CAPTION_APPID / VOLCENGINE_CAPTION_TOKEN + +platforms: + # ── Mock 平台(测试用)── + mock: + name: "Mock 测试平台" + provider: "mock" + base_url: "" + rate_limit: + qps: 999 + burst: 999 + models: + - id: "mock-model" + model_name: "mock-model" + display_name: "Mock 测试模型" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 2000 + is_enabled: true + + # ── 火山方舟(LLM)── + volcengine_ark: + name: "火山方舟" + provider: "volcengine" + base_url: "https://ark.cn-beijing.volces.com/api/v3" + rate_limit: + qps: 50 + burst: 100 + models: + - id: "doubao-seed-2-0-pro" + model_name: "doubao-seed-2-0-pro-260215" + display_name: "豆包 Seed 2.0 Pro" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + reasoning_effort: minimal + is_enabled: true + + - id: "deepseek-v3-2" + model_name: "deepseek-v3-2-251201" + display_name: "DeepSeek V3.2" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + is_enabled: true + cost_per_1k_input: 0.002 + cost_per_1k_output: 0.008 + max_tokens_limit: 64000 + + - id: "doubao-seed-character" + model_name: "doubao-seed-character-251128" + display_name: "豆包 Seed Character" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + is_enabled: true + + - id: "doubao-seed-2-0-lite" + model_name: "doubao-seed-2-0-lite-260215" + display_name: "豆包 Seed 2.0 Lite" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + is_enabled: true + + - id: "doubao-lite-32k" + model_name: "doubao-lite-32k-240828" + display_name: "豆包 Lite 32K" + capabilities: ["polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 2000 + is_enabled: true + cost_per_1k_input: 0.0005 + cost_per_1k_output: 0.001 + max_tokens_limit: 32000 + + methods: + chat: + timeout: 1800 + max_connections: 50 + rate_limit: + qps: 50 + burst: 100 + chat_stream: + timeout: 1800 + max_connections: 30 + rate_limit: + qps: 30 + burst: 50 + image_generate: + timeout: 60 + max_connections: 10 + rate_limit: + qps: 10 + burst: 20 + + # ── Vidu(TTS + 对口型)── + vidu: + name: "Vidu" + provider: "vidu" + base_url: "https://api.vidu.cn" + rate_limit: + qps: 20 + burst: 30 + models: [] + methods: + tts: + timeout: 30 + max_connections: 20 + rate_limit: + qps: 20 + burst: 30 + lip_sync: + timeout: 300 + max_connections: 10 + rate_limit: + qps: 5 + burst: 10 + clone_voice: + timeout: 60 + max_connections: 5 + rate_limit: + qps: 5 + burst: 10 + + # ── 火山引擎字幕(OpenSpeech)── + volcengine_caption: + name: "火山引擎字幕" + provider: "volcengine_caption" + base_url: "https://openspeech.bytedance.com/api/v1/vc" + rate_limit: + qps: 2 + burst: 5 + models: [] + methods: + caption: + timeout: 120 + max_connections: 10 + rate_limit: + qps: 2 + burst: 5 + auto_align: + timeout: 120 + max_connections: 5 + rate_limit: + qps: 2 + burst: 5 + +# ── 运行时全局策略 ── +runtime: + fallback_chains: + chat: + - "doubao-seed-2-0-pro" + - "doubao-seed-2-0-lite" + - "deepseek-v3-2" + + task_timeouts: + lip_sync: 1800 # 30分钟 + caption: 300 # 5分钟 + auto_align: 300 # 5分钟 + script: 300 # 5分钟 + + task_ttl: + lip_sync: 86400 # 完成后保留24h + caption: 3600 # 完成后保留1h + auto_align: 3600 # 完成后保留1h + script: 1800 # 完成后保留30min diff --git a/python-api/docker-compose.dev.yml b/python-api/docker-compose.dev.yml index b2eef46..93efbb0 100644 --- a/python-api/docker-compose.dev.yml +++ b/python-api/docker-compose.dev.yml @@ -26,7 +26,6 @@ services: - REDIS_DB=1 - SECRET_KEY=dev-secret-key-change-in-production - VIDU_API_KEY=${VIDU_API_KEY} - - VIDU_BASE_URL=${VIDU_BASE_URL:-https://api.vidu.cn} volumes: - .:/app - ~/Documents/Meijiaka-zy:/root/Documents/Meijiaka-zy diff --git a/python-api/docker-compose.prod.yml b/python-api/docker-compose.prod.yml index 10e87db..500824c 100644 --- a/python-api/docker-compose.prod.yml +++ b/python-api/docker-compose.prod.yml @@ -27,11 +27,7 @@ services: - REDIS_DB=${REDIS_DB:-0} - SECRET_KEY=${SECRET_KEY} - VOLCENGINE_API_KEY=${VOLCENGINE_API_KEY} - - VOLCENGINE_BASE_URL=${VOLCENGINE_BASE_URL:-https://ark.cn-beijing.volces.com/api/v3} - VIDU_API_KEY=${VIDU_API_KEY} - - VIDU_BASE_URL=${VIDU_BASE_URL:-https://api.vidu.cn} - - ANYTOCOPY_API_KEY=${ANYTOCOPY_API_KEY} - - ANYTOCOPY_API_SECRET=${ANYTOCOPY_API_SECRET} - QINIU_ACCESS_KEY=${QINIU_ACCESS_KEY} - QINIU_SECRET_KEY=${QINIU_SECRET_KEY} volumes: diff --git a/python-api/docker-compose.test.yml b/python-api/docker-compose.test.yml index 6325068..4e4eb95 100644 --- a/python-api/docker-compose.test.yml +++ b/python-api/docker-compose.test.yml @@ -55,11 +55,7 @@ services: - REDIS_DB=0 - SECRET_KEY=${SECRET_KEY} - VOLCENGINE_API_KEY=${VOLCENGINE_API_KEY} - - VOLCENGINE_BASE_URL=${VOLCENGINE_BASE_URL:-https://ark.cn-beijing.volces.com/api/v3} - VIDU_API_KEY=${VIDU_API_KEY} - - VIDU_BASE_URL=${VIDU_BASE_URL:-https://api.vidu.cn} - - ANYTOCOPY_API_KEY=${ANYTOCOPY_API_KEY} - - ANYTOCOPY_API_SECRET=${ANYTOCOPY_API_SECRET} volumes: - /opt/meijiaka-zy/data/logs:/root/Documents/Meijiaka-zy/logs ports: