Compare commits

...

18 Commits

Author SHA1 Message Date
xiaoxia 6227aa610c fix: Dashboard CSS 类名对齐已有样式 + 修复路由指向
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 9s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 11s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 5s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m2s
- 所有类名改回 dashboard.css 已有定义(xx-kpi-*/xx-task-*/xx-quick-*/xx-chart-*/xx-announcement-*)
- "查看全部"路由从 /asset-library 改为 /app/history
- vite build 验证通过
2026-07-13 16:57:21 +08:00
CI Test 3ebcc7e066 refactor: 补做 Phase 3 Mock 真替换 — accounts/Dashboard/GeneratePage
CI/CD Pipeline / Unit Tests (pull_request) Failing after 16s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 21s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 39s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 43s
- 删除 api/accounts.ts(163行全 mock),Accounts.tsx 改为占位状态
- Dashboard.tsx 移除硬编码 mock 数据(kpiData/quickEntries/recentTasks),改用空状态
- GeneratePage.tsx 移除 MOCK_TIMELINE 及 TimelineScene 接口,时间线预览改为占位提示
- 清理所有相关 TODO 注释和未使用的 import
- vite build 验证通过
2026-07-13 16:46:14 +08:00
CI Test 923c6bad1c feat(frontend): Phase 3 前端深度清理 — Mock 数据真替换 + Admin.css 全清
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m40s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m44s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 3m19s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. TitleLibrary.tsx: 移除 MOCK_CATEGORIES,分类从真实 API 数据动态派生
   - 删除 CategoryItem 接口、MOCK_CATEGORIES 常量
   - 用 useMemo 从 apiTitles.category 字段聚合分类列表
   - 分类筛选改为直接匹配 category 字段(移除 catToIndustry 硬编码映射)
   - 移除无后端支持的新建/删除分类功能(保留 TODO 注释)
   - TitleData 新增 category 字段

2. accounts.ts: 全文件标记为 Mock,添加明确 TODO
   - 文件头注明后端无 accounts 路由,待后端就绪后替换
   - 各 section 注释标注 mock 性质

3. Admin.css: 477 行 → 27 行
   - 仅保留 AdminComingSoon.tsx 实际使用的 2 个 class
   - 其余样式已迁移至 global.css / ui.css

4. 未使用依赖: 已确认 package.json 全部 26 个依赖均在使用,无需清理
2026-07-13 16:07:26 +08:00
xiaoxia f0dee5bbd3 fix(backend): Phase 2 后端技术债务清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 15s
CI/CD Pipeline / Unit Tests (push) Failing after 45s
CI/CD Pipeline / Integration Tests (push) Failing after 2m2s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m53s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #261: fix(backend): Phase 2 后端技术债务清理 — CI修复 + OSS拼写 + 废弃API删除 into develop
2026-07-13 15:54:13 +08:00
xiaoxia 50719db7c8 feat(frontend): Phase 2 前端技术债务清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 27s
CI/CD Pipeline / Unit Tests (push) Failing after 30s
CI/CD Pipeline / Integration Tests (push) Failing after 39s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m39s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #258: feat(frontend): Phase 2 前端技术债务清理(Mock 数据 + 样式迁移 + 组件合并) into develop
2026-07-13 15:53:58 +08:00
CI Bot c36ec5e780 docs: 修复 ci-env-vars.md 中 OSS_ENDPOINT 拼写 aliiyuncs → aliyuncs
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 18s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 46s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 41s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m16s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
与代码修复保持同步(PR #261 代码审计发现)
2026-07-13 15:47:29 +08:00
CI Test eb50442296 fix: 修复 PR #258 P1 CSS 变量问题
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 23s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 18s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m9s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 28s
- 删除 .xx-card:hover(global.css V21 设计系统已定义)
- var(--xx-border) → var(--border-color)(原变量未定义)
- var(--xx-text) → var(--text-primary)(原变量未定义)
- .ant-card-head padding 修正为 20px 24px(与原 Admin.css 一致)
- .ant-card-head-title color 对齐原 Admin.css 的 var(--slate) 语义
2026-07-13 15:35:10 +08:00
CI Bot 74a136e931 refactor: 删除 5 组废弃 API 路由及关联 schema
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m28s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 44s
删除的 API 路由(5 个文件):
- dashboard.py — 仪表盘概览(前端已改用 mock 数据)
- edit_templates.py — 模板 CRUD(无前端调用,领域层保留)
- generated_videos.py — 成片管理(已被 generation/tasks/{id}/results 替代)
- recipes.py — 配方 CRUD + use(无任何调用方)
- jobs.py — 通用异步任务管理(无调用方)

删除的 schema(3 个文件):
- schemas/dashboard.py — 仅 dashboard 路由使用
- schemas/recipe.py — 仅 recipes 路由使用
- schemas/job.py — 仅 jobs 路由使用
- schemas/generated_video.py 保留(generation_tasks.py 仍在使用)

清理 router.py 中对应的 5 组 import 和 include_router 注册。
同步清理 3 个已删路由的测试文件,移除 1 个测试类中的死引用。

路由总数:121 → 116(减少 5 组死接口)
2026-07-13 15:27:15 +08:00
xiaoxia 73375c6639 PR #252: redis_url配置修复
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 34s
CI/CD Pipeline / Unit Tests (push) Failing after 24s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m40s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 40s
fix: 修复worker配置中redis_url字段未正确映射REDIS_URL环境变量

Signed-off-by: CI Bot <bot@xiaoxiajianji.com>
2026-07-13 15:17:32 +08:00
CI Test 5bde975ea6 feat(frontend): Phase 2 前端技术债务清理
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 31s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 29s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m45s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 32s
1. Mock 数据清理(4 项):
   - Dashboard.tsx: kpiData/quickEntries/recentTasks 标记 TODO
   - TitleLibrary.tsx: MOCK_CATEGORIES 精简为占位+TODO
   - Accounts.tsx: 移除 2 处误导性 mock 注释(实际已用真实 API)

2. Admin.css 样式迁移:
   - 4 条 .xx-card antd 覆盖样式迁移到 ui.css
   - AdminComingSoon 页面样式不受影响

3. CloneModal 组件合并:
   - VoiceClone.tsx 改用共享 CloneModal 组件
   - 删除重复的 CloneVoiceModal.tsx + clone-voice-modal.css
   - 清理空目录 components/modals/
2026-07-13 15:15:47 +08:00
CI Bot 0634fc4833 fix: 修复 OSS_ENDPOINT 拼写错误 aliiyuncs → aliyun
OSS_ENDPOINT 默认值中的域名拼写错误:
- 错误:oss-cn-hangzhou.aliiyuncs.com(多了一个 i)
- 正确:oss-cn-hangzhou.aliyuncs.com

同步修复:
- apps/api/app/config.py
- packages/shared/config.py
- tests/unit/test_config_oss.py
2026-07-13 15:10:43 +08:00
CI Bot 0000c30ef2 fix: 恢复 GetGenerationTaskUseCase 导出
Phase 1 清理时误删了 packages/application/__init__.py 中的
GetGenerationTaskUseCase 导出,导致 generation_tasks.py 路由
ImportError。
2026-07-13 15:10:39 +08:00
xiaoxia cc47c9f90f chore(frontend): Phase 1 技术债务清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 28s
CI/CD Pipeline / Unit Tests (push) Failing after 32s
CI/CD Pipeline / Integration Tests (push) Failing after 24s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m53s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #251: chore(frontend): Phase 1 技术债务清理 - 删除死代码和重复样式 into develop
2026-07-13 15:06:45 +08:00
xiaoxia c1e466f9c1 fix(backend): Phase 1 后端代码清理与修复
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 25s
CI/CD Pipeline / Unit Tests (push) Failing after 26s
CI/CD Pipeline / Integration Tests (push) Failing after 25s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #249: fix(backend): Phase 1 后端代码清理与修复 into develop
2026-07-13 15:02:13 +08:00
CI Bot 8598638e8f fix: 恢复 tts_job/workflow.py 中误删的 TTSJobStatus import
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 49s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 43s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m56s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 第 207 行仍在使用 TTSJobStatus.COMPLETED.value
- 删除 import 会导致运行时 NameError
- pyflakes 验证零 undefined name 错误
2026-07-13 14:30:31 +08:00
xiaoxia c48ddeef7d fix: black/isort 格式化修复 - generation.py 和单测文件 (#250)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Successful in 2m12s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m27s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-13 14:13:45 +08:00
CI Test b87d7b763e chore(frontend): Phase 1 技术债务清理 - 删除死代码和重复样式
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m7s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 5m5s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 删除 src/config/navigation.tsx(废弃文件,211行)
- 删除 src/components/business/business.css(死CSS,365行)
- 删除 src/api/dashboard.ts(废弃API模块,42行)
- 移除 router/index.tsx 重复 my-voices 路由(7行)
- 清理 generate.css 重复按钮样式(48行,改用 ui.css 统一样式)
- 清理 Admin.css 重复 .xx-card/.xx-select 定义(40行)

共减少约 713 行代码,vite build 验证通过
2026-07-13 14:12:00 +08:00
CI Bot 9c6c477f55 fix(backend): Phase 1 后端代码清理与修复
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m3s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
P0 关键修复:
- P0-1: 注册接口添加 RateLimitMiddleware 限流保护
- P0-3: /metrics 端点添加 JWT 认证(移除匿名访问)
- P0-4: 修复 Celery 任务名冲突(generation_task vs generate_video)
- P1-5: JWT logout token 黑名单机制

P1 修复:
- P1-1: forgot_password 硬编码 localhost → 使用 settings.APP_BASE_URL
- P1-2: generation.py 直接创建 DB 连接 → 使用依赖注入
- P1-6: Image.open() 未关闭 → 统一使用 with 语句
- P1-7: 订阅续费事务修复

P2 代码质量:
- P2-1: 修复 EditingMode 枚举重复定义 → 统一引用 shared 包
- P2-2: 修复 SMTP_FRON_NAME → SMTP_FROM_NAME 拼写
- P2-3: UserModel subscription_quota 类型统一为 float
- P2-4: .env.production DATABASE_MAX_OVERFLOW 30 → 10
- 清理 15 处 except:pass(保留 2 处有注释说明的)
- 禁用 SVG 上传(XSS 风险)
- 删除 decode_token_unsafe() 不安全函数
- 简化 /ready 端点
- 删除 8 处死代码、10 个空文件/模块
- 合并 3 对 100% 重复函数
- 对齐 6 个废弃环境变量

v2 修复(代码审查后):
- 修复密码重置路由路径: /password/forgot → /forgot-password,
  /password/reset → /reset-password(与前端 API 对齐)
- 合并 _check_project_access: asset_libraries.py 和 edit_plans.py
  中的重复函数统一到 _helpers.py(含空字符串守卫 + 中文错误信息)
- 顺手修复: HTTPException 统一从 fastapi 导入(替换 starlette 导入)
- OSS_ENDPOINT 拼写修复拆分为单独 PR,本 PR 不包含
2026-07-13 13:50:52 +08:00
85 changed files with 447 additions and 5767 deletions
+4 -1
View File
@@ -3,6 +3,7 @@
# ==================== 应用配置 ====================
APP_NAME=小虾 SaaS
APP_BASE_URL=http://localhost:3000
APP_ENV=development
# ==================== 数据库配置 ====================
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
@@ -35,7 +36,8 @@ ENVIRONMENT=development
DEBUG=true
# ==================== CORS 配置 ====================
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
# ==================== 阿里云 OSS 配置 ====================
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
@@ -49,6 +51,7 @@ OSS_BUCKET_NAME=xiaoxia-autocut
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
-1
View File
@@ -1 +0,0 @@
"""API application package."""
-1
View File
@@ -1 +0,0 @@
"""API package."""
-29
View File
@@ -4,19 +4,14 @@ from app.api.routes.assets import router as assets_router
from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.edit_templates import router as edit_templates_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generated_videos import router as generated_videos_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.jobs import router as jobs_router
from app.api.routes.projects import router as projects_router
from app.api.routes.recipes import router as recipes_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
@@ -89,15 +84,6 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
jobs_router,
tags=["Job"],
)
api_router.include_router(
generated_videos_router,
prefix="/generated-videos",
tags=["GeneratedVideo"],
)
api_router.include_router(
titles_router,
prefix="/titles",
@@ -123,26 +109,11 @@ api_router.include_router(
prefix="/subscription",
tags=["Subscription"],
)
api_router.include_router(
recipes_router,
prefix="/recipes",
tags=["Recipe"],
)
api_router.include_router(
templates_router,
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
dashboard_router,
prefix="/dashboard",
tags=["Dashboard"],
)
api_router.include_router(
edit_templates_router,
prefix="/edit-templates",
tags=["EditTemplate"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
+48
View File
@@ -0,0 +1,48 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
from typing import Any
from fastapi import HTTPException, status
from packages.application import GetProjectUseCase
from packages.ports.user_repository import UserRepository
def check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限。
合并自 asset_libraries.py / edit_plans.py 的同名函数。
- 空 project_id 直接放行(兼容 edit_plans 中 project_id 可选的场景)
- 错误信息使用中文,与项目其他路由保持一致
"""
if not project_id or not project_id.strip():
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
"""获取用户的订阅计划名称。"""
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
def require_project_and_library(
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
"""Verify project and asset library exist."""
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
+3 -10
View File
@@ -22,18 +22,11 @@ from packages.application import (
)
from packages.domain import AssetLibrary, AssetLibraryKind
from ._helpers import check_project_access
router = APIRouter()
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
def _to_asset_library_response(item) -> AssetLibraryResponse:
return AssetLibraryResponse(
id=item.id,
@@ -168,7 +161,7 @@ def delete_asset_library(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
# 权限校验:检查用户是否有项目访问权限
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 删除库内所有素材(无 FK 级联,需手动清理)
assets_in_library = asset_repository.find_by_library(library_id)
+13 -19
View File
@@ -27,6 +27,8 @@ from packages.application import (
)
from packages.domain import AssetStatus, ClassificationStatus
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -72,14 +74,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
)
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
@router.get("", response_model=ListAssetsResponse)
def list_assets(
@@ -136,7 +130,7 @@ def list_assets(
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
check_project_access(library.project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
@@ -152,7 +146,7 @@ def list_assets(
# 模式2:指定 project_id
if project_id:
_check_project_access(project_id, user_id, project_repository)
check_project_access(project_id, user_id, project_repository)
if ft:
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
all_items = asset_repository.find_by_project(project_id)
@@ -210,13 +204,13 @@ def list_assets(
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
check_project_access(library.project_id, user_id, project_repository)
if kind:
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
else:
all_items = asset_repository.find_by_library(library_id)
elif project_id:
_check_project_access(project_id, user_id, project_repository)
check_project_access(project_id, user_id, project_repository)
all_items = asset_repository.find_by_project(project_id)
else:
try:
@@ -262,7 +256,7 @@ def update_asset_review_status(
item = asset_repository.get(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
_apply_asset_review_status(item, request.review_status)
updated = asset_repository.update(item)
return _to_asset_response(updated)
@@ -286,7 +280,7 @@ def batch_delete_assets(
failed_ids.append(asset_id)
continue
try:
_check_project_access(item.project_id, user_id, project_repository)
check_project_access(item.project_id, user_id, project_repository)
deleted_ids.append(asset_id)
except HTTPException:
failed_ids.append(asset_id)
@@ -307,7 +301,7 @@ def get_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
return _to_asset_response(item)
@@ -322,7 +316,7 @@ def update_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
# 合并可修改字段
if request.name is not None:
@@ -346,7 +340,7 @@ def delete_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
asset_repository.delete(asset_id)
@@ -363,7 +357,7 @@ def tag_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
for tag_id in request.tag_ids:
tag = tag_repository.get(tag_id)
if tag is None:
@@ -387,7 +381,7 @@ def untag_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
item.remove_tag(tag_id)
asset_repository.update(item)
+2 -3
View File
@@ -206,7 +206,7 @@ async def verify_email_post(
return _verify_email_token(request.token, user_repository)
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
async def forgot_password(
request: PasswordResetRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
@@ -223,7 +223,7 @@ async def forgot_password(
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
@router.post("/password/reset", response_model=MessageResponse)
@router.post("/reset-password", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordModel,
user_repository: UserRepository = Depends(get_user_repository),
@@ -243,7 +243,6 @@ async def logout(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""登出 - 将当前 token 加入黑名单"""
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
if credentials:
try:
+3 -19
View File
@@ -14,7 +14,6 @@ from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.config import get_settings
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
@@ -35,6 +34,8 @@ from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -113,22 +114,6 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def _require_project_and_library(
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
"""Verify project and asset library exist"""
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
"""Load upload metadata"""
meta_path = _get_upload_meta_path(upload_id)
@@ -206,7 +191,6 @@ async def init_chunked_upload(
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> ChunkedUploadInitResponse:
"""Initialize chunked upload"""
settings = get_settings()
# Validate file size
if request.file_size > MAX_FILE_SIZE:
@@ -221,7 +205,7 @@ async def init_chunked_upload(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Verify asset library
_require_project_and_library(
require_project_and_library(
request.project_id,
request.library_id,
project_repository,
-92
View File
@@ -1,92 +0,0 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_generation_task_repository,
get_project_repository,
get_title_library_repository,
get_voice_library_repository,
)
from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo
from fastapi import APIRouter, Depends
router = APIRouter()
def _status_value(status) -> str:
return status.value if hasattr(status, "value") else str(status)
def _generation_step(status: str) -> str:
if status == "pending":
return "等待 Worker 执行"
if status == "running":
return "正在生成成片"
if status == "completed":
return "生成完成"
if status == "failed":
return "生成失败"
return status
@router.get("/overview", response_model=DashboardOverviewResponse)
def get_dashboard_overview(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
title_library_repository: Any = Depends(get_title_library_repository),
voice_library_repository: Any = Depends(get_voice_library_repository),
) -> DashboardOverviewResponse:
"""Dashboard 概览:用户级汇总数据。"""
user_id = authenticated_user.user.id
# 获取用户可访问的所有 project
projects = project_repository.find_accessible_projects(user_id)
project_ids = [p.id for p in projects]
# 素材统计
total_assets = asset_repository.count_by_project_ids(project_ids)
used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids)
# 标题库 / 配音库统计
total_titles = title_library_repository.count_by_user(user_id)
total_voices = voice_library_repository.count_by_user(user_id)
# 生成任务统计
total_tasks = generation_task_repository.count_by_user(user_id)
# 最近任务(SQL 层 LIMIT 5
recent = generation_task_repository.list_recent_by_user(user_id, limit=5)
recent_tasks = []
for task in recent:
s = _status_value(task.status)
recent_tasks.append(
RecentTaskItem(
id=task.id,
task_type="generation",
status=s,
current_step=_generation_step(s),
error_message=task.error_message or "",
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
# 订阅信息
user = authenticated_user.user
subscription = SubscriptionInfo(
plan=getattr(user, "subscription_plan", "free") or "free",
is_active=getattr(user, "subscription_status", "") == "active",
)
return DashboardOverviewResponse(
total_assets=total_assets,
used_storage_bytes=used_storage_bytes,
total_titles=total_titles,
total_voices=total_voices,
total_tasks=total_tasks,
total_products=len(projects),
subscription=subscription,
recent_tasks=recent_tasks,
)
+16 -31
View File
@@ -32,12 +32,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
@@ -51,6 +45,8 @@ from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from ._helpers import check_project_access
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
@@ -247,17 +243,6 @@ class GenerateFromTemplateResponse(BaseModel):
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
if not project_id or not project_id.strip():
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
@@ -311,7 +296,7 @@ def list_plans(
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
_check_project_access(project_id, current_user.user.id, project_repository)
check_project_access(project_id, current_user.user.id, project_repository)
skip = (page - 1) * page_size
plans = svc.list_plans(
@@ -353,7 +338,7 @@ def get_plan(
)
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
return _to_response(plan)
@@ -369,7 +354,7 @@ def create_plan(
project_id = (body.project_id or "").strip()
# 项目鉴权
if project_id:
_check_project_access(project_id, current_user.user.id, project_repository)
check_project_access(project_id, current_user.user.id, project_repository)
svc = EditPlanService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_plan_config(body.config)
@@ -411,7 +396,7 @@ def update_plan(
if existing is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if existing.project_id:
_check_project_access(existing.project_id, current_user.user.id, project_repository)
check_project_access(existing.project_id, current_user.user.id, project_repository)
# 基础字段更新
try:
@@ -465,7 +450,7 @@ def delete_plan(
# 项目鉴权
existing = svc.get_plan(plan_id)
if existing and existing.project_id:
_check_project_access(existing.project_id, current_user.user.id, project_repository)
check_project_access(existing.project_id, current_user.user.id, project_repository)
deleted = svc.delete_plan(plan_id)
if not deleted:
raise HTTPException(
@@ -507,7 +492,7 @@ def generate_plan(
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
@@ -710,7 +695,7 @@ def generate_plan(
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception as exc:
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
@@ -749,7 +734,7 @@ def get_generation_status(
plan = gen_status["plan"]
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
@@ -791,7 +776,7 @@ def list_plan_generations(
# 验证计划存在 + 项目鉴权
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
@@ -860,7 +845,7 @@ def ai_recommend_clips(
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证状态:只允许 draft 或 editing
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
@@ -909,7 +894,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception as exc:
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
# 尝试回滚未提交的变更
try:
@@ -990,7 +975,7 @@ def generate_cover(
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 调用 AI 封面生成服务
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
@@ -1110,7 +1095,7 @@ def get_plan_timeline(
plan = svc.get_plan_or_raise(plan_id)
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
# 按 order 排序
@@ -1171,7 +1156,7 @@ def generate_from_template(
# 项目鉴权
if body.project_id:
_check_project_access(body.project_id, current_user.user.id, project_repository)
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
-289
View File
@@ -1,289 +0,0 @@
"""模板管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditTemplate:
- GET /api/v1/edit-templates 列表(分页 + 类型筛选)
- GET /api/v1/edit-templates/{id} 详情
- POST /api/v1/edit-templates 创建(管理员)
- PUT /api/v1/edit-templates/{id} 更新
- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive
业务逻辑委托给 EditTemplateService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_template_config
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditTemplateCreateRequest(BaseModel):
"""创建模板请求体"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=2000, description="模板描述")
template_type: str = Field(default="default", max_length=50, description="模板类型")
editing_mode: str = Field(
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
preview_url: str = Field(default="", max_length=500, description="预览地址")
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
class EditTemplateUpdateRequest(BaseModel):
"""更新模板请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
editing_mode: Optional[str] = Field(
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
status: Optional[str] = Field(default=None, description="状态: active / inactive")
class EditTemplateResponse(BaseModel):
"""模板响应体"""
id: str
name: str
description: str
template_type: str
editing_mode: str
config: dict[str, Any]
preview_url: str
sort_weight: int
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditTemplateListResponse(BaseModel):
"""模板列表响应体"""
items: List[EditTemplateResponse]
total: int
page: int
page_size: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _require_admin(current_user: AuthenticatedUser) -> None:
"""校验当前用户是否为管理员,非管理员返回 403"""
if not getattr(current_user.user, "is_admin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅管理员可执行此操作",
)
def _to_response(t: EditTemplate) -> EditTemplateResponse:
return EditTemplateResponse(
id=t.id,
name=t.name,
description=t.description,
template_type=t.template_type,
editing_mode=t.editing_mode,
config=t.config,
preview_url=t.preview_url,
sort_weight=t.sort_weight,
status=t.status.value if hasattr(t.status, "value") else t.status,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditTemplateListResponse)
def list_templates(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_type: Optional[str] = Query(default=None, description="按类型筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: active / inactive",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateListResponse:
"""获取模板列表(支持分页、按类型/状态筛选)"""
svc = EditTemplateService(db)
# 解析状态筛选
status_enum: Optional[EditTemplateStatus] = None
if status_filter:
try:
status_enum = EditTemplateStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {status_filter},可选值: active, inactive",
)
skip = (page - 1) * page_size
templates = svc.list_templates(
template_type=template_type,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_templates(
template_type=template_type,
status=status_enum,
)
return EditTemplateListResponse(
items=[_to_response(t) for t in templates],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{template_id}", response_model=EditTemplateResponse)
def get_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""获取单个模板详情"""
svc = EditTemplateService(db)
try:
template = svc.get_template_or_raise(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
return _to_response(template)
@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
body: EditTemplateCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""创建模板(管理员)"""
_require_admin(current_user)
svc = EditTemplateService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_template_config(body.config)
try:
created = svc.create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=normalized_config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id)
return _to_response(created)
@router.put("/{template_id}", response_model=EditTemplateResponse)
def update_template(
template_id: str,
body: EditTemplateUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""更新模板"""
_require_admin(current_user)
svc = EditTemplateService(db)
# 解析状态
status_enum: Optional[EditTemplateStatus] = None
if body.status is not None:
try:
status_enum = EditTemplateStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {body.status},可选值: active, inactive",
)
# 标准化 config(如果提供了)
config_to_update = normalize_template_config(body.config) if body.config is not None else None
try:
result = svc.update_template(
template_id,
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=config_to_update,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
status=status_enum,
)
except ValueError as exc:
err_msg = str(exc)
if "不存在" in err_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id)
return _to_response(result)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> Response:
"""删除模板(软删除 → 设为 inactive)"""
_require_admin(current_user)
svc = EditTemplateService(db)
try:
svc.deactivate_template(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id)
return Response(status_code=204)
-123
View File
@@ -1,123 +0,0 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository, get_project_repository
from app.schemas.generated_video import (
GeneratedVideoDownloadUrlResponse,
GeneratedVideoResponse,
ListGeneratedVideosResponse,
UpdateGeneratedVideoReviewRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
GetGeneratedVideoDownloadUrlUseCase,
GetGeneratedVideoUseCase,
ListGeneratedVideosUseCase,
)
router = APIRouter()
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
file_url=item.file_url,
file_size=item.file_size,
duration=item.duration,
thumbnail_url=item.thumbnail_url,
width=item.width,
height=item.height,
fps=item.fps,
status=item.status,
review_status=item.review_status,
generation_params=item.generation_params,
download_url=download_url,
)
@router.get("", response_model=ListGeneratedVideosResponse)
def list_generated_videos(
project_id: str | None = Query(None),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
user_id = authenticated_user.user.id
use_case = ListGeneratedVideosUseCase(generated_video_repository)
if project_id:
# If project_id provided, check access and filter by project
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
items = use_case.execute(project_id)
else:
# If no project_id, list all videos from accessible projects
accessible_projects = project_repository.find_accessible_projects(user_id)
all_items = []
for proj in accessible_projects:
all_items.extend(use_case.execute(proj.id))
items = all_items
# Generate download URLs for each video
responses = []
for item in items:
download_url = storage_service.get_download_url(item.file_url)
responses.append(_to_generated_video_response(item, download_url=download_url))
return ListGeneratedVideosResponse(items=responses)
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
def get_generated_video(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
use_case = GetGeneratedVideoUseCase(generated_video_repository)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(item.file_url)
return _to_generated_video_response(item, download_url=download_url)
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
def update_generated_video_review_status(
video_id: str,
request: UpdateGeneratedVideoReviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
video.review_status = request.review_status
updated = generated_video_repository.update(video)
download_url = storage_service.get_download_url(updated.file_url)
return _to_generated_video_response(updated, download_url=download_url)
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
def get_generated_video_download_url(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoDownloadUrlResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
file_url = use_case.execute(video_id)
if file_url is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(file_url)
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
+4 -11
View File
@@ -32,6 +32,8 @@ from app.schemas.generation_task import (
)
from fastapi import APIRouter, Depends, HTTPException
from app.api.routes._helpers import check_project_access
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -43,15 +45,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -339,7 +332,7 @@ def get_generation_task(
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
return _to_generation_task_response(task)
@@ -356,7 +349,7 @@ def list_generation_results(
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
responses = []
-332
View File
@@ -1,332 +0,0 @@
"""Job API 路由 — Phase 8 任务 2.10.
提供统一异步任务管理 RESTful 接口:
- POST /api/v1/jobs 创建任务
- GET /api/v1/jobs/{job_id} 任务详情
- GET /api/v1/projects/{project_id}/jobs 项目任务列表
- GET /api/v1/projects/{project_id}/jobs/stats 任务统计
- PUT /api/v1/jobs/{job_id}/progress 更新进度
- POST /api/v1/jobs/{job_id}/complete 标记完成
- POST /api/v1/jobs/{job_id}/fail 标记失败
- POST /api/v1/jobs/{job_id}/retry 重试任务
- POST /api/v1/jobs/{job_id}/cancel 取消任务
- POST /api/v1/jobs/{job_id}/submit 提交执行
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session, get_job_repository, get_project_repository
from app.schemas.job import (
CompleteJobRequest,
CreateJobRequest,
FailJobRequest,
JobResponse,
JobStatisticsResponse,
ListJobsResponse,
UpdateProgressRequest,
job_to_response,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
CompleteJobUseCase,
CreateJobCommand,
CreateJobUseCase,
FailJobCommand,
FailJobUseCase,
GetJobStatisticsUseCase,
GetJobUseCase,
ListJobsUseCase,
RetryJobUseCase,
SubmitJobUseCase,
UpdateJobProgressCommand,
UpdateJobProgressUseCase,
)
from packages.domain.job import JobType
logger = logging.getLogger(__name__)
router = APIRouter()
# 任务类型 → Celery task name 映射
_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
JobType.VIDEO_COMPOSE: "worker.compose_video",
JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan",
JobType.ASSET_INGEST: "worker.ingest_asset",
JobType.CLASSIFICATION: "worker.classify_asset",
JobType.VOICE_EXTRACTION: "worker.extract_voice",
JobType.GENERATION: "worker.generate_video",
}
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限。"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
# ── 创建任务 ──────────────────────────────────────────────────────────────────
@router.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
def create_job(
request: CreateJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobResponse:
"""创建异步任务。
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
"""
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
# 校验 job_type
try:
JobType(request.job_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"不支持的任务类型: {request.job_type}" f"可选值: {[t.value for t in JobType]}",
)
use_case = CreateJobUseCase(job_repo)
job = use_case.execute(
CreateJobCommand(
project_id=request.project_id,
job_type=request.job_type,
payload=request.payload,
source_id=request.source_id,
created_by_user_id=authenticated_user.user.id,
max_retries=request.max_retries,
)
)
return job_to_response(job)
# ── 提交执行 ──────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/submit", response_model=JobResponse)
def submit_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""提交任务执行。
将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = SubmitJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Dispatch Celery 任务
celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value)
if celery_task_name:
result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload)
job.celery_task_id = result.id
job_repo.update(job)
logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id)
return job_to_response(job)
# ── 查询接口 ──────────────────────────────────────────────────────────────────
@router.get("/jobs/{job_id}", response_model=JobResponse)
def get_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""获取任务详情。"""
use_case = GetJobUseCase(job_repo)
job = use_case.execute(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return job_to_response(job)
@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse)
def list_project_jobs(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
job_type: str | None = Query(default=None, description="按任务类型过滤"),
status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ListJobsResponse:
"""获取项目下的任务列表。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = ListJobsUseCase(job_repo)
jobs = use_case.execute(
project_id=project_id,
job_type=job_type,
status=status_filter,
limit=limit,
offset=offset,
)
items = [job_to_response(j) for j in jobs]
return ListJobsResponse(items=items, total=len(items))
@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse)
def get_job_statistics(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobStatisticsResponse:
"""获取项目任务统计摘要。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = GetJobStatisticsUseCase(job_repo)
stats = use_case.execute(project_id)
return JobStatisticsResponse(**stats)
# ── 进度更新 ──────────────────────────────────────────────────────────────────
@router.put("/jobs/{job_id}/progress", response_model=JobResponse)
def update_job_progress(
job_id: str,
request: UpdateProgressRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""更新任务进度。"""
use_case = UpdateJobProgressUseCase(job_repo)
try:
job = use_case.execute(
UpdateJobProgressCommand(
job_id=job_id,
progress=request.progress,
current_stage=request.current_stage,
)
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 完成 / 失败 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/complete", response_model=JobResponse)
def complete_job(
job_id: str,
request: CompleteJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务完成。"""
use_case = CompleteJobUseCase(job_repo)
try:
job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/fail", response_model=JobResponse)
def fail_job(
job_id: str,
request: FailJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务失败。"""
use_case = FailJobUseCase(job_repo)
try:
job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 重试 / 取消 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/retry", response_model=JobResponse)
def retry_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""重试失败任务。
将任务重置为 pendingretry_count + 1,但不自动 dispatch。
需要再次调用 /submit 提交执行。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = RetryJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/cancel", response_model=JobResponse)
def cancel_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""取消任务。"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = CancelJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
-212
View File
@@ -1,212 +0,0 @@
"""Recipe CRUD + use routes."""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.recipe import (
CreateRecipeRequest,
ListRecipesResponse,
RecipeItemResponse,
RecipeResponse,
UpdateRecipeRequest,
UseRecipeResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.application.recipe.use_cases import (
CreateRecipeUseCase,
DeleteRecipeUseCase,
FeatureDisabledError,
GetRecipeUseCase,
ListRecipesUseCase,
NotFoundError,
UpdateRecipeUseCase,
UseRecipeUseCase,
)
from packages.ports.user_repository import UserRepository
router = APIRouter()
def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository:
return SQLAlchemyRecipeRepository(session)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
def _item_to_response(item) -> RecipeItemResponse:
return RecipeItemResponse(
id=item.id,
recipe_id=item.recipe_id,
item_type=item.item_type,
item_id=item.item_id,
position=item.position,
metadata=item.metadata_,
)
def _to_response(recipe) -> RecipeResponse:
return RecipeResponse(
id=recipe.id,
user_id=recipe.user_id,
name=recipe.name,
description=recipe.description,
template_id=recipe.template_id,
generation_params=recipe.generation_params,
items=[_item_to_response(i) for i in getattr(recipe, "items", [])],
is_active=recipe.is_active,
metadata=recipe.metadata_,
created_at=recipe.created_at,
updated_at=recipe.updated_at,
)
@router.get("", response_model=ListRecipesResponse)
def list_recipes(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> ListRecipesResponse:
user_id = authenticated_user.user.id
use_case = ListRecipesUseCase(recipe_repository)
recipes = use_case.execute(user_id, skip=skip, limit=limit)
total = recipe_repository.count_by_user(user_id)
return ListRecipesResponse(
items=[_to_response(r) for r in recipes],
total=total,
)
@router.get("/{recipe_id}", response_model=RecipeResponse)
def get_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
use_case = GetRecipeUseCase(recipe_repository)
recipe = use_case.execute(recipe_id, user_id)
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.post("", response_model=RecipeResponse, status_code=status.HTTP_201_CREATED)
def create_recipe(
request: CreateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = CreateRecipeCommand(
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
],
metadata_=request.metadata_,
)
use_case = CreateRecipeUseCase(recipe_repository)
recipe = use_case.execute(command)
return _to_response(recipe)
@router.patch("/{recipe_id}", response_model=RecipeResponse)
def update_recipe(
recipe_id: str,
request: UpdateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = UpdateRecipeCommand(
recipe_id=recipe_id,
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=(
[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
]
if request.items is not None
else None
),
metadata_=request.metadata_,
)
use_case = UpdateRecipeUseCase(recipe_repository)
try:
recipe = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteRecipeUseCase(recipe_repository)
deleted = use_case.execute(recipe_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return Response(status_code=204)
@router.post("/{recipe_id}/use", response_model=UseRecipeResponse)
def use_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> UseRecipeResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
use_case = UseRecipeUseCase(recipe_repository)
try:
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
except FeatureDisabledError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return UseRecipeResponse(
recipe=_to_response(result.recipe),
warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings],
)
+1 -1
View File
@@ -232,7 +232,7 @@ async def payment_callback(
# 创建账单记录
record_id = uuid.uuid4().hex
record = repo.create(
repo.create(
{
"id": record_id,
"user_id": user_id,
+3 -8
View File
@@ -28,6 +28,8 @@ from packages.application.title_library.use_cases import (
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -51,13 +53,6 @@ def _to_response(item) -> TitleLibraryItemResponse:
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
@router.get("", response_model=ListTitleLibraryResponse)
def list_titles(
category: Optional[str] = Query(None),
@@ -98,7 +93,7 @@ def create_title(
user_repository: UserRepository = Depends(get_user_repository),
) -> TitleLibraryItemResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
plan_name = get_user_plan(user_id, user_repository)
command = CreateTitleLibraryCommand(
user_id=user_id,
name=request.name,
+7 -21
View File
@@ -1,5 +1,5 @@
import logging
from typing import Annotated, Any
from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
@@ -17,12 +17,13 @@ from app.schemas.upload import (
DirectUploadCompleteResponse,
DirectUploadPrepareRequest,
DirectUploadPrepareResponse,
UploadAssetRequest,
UploadAssetResponse,
)
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
logger = logging.getLogger(__name__)
@@ -80,21 +81,6 @@ def _validate_mime_type(content_type: str | None) -> str:
return base_type
def _require_project_and_library(
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def _submit_ingest_job(
project_id: str,
library_id: str,
@@ -135,7 +121,7 @@ async def prepare_direct_upload(
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(request.content_type)
_require_project_and_library(
require_project_and_library(
request.project_id,
request.library_id,
project_repository,
@@ -183,7 +169,7 @@ async def complete_direct_upload(
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
_require_project_and_library(
require_project_and_library(
request.project_id,
request.library_id,
project_repository,
@@ -252,7 +238,7 @@ async def upload_asset(
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
if file_hash:
-1
View File
@@ -28,7 +28,6 @@ from packages.application.voice_clone.use_cases import (
VoiceCloneNotRetryableError,
)
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowError,
VoiceCloneWorkflowService,
)
+3 -8
View File
@@ -39,6 +39,8 @@ from packages.application.voice_library.use_cases import (
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -125,13 +127,6 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
# ==================== 统一配音列表(预置 + 克隆)====================
@@ -271,7 +266,7 @@ def create_voice(
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
plan_name = get_user_plan(user_id, user_repository)
command = CreateVoiceLibraryCommand(
user_id=user_id,
name=request.name,
+7 -2
View File
@@ -25,7 +25,7 @@ class Settings(BaseSettings):
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
DATABASE_POOL_TIMEOUT: int = 30
DATABASE_POOL_RECYLE: int = 3600
DATABASE_POOL_RECYCLE: int = 3600
USE_IN_MEMORY_DB: bool = False
AUTO_CREATE_SCHEMA: bool = False
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
# 密钥轮换天数(到达此天数后建议更换密钥)
SECRET_ROTATION_DAYS: int = 90
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
@field_validator("JWT_SECRET_KEY", mode="before")
@classmethod
def validate_jwt_secret_key(cls, v):
@@ -75,7 +80,7 @@ class Settings(BaseSettings):
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
# OSS 七牛云相关
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com"
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
-1
View File
@@ -1 +0,0 @@
"""Core configuration package."""
-12
View File
@@ -50,20 +50,8 @@ from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
SQLAlchemyVoiceLibraryRepository,
)
from packages.ports.asset_library_repository import AssetLibraryRepository
from packages.ports.asset_repository import AssetRepository
from packages.ports.classification_job_repository import ClassificationJobRepository
from packages.ports.duplication_repository import DuplicationRecordRepository
from packages.ports.generated_video_repository import GeneratedVideoRepository
from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.ingest_job_repository import IngestJobRepository
from packages.ports.job_repository import JobRepository
from packages.ports.project_repository import ProjectRepository
from packages.ports.tag_repository import TagRepository
from packages.ports.title_library_repository import TitleLibraryRepository
from packages.ports.user_repository import UserRepository
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
from packages.ports.voice_library_repository import VoiceLibraryRepository
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from app.auth import AuthenticatedUser
from app.auth import get_current_user as get_authenticated_user
from app.dependencies import get_user_repository
from fastapi import Depends
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.domain.entities import User
+1 -1
View File
@@ -6,7 +6,7 @@ import logging
import time
from typing import Callable
from fastapi import Request, Response
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
-32
View File
@@ -1,32 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, Field
class RecentTaskItem(BaseModel):
id: str
task_type: str = "generation"
status: str
current_step: str = ""
error_message: str = ""
updated_at: datetime | None = None
class SubscriptionInfo(BaseModel):
"""用户订阅信息。"""
plan: str = "free"
is_active: bool = False
class DashboardOverviewResponse(BaseModel):
"""Dashboard 概览数据。"""
total_assets: int = 0
used_storage_bytes: int = 0
total_titles: int = 0
total_voices: int = 0
total_tasks: int = 0
total_products: int = 0
subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo)
recent_tasks: list[RecentTaskItem] = Field(default_factory=list)
-109
View File
@@ -1,109 +0,0 @@
"""Job API schemas — Phase 8 任务 2.10."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class CreateJobRequest(BaseModel):
"""创建任务请求体。"""
project_id: str = Field(..., min_length=1, description="项目 ID")
job_type: str = Field(
...,
description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation",
)
payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数")
source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id")
max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数")
class UpdateProgressRequest(BaseModel):
"""更新任务进度请求体。"""
progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比")
current_stage: str = Field(default="", description="当前阶段描述")
class CompleteJobRequest(BaseModel):
"""完成任务请求体。"""
result: dict[str, Any] = Field(default_factory=dict, description="任务结果")
class FailJobRequest(BaseModel):
"""标记任务失败请求体。"""
error_message: str = Field(..., min_length=1, description="错误信息")
class JobResponse(BaseModel):
"""任务响应体。"""
id: str
project_id: str
job_type: str
status: str
progress: float
current_stage: str
payload: dict[str, Any]
result: dict[str, Any]
error_message: str
retry_count: int
max_retries: int
celery_task_id: str
source_id: str
created_by_user_id: str
is_retryable: bool
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ListJobsResponse(BaseModel):
"""任务列表响应体。"""
items: list[JobResponse]
total: int
class JobStatisticsResponse(BaseModel):
"""任务统计响应体。"""
project_id: str
total: int
pending: int
running: int
success: int
failed: int
def job_to_response(job) -> JobResponse:
"""将 Job 领域对象转换为 API 响应。"""
return JobResponse(
id=job.id,
project_id=job.project_id,
job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type),
status=job.status.value if hasattr(job.status, "value") else str(job.status),
progress=job.progress,
current_stage=job.current_stage,
payload=job.payload,
result=job.result,
error_message=job.error_message,
retry_count=job.retry_count,
max_retries=job.max_retries,
celery_task_id=job.celery_task_id,
source_id=job.source_id,
created_by_user_id=job.created_by_user_id,
is_retryable=job.is_retryable,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
-86
View File
@@ -1,86 +0,0 @@
"""Recipe API schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Response ──
class RecipeItemResponse(BaseModel):
id: str
recipe_id: str
item_type: str
item_id: str
position: int
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class RecipeResponse(BaseModel):
id: str
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemResponse] = Field(default_factory=list)
is_active: bool = True
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
class ListRecipesResponse(BaseModel):
items: List[RecipeResponse]
total: int = 0
class UseRecipeResponse(BaseModel):
recipe: RecipeResponse
warnings: List[Dict[str, Any]] = Field(default_factory=list)
# ── Request ──
class RecipeItemRequest(BaseModel):
item_type: str
item_id: str
position: int = 0
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class CreateRecipeRequest(BaseModel):
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemRequest] = Field(default_factory=list)
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class UpdateRecipeRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
template_id: Optional[str] = None
generation_params: Optional[Dict[str, Any]] = None
items: Optional[List[RecipeItemRequest]] = None
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata")
class Config:
populate_by_name = True
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
+1 -1
View File
@@ -24,7 +24,7 @@ from packages.adapters.sqlalchemy_impl import (
)
from packages.domain.asset import AssetType
from packages.domain.classification import AssetClassification
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.edit_plan_clip import EditPlanClip
logger = logging.getLogger(__name__)
@@ -18,7 +18,6 @@ from packages.adapters.sqlalchemy_impl import (
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.generation_task import GenerationTaskStatus
logger = logging.getLogger(__name__)
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session
from packages.application.jobs import (
CancelJobUseCase,
@@ -13,7 +13,7 @@
from __future__ import annotations
import logging
from typing import Any, List, Optional
from typing import Any, List
from sqlalchemy.orm import Session
@@ -16,7 +16,6 @@ FFmpeg 视频合成编排服务:
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from typing import Any
@@ -28,7 +27,7 @@ from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect
-161
View File
@@ -1,161 +0,0 @@
/**
* Mock API
*
* /
*
*/
/* ── 类型定义 ───────────────────────────────────────────── */
/** 平台 ID */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
/** 账号状态 */
export type AccountStatus = "active" | "expired" | "limited";
/** 已绑定的账号 */
export interface Account {
id: string;
platform_id: PlatformId;
name: string;
avatar?: string;
status: AccountStatus;
bound_at: string;
}
/** 平台信息 */
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
/** 绑定账号请求 */
export interface BindAccountRequest {
platform_id: PlatformId;
name: string;
}
/* ── 平台配置 ───────────────────────────────────────────── */
export const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布平台",
icon: "📱",
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布平台",
icon: "🎬",
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记发布平台",
icon: "📕",
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布平台",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
},
];
/* ── Mock 数据 ───────────────────────────────────────────── */
let MOCK_ACCOUNTS: Account[] = [
{
id: "acc-001",
platform_id: "douyin",
name: "小虾官方号",
avatar: "🦐",
status: "active",
bound_at: "2025-12-01T10:00:00Z",
},
{
id: "acc-002",
platform_id: "douyin",
name: "小虾日常",
avatar: "🐟",
status: "active",
bound_at: "2025-12-15T14:30:00Z",
},
{
id: "acc-003",
platform_id: "kuaishou",
name: "小虾剪辑",
avatar: "🎬",
status: "active",
bound_at: "2026-01-05T09:00:00Z",
},
{
id: "acc-004",
platform_id: "xiaohongshu",
name: "小虾种草",
avatar: "📕",
status: "limited",
bound_at: "2026-02-20T16:00:00Z",
},
];
/* ── 模拟延迟 ───────────────────────────────────────────── */
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ── API 函数 ───────────────────────────────────────────── */
/** 获取指定平台的账号列表 */
export async function getAccountsByPlatform(
platformId: PlatformId,
): Promise<Account[]> {
await delay(300);
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
}
/** 获取所有平台的账号总数 */
export async function getAllAccounts(): Promise<Account[]> {
await delay(200);
return [...MOCK_ACCOUNTS];
}
/** 绑定新账号 */
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
await delay(500);
const newAccount: Account = {
id: `acc-${Date.now()}`,
platform_id: data.platform_id,
name: data.name,
avatar: undefined,
status: "active",
bound_at: new Date().toISOString(),
};
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
return newAccount;
}
/** 解绑账号 */
export async function unbindAccount(accountId: string): Promise<void> {
await delay(400);
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
}
/* ── 状态配置 ───────────────────────────────────────────── */
export const ACCOUNT_STATUS_CONFIG: Record<
AccountStatus,
{ label: string; className: string }
> = {
active: { label: "正常", className: "acc-status--active" },
expired: { label: "已过期", className: "acc-status--expired" },
limited: { label: "受限", className: "acc-status--limited" },
};
-42
View File
@@ -1,42 +0,0 @@
/**
* API
* Phase 1
*/
import apiClient from "./client";
/** 仪表盘概览数据 */
export interface DashboardOverview {
/** 素材总数 */
total_assets: number;
/** 已用存储(字节) */
used_storage_bytes: number;
/** 总标题数 */
total_titles: number;
/** 总配音数 */
total_voices: number;
/** 生成任务总数 */
total_tasks: number;
/** 成品总数 */
total_products: number;
/** 最近生成任务 */
recent_tasks: Array<{
id: string;
task_type: string;
status: string;
progress: number;
user_message: string;
created_at: string;
}>;
/** 订阅信息 */
subscription: {
plan: "free" | "pro" | "enterprise";
status: "active" | "inactive" | "expired";
expires_at?: string;
};
}
/** 获取仪表盘概览数据 */
export const getDashboardOverview = async (): Promise<DashboardOverview> => {
const response = await apiClient.get("/dashboard/overview");
return response.data;
};
@@ -1,365 +0,0 @@
/* V21 业务组件统一样式 */
/* ==================== 按钮 ==================== */
.xx-primary-btn {
background: var(--gradient-primary) !important;
color: var(--text-inverse) !important;
border: none !important;
border-radius: var(--radius-md) !important;
padding: 10px 20px !important;
font-weight: var(--font-weight-bold) !important;
box-shadow: var(--shadow-primary) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-primary-btn:hover {
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: transparent !important;
color: var(--primary-color) !important;
border: 2px solid var(--primary-color) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-sm) 18px !important;
font-weight: var(--font-weight-bold) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-ghost-btn:hover {
background: var(--primary-soft) !important;
}
/* ==================== 卡片 ==================== */
.xx-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
margin-bottom: 20px;
transition: all var(--transition-slow);
}
.xx-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* ==================== 页面结构 ==================== */
.xx-page {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-lg);
}
.xx-page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
margin-bottom: 28px;
}
.xx-page-head h2 {
font-size: 26px;
font-weight: var(--font-weight-extrabold);
color: var(--text-primary);
margin: 0 0 var(--space-sm);
}
.xx-page-head p {
font-size: var(--font-size-base);
color: var(--text-secondary);
margin: 0;
}
/* ==================== 表格样式 ==================== */
.xx-table-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: 20px;
overflow: hidden;
}
/* 表格包装器 */
.xx-table-wrapper {
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ==================== 标签/Tag ==================== */
.xx-tag {
padding: var(--space-xs) 12px;
border-radius: var(--radius-xs);
font-size: 13px;
font-weight: var(--font-weight-medium);
}
.xx-tag-indigo {
background: var(--primary-soft);
color: var(--primary-color);
border: 1px solid var(--color-primary-200);
}
.xx-tag-success {
background: var(--success-soft);
color: var(--color-secondary-500);
border: 1px solid var(--success-border);
}
.xx-tag-warning {
background: var(--warning-soft);
color: var(--accent-dark);
border: 1px solid var(--color-accent-200);
}
.xx-tag-error {
background: var(--error-soft);
color: var(--error-color);
border: 1px solid var(--error-border);
}
/* ==================== 搜索栏 ==================== */
.xx-search-bar {
margin-bottom: 20px;
}
.xx-search-input {
width: 100%;
padding: 12px 18px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--bg-primary);
transition: var(--transition-all);
outline: none;
}
.xx-search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== Modal ==================== */
.xx-modal .ant-modal-content {
border-radius: var(--radius-xl);
padding: var(--space-lg);
}
.xx-modal .ant-modal-header {
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: 20px var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.xx-modal .ant-modal-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-modal .ant-modal-footer {
border-top: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
}
/* ==================== 空状态 ==================== */
.xx-empty-state {
text-align: center;
padding: var(--space-3xl) var(--space-lg);
color: var(--text-secondary);
}
.xx-empty-state-icon {
font-size: 48px;
margin-bottom: var(--space-md);
}
/* ==================== 网格布局 ==================== */
.xx-grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.xx-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.xx-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
@media (max-width: 768px) {
.xx-grid-2,
.xx-grid-3,
.xx-grid-4 {
grid-template-columns: 1fr;
}
}
/* ==================== 配额展示 ==================== */
.xx-quota-item {
padding: 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
transition: var(--transition-all);
}
.xx-quota-item:hover {
border-color: var(--primary-color);
box-shadow: 0 8px 24px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== 进度条 ==================== */
.xx-progress {
margin-top: 12px;
}
/* ==================== Ant Design 覆盖样式 ==================== */
/* Table overrides */
.ant-table-wrapper .ant-table-thead > tr > th {
background: var(--bg-secondary) !important;
font-weight: var(--font-weight-bold) !important;
color: var(--text-primary) !important;
border-bottom: 2px solid var(--border-color) !important;
padding: 14px var(--space-md) !important;
}
.ant-table-wrapper .ant-table-tbody > tr > td {
padding: 14px var(--space-md) !important;
border-bottom: 1px solid var(--color-gray-100) !important;
}
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
background: var(--color-gray-50) !important;
}
/* Card overrides */
.ant-card {
border-radius: var(--radius-xl) !important;
border: 1px solid var(--border-color) !important;
}
.ant-card-head {
border-bottom: 1px solid var(--border-color) !important;
min-height: 52px !important;
padding: 0 var(--space-lg) !important;
}
.ant-card-head-title {
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-md) !important;
color: var(--text-primary) !important;
}
.ant-card-body {
padding: 20px var(--space-lg) !important;
}
/* Modal overrides */
.ant-modal-content {
border-radius: var(--radius-xl) !important;
overflow: hidden;
}
.ant-modal-header {
padding: 20px var(--space-lg) !important;
background: var(--bg-primary) !important;
}
.ant-modal-title {
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-lg) !important;
color: var(--text-primary) !important;
}
.ant-modal-body {
padding: var(--space-lg) !important;
}
.ant-modal-footer {
padding: var(--space-md) var(--space-lg) !important;
}
/* Button overrides */
.ant-btn-primary {
background: var(--gradient-primary) !important;
border: none !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-primary) !important;
height: auto !important;
padding: 10px 20px !important;
font-weight: var(--font-weight-bold) !important;
}
.ant-btn-primary:hover {
background: var(--gradient-primary) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
/* Tag overrides */
.ant-tag {
border-radius: var(--radius-xs) !important;
padding: var(--space-xs) 12px !important;
font-weight: var(--font-weight-medium) !important;
}
/* Select overrides */
.ant-select-selector {
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
}
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary-color) !important;
}
.ant-select-focused .ant-select-selector {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Input overrides */
.ant-input {
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
padding: 10px 14px !important;
}
.ant-input:hover {
border-color: var(--primary-color) !important;
}
.ant-input:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Progress overrides */
.ant-progress-inner {
background: var(--color-gray-100) !important;
border-radius: var(--radius-xs) !important;
}
.ant-progress-bg {
border-radius: var(--radius-xs) !important;
}
@@ -1,290 +0,0 @@
/**
* CloneVoiceModal
*
* input uploading success
* mock
*
* V21 Design System antd
*/
import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { uploadAsset } from "@/api/assets";
import "./clone-voice-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalStep = "input" | "uploading" | "success";
export interface CloneVoiceModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 默认音色名称计数器 ─────────────────────────────────── */
let cloneCounter = 1;
const getNextDefaultName = (): string => {
const name = `我的声音 ${cloneCounter}`;
cloneCounter += 1;
return name;
};
/* ── 组件 ───────────────────────────────────────────────── */
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [step, setStep] = useState<ModalStep>("input");
const [voiceName, setVoiceName] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setStep("input");
setVoiceName("");
setSelectedFile(null);
setIsRecording(false);
setDragActive(false);
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 上传区域点击 */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
/** 文件选择 */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
// 清除之前的录制状态
setIsRecording(false);
}
// 清空 input 以允许重复选择同一文件
e.target.value = "";
};
/** 拖拽事件 */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext === "mp3" || ext === "wav") {
setSelectedFile(file);
setIsRecording(false);
}
}
};
/** 录制按钮(mock */
const handleRecord = () => {
setIsRecording((prev) => !prev);
if (!isRecording) {
// 开始录制 — 清除已选文件
setSelectedFile(null);
}
};
/** 开始克隆 */
const handleStartClone = async () => {
const name = voiceName.trim() || getNextDefaultName();
setStep("uploading");
try {
// 先上传音频文件获取真实 URL
let audioUrl: string;
if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("kind", "voice");
const uploadResult = await uploadAsset(formData);
audioUrl = uploadResult.url;
} else {
// 录制功能暂未实现,提示用户上传
setStep("input");
return;
}
// 提交克隆请求
const result = await createVoiceClone({
name,
audio_url: audioUrl,
});
setStep("success");
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
setStep("input");
}
};
/** 弹窗打开时初始化默认名称 */
const handleAfterOpenChange = (visible: boolean) => {
if (visible) {
setVoiceName(getNextDefaultName());
}
};
const canStart = selectedFile || isRecording;
return (
<Modal
open={open}
onCancel={handleClose}
title="🎤 克隆新音色"
width={520}
footer={null}
destroyOnClose
afterOpenChange={handleAfterOpenChange}
>
{/* ── 输入步骤 ──────────────────────────────────── */}
{step === "input" && (
<div className="cvm-body">
{/* 音色名称 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<input
type="text"
className="cvm-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称"
/>
</div>
{/* 上传区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="cvm-upload-icon">🎵</div>
<p className="cvm-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
</p>
<p className="cvm-upload-hint"> MP3WAV </p>
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,audio/mpeg,audio/wav"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="cvm-divider">
<div className="cvm-divider-line" />
<span className="cvm-divider-text"></span>
<div className="cvm-divider-line" />
</div>
{/* 录制区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div className="cvm-record-area">
<p className="cvm-record-hint">
{isRecording
? "录制中…再次点击停止"
: "点击按钮开始录制你的声音"}
</p>
<button
type="button"
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
onClick={handleRecord}
>
🎙
</button>
</div>
</div>
{/* 提示 */}
<div className="cvm-tip">
<span className="cvm-tip-icon">💡</span>
<span>
10~3
</span>
</div>
{/* 底部按钮 */}
<div className="cvm-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canStart}
onClick={handleStartClone}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 上传中步骤 ────────────────────────────────── */}
{step === "uploading" && (
<div className="cvm-uploading">
<div className="cvm-uploading-spinner" />
<p className="cvm-uploading-text"></p>
<p className="cvm-uploading-sub">AI </p>
</div>
)}
{/* ── 成功步骤 ──────────────────────────────────── */}
{step === "success" && (
<div className="cvm-success">
<div className="cvm-success-icon"></div>
<h3 className="cvm-success-title"></h3>
<p className="cvm-success-desc">
</p>
</div>
)}
</Modal>
);
};
export default CloneVoiceModal;
@@ -1,325 +0,0 @@
/**
* CloneVoiceModal V21 Design System
*
* 音色克隆弹窗样式
* 三步骤状态input uploading success
*/
/* ── 弹窗内容区 ─────────────────────────────────────────── */
.cvm-body {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── 表单区 ─────────────────────────────────────────────── */
.cvm-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.cvm-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #475467);
}
.cvm-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-sm);
background: var(--bg-surface, #fff);
color: var(--text-primary, #101828);
font-size: 14px;
line-height: 1.5;
transition:
border-color 0.2s,
box-shadow 0.2s;
outline: none;
}
.cvm-input:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cvm-input::placeholder {
color: var(--muted, #98a2b3);
}
/* ── 上传区域 ───────────────────────────────────────────── */
.cvm-upload-zone {
border: 2px dashed var(--line, #e4e7ec);
border-radius: var(--radius-md);
padding: 28px 20px;
text-align: center;
background: var(--bg-subtle, #f8fafc);
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
}
.cvm-upload-zone:hover {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
}
.cvm-upload-zone.cvm-upload-zone--active {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
}
.cvm-upload-icon {
font-size: 36px;
margin-bottom: 8px;
line-height: 1;
}
.cvm-upload-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #101828);
margin: 0 0 4px;
}
.cvm-upload-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 或分隔线 ───────────────────────────────────────────── */
.cvm-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 4px 0;
}
.cvm-divider-line {
flex: 1;
height: 1px;
background: var(--line, #e4e7ec);
}
.cvm-divider-text {
font-size: 13px;
color: var(--muted, #98a2b3);
flex-shrink: 0;
}
/* ── 录制区域 ───────────────────────────────────────────── */
.cvm-record-area {
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-md);
padding: 24px;
text-align: center;
}
.cvm-record-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0 0 14px;
}
.cvm-record-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 32px;
line-height: 1;
padding: 0;
background: linear-gradient(
135deg,
var(--error-color, #ef4444),
var(--error-dark, #dc2626)
);
color: var(--text-inverse);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
transition:
transform 0.15s,
box-shadow 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cvm-record-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 20px
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
}
.cvm-record-btn:active {
transform: scale(0.96);
}
.cvm-record-btn--recording {
animation: cvm-pulse 1.2s ease-in-out infinite;
}
@keyframes cvm-pulse {
0%,
100% {
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
}
50% {
box-shadow: 0 4px 28px
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
}
}
/* ── 提示条 ─────────────────────────────────────────────── */
.cvm-tip {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background: var(--warning-soft, #fef3c7);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--warning-color, #92400e);
line-height: 1.5;
}
.cvm-tip-icon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
/* ── 底部按钮 ───────────────────────────────────────────── */
.cvm-footer {
display: flex;
gap: 12px;
margin-top: 4px;
}
.cvm-footer .xx-btn {
flex: 1;
}
/* ── 上传中状态 ─────────────────────────────────────────── */
.cvm-uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 16px;
}
.cvm-uploading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line, #e4e7ec);
border-top-color: var(--primary, #6366f1);
border-radius: 50%;
animation: cvm-spin 0.8s linear infinite;
}
@keyframes cvm-spin {
to {
transform: rotate(360deg);
}
}
.cvm-uploading-text {
font-size: 15px;
font-weight: 500;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-uploading-sub {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 成功状态 ───────────────────────────────────────────── */
.cvm-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 12px;
}
.cvm-success-icon {
font-size: 56px;
line-height: 1;
}
.cvm-success-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-success-desc {
font-size: 14px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 响应式 ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.cvm-overlay {
padding: var(--space-md);
}
.cvm-modal {
width: 100%;
max-width: 100%;
padding: var(--space-lg);
}
}
@media (max-width: 576px) {
.cvm-upload-zone {
padding: 20px 14px;
}
.cvm-record-btn {
width: 64px;
height: 64px;
font-size: 26px;
}
.cvm-footer {
flex-direction: column;
}
}
@media (max-width: 480px) {
.cvm-record-btn {
width: 60px;
height: 60px;
}
.cvm-tip {
font-size: 12px;
padding: var(--space-sm);
}
}
+20
View File
@@ -532,3 +532,23 @@
padding: 8px 16px !important;
}
}
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
.xx-card .ant-card-head {
border-bottom: 1px solid var(--border-color);
padding: 20px 24px;
}
.xx-card .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--text-primary);
}
.xx-card .ant-card-body {
padding: 24px;
}
-211
View File
@@ -1,211 +0,0 @@
/**
*
* Header Sidebar
*/
import React from "react";
import {
DashboardOutlined,
VideoCameraOutlined,
FileOutlined,
AudioOutlined,
FileTextOutlined,
TrophyOutlined,
AppstoreOutlined,
HistoryOutlined,
ControlOutlined,
CrownOutlined,
ScanOutlined,
EditOutlined,
FolderOutlined,
} from "@ant-design/icons";
/** 导航项定义 */
export interface NavItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 导航分组定义 */
export interface NavGroup {
title: string;
items: NavItem[];
}
/**
* Header 使
*/
export const NAV_ITEMS: NavItem[] = [
{
key: "dashboard",
label: "概览",
path: "/app/dashboard",
icon: <DashboardOutlined />,
},
{
key: "assets",
label: "素材库",
path: "/app/assets",
icon: <FileOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/app/titles",
icon: <FileTextOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/app/voices",
icon: <AudioOutlined />,
},
{
key: "voice-clone",
label: "我的音色",
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/app/templates",
icon: <AppstoreOutlined />,
},
{
key: "editing-planner",
label: "剪辑编辑器",
path: "/app/editing-planner",
icon: <EditOutlined />,
},
{
key: "my-templates",
label: "我的模板",
path: "/app/my-templates",
icon: <FolderOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/app/generate",
icon: <VideoCameraOutlined />,
},
{
key: "history",
label: "任务历史",
path: "/app/history",
icon: <HistoryOutlined />,
},
{
key: "products",
label: "成品库",
path: "/app/products",
icon: <TrophyOutlined />,
},
{
key: "duplication",
label: "查重",
path: "/app/duplication",
icon: <ScanOutlined />,
},
];
/**
* Sidebar 使
*/
export const NAV_GROUPS: NavGroup[] = [
{
title: "创作工具",
items: [
{
key: "dashboard",
label: "首页",
path: "/app/dashboard",
icon: <DashboardOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/app/generate",
icon: <VideoCameraOutlined />,
},
],
},
{
title: "资源管理",
items: [
{
key: "assets",
label: "素材库",
path: "/app/assets",
icon: <FileOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/app/voices",
icon: <AudioOutlined />,
},
{
key: "voice-clone",
label: "我的音色",
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/app/titles",
icon: <FileTextOutlined />,
},
{
key: "products",
label: "成片库",
path: "/app/products",
icon: <TrophyOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/app/templates",
icon: <AppstoreOutlined />,
},
],
},
{
title: "系统",
items: [
{
key: "history",
label: "任务历史",
path: "/app/history",
icon: <HistoryOutlined />,
},
{
key: "admin",
label: "控制台",
path: "/app/admin",
icon: <ControlOutlined />,
},
{
key: "subscription",
label: "订阅管理",
path: "/app/subscription",
icon: <CrownOutlined />,
},
],
},
];
+75 -198
View File
@@ -2,194 +2,66 @@
* V21 Design System
*
* ///
* /
* API
*
* antd 使 CSS
*/
import React, { useState, useCallback } from "react";
import { useQueries, useMutation, useQueryClient } from "@tanstack/react-query";
import React from "react";
import { Button } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
PLATFORMS,
getAccountsByPlatform,
unbindAccount,
bindAccount,
ACCOUNT_STATUS_CONFIG,
type Platform,
type Account,
type PlatformId,
} from "@/api/accounts";
import "./accounts.css";
/* ── Toast 系统 ─────────────────────────────────────────── */
/* ── 类型定义 ───────────────────────────────────────────── */
interface Toast {
id: number;
message: string;
type: "success" | "error";
export type PlatformId =
| "douyin"
| "kuaishou"
| "xiaohongshu"
| "wechat";
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
let toastIdCounter = 0;
/* ── 平台卡片组件 ───────────────────────────────────────── */
interface PlatformCardProps {
platform: Platform;
accounts: Account[];
isLoading: boolean;
onBind: (platformId: PlatformId) => void;
onUnbind: (accountId: string, accountName: string) => void;
}
const PlatformCard: React.FC<PlatformCardProps> = ({
platform,
accounts,
isLoading,
onBind,
onUnbind,
}) => {
return (
<div className="acc-card">
{/* 平台头部 */}
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
{/* 账号列表 */}
<div className="acc-account-list">
{isLoading ? (
<div className="acc-empty">
<p className="acc-empty-text"></p>
</div>
) : accounts.length > 0 ? (
accounts.map((account) => {
const statusCfg = ACCOUNT_STATUS_CONFIG[account.status];
return (
<div key={account.id} className="acc-account-row">
<div
className="acc-account-avatar"
style={{ background: platform.gradient }}
>
{account.avatar || platform.icon}
</div>
<div className="acc-account-info">
<div className="acc-account-name">{account.name}</div>
<span className={`acc-status-pill ${statusCfg.className}`}>
{statusCfg.label}
</span>
</div>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onUnbind(account.id, account.name)}
>
</Button>
</div>
);
})
) : (
<div className="acc-empty">
<div className="acc-empty-icon">🔓</div>
<p className="acc-empty-text"></p>
</div>
)}
</div>
{/* 绑定按钮 */}
<Button buttonType="ghost" onClick={() => onBind(platform.id)}>
+
</Button>
</div>
);
};
/** 支持的平台列表 */
const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布",
icon: "🎵",
gradient: "linear-gradient(135deg, #000 0%, #333 100%)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布",
icon: "📹",
gradient: "linear-gradient(135deg, #ff6600 0%, #ff9933 100%)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记 + 视频",
icon: "📕",
gradient: "linear-gradient(135deg, #fe2c55 0%, #ff6680 100%)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)",
},
];
/* ── 主页面 ─────────────────────────────────────────────── */
const Accounts: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 查询所有平台的账号 */
const _queriesResults = useQueries({
queries: PLATFORMS.map((platform) => ({
queryKey: ["accounts", platform.id] as const,
queryFn: () => getAccountsByPlatform(platform.id),
})),
});
const accountQueries = PLATFORMS.map((platform, i) => ({
platform,
..._queriesResults[i],
}));
/** 解绑 mutation */
const unbindMutation = useMutation({
mutationFn: unbindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("已解绑账号", "success");
},
onError: () => {
showToast("解绑失败", "error");
},
});
/** 绑定 mutationmock */
const bindMutation = useMutation({
mutationFn: bindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("账号绑定成功", "success");
},
onError: () => {
showToast("绑定失败", "error");
},
});
/** 绑定新账号(mock:直接创建) */
const handleBind = (platformId: PlatformId) => {
const platform = PLATFORMS.find((p) => p.id === platformId);
if (!platform) return;
const name = window.prompt(`请输入要绑定的${platform.name}账号名称:`);
if (name && name.trim()) {
bindMutation.mutate({ platform_id: platformId, name: name.trim() });
}
};
/** 解绑账号 */
const handleUnbind = (accountId: string, accountName: string) => {
if (window.confirm(`确定解绑账号「${accountName}」吗?`)) {
unbindMutation.mutate(accountId);
}
};
/** 统计已绑定账号数 */
const totalBound = accountQueries.reduce(
(sum, q) => sum + (q.data?.length ?? 0),
0,
);
const totalPlatforms = PLATFORMS.length;
return (
<div className="acc-page">
<PageHead
@@ -197,17 +69,34 @@ const Accounts: React.FC = () => {
description="绑定您的社交平台账号,用于视频一键发布到各平台"
/>
{/* 平台卡片网格 */}
{/* 平台卡片网格 — 占位状态 */}
<div className="acc-grid">
{accountQueries.map(({ platform, data, isLoading }) => (
<PlatformCard
key={platform.id}
platform={platform}
accounts={data ?? []}
isLoading={isLoading}
onBind={handleBind}
onUnbind={handleUnbind}
/>
{PLATFORMS.map((platform) => (
<div key={platform.id} className="acc-card">
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
<div className="acc-account-list">
<div className="acc-empty">
<div className="acc-empty-icon">🔒</div>
<p className="acc-empty-text">线</p>
</div>
</div>
<Button buttonType="ghost" disabled>
</Button>
</div>
))}
</div>
@@ -215,22 +104,10 @@ const Accounts: React.FC = () => {
<div className="acc-stats-bar">
<span className="acc-stats-icon">📊</span>
<span className="acc-stats-text">
<span className="acc-stats-highlight">{totalBound}</span>{" "}
/ {" "}
<span className="acc-stats-highlight">{totalPlatforms}</span>
<span className="acc-stats-highlight">{PLATFORMS.length}</span>{" "}
线
</span>
</div>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
+10 -500
View File
@@ -1,482 +1,20 @@
/* V21 Admin 页面样式 */
/* Admin 页面样式Phase 3 精简
*
* 原始 477 精简至仅保留实际使用的 class
* 已迁移至 global.css / ui.css 的样式不再重复定义
* .xx-page-head global.css
* .xx-primary-btn global.css
* .xx-tag / .xx-card ui.css / global.css
*
* 以下 class 仅被 AdminComingSoon.tsx 使用
*/
/* 页面容器 */
.dashboard-page,
.analytics-page,
.user-management-page,
.log-viewer-page,
.system-monitor-page,
.admin-coming-soon-page {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
/* 页面头部 */
.xx-page-head {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-page-head-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-page-head h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0;
letter-spacing: -0.02em;
}
.xx-page-head p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
.xx-page-head-actions {
display: flex;
gap: 12px;
align-items: center;
}
/* 简化页面头部(无操作按钮) */
.xx-page-head-simple {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
}
.xx-page-head-simple h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 8px;
letter-spacing: -0.02em;
}
.xx-page-head-simple p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
/* V21 卡片 */
.xx-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
transition: all 0.3s;
}
.xx-card:hover {
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.xx-card .ant-card-head {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-card .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--slate, #0f172a);
}
.xx-card .ant-card-body {
padding: 24px;
}
/* 统计卡片网格 - 4列 */
.xx-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 32px;
}
@media (max-width: 1200px) {
.xx-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-grid-4 {
grid-template-columns: 1fr;
}
}
/* 统计卡片 */
.xx-stat-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
transition: all 0.3s;
}
.xx-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.xx-stat-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.xx-stat-card-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.xx-stat-card-icon.primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.xx-stat-card-icon.success {
background: linear-gradient(135deg, #34d399, #10b981);
color: white;
}
.xx-stat-card-icon.warning {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
}
.xx-stat-card-icon.purple {
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
color: white;
}
.xx-stat-card-icon.info {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
color: white;
}
.xx-stat-card-icon.orange {
background: linear-gradient(135deg, #fb923c, #f97316);
color: white;
}
.xx-stat-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.xx-stat-card-value {
font-size: 32px;
font-weight: 900;
color: var(--slate, #0f172a);
line-height: 1.2;
letter-spacing: -0.02em;
}
.xx-stat-card-value.primary {
color: var(--indigo, #4f46e5);
}
.xx-stat-card-value.success {
color: var(--green, #10b981);
}
.xx-stat-card-value.warning {
color: var(--amber, #f59e0b);
}
.xx-stat-card-value.purple {
color: #8b5cf6;
}
.xx-stat-card-growth {
font-size: 13px;
color: var(--green, #10b981);
font-weight: 600;
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
/* 数据表格 */
.xx-table-wrapper {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.xx-table-wrapper .ant-table {
background: transparent;
}
.xx-table-wrapper .ant-table-thead > tr > th {
background: rgba(248, 250, 252, 0.8);
font-weight: 800;
font-size: 13px;
color: var(--slate, #0f172a);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 16px 20px;
}
.xx-table-wrapper .ant-table-tbody > tr > td {
padding: 16px 20px;
font-size: 14px;
}
.xx-table-wrapper .ant-table-tbody > tr:hover > td {
background: rgba(79, 70, 229, 0.03);
}
/* V21 按钮 */
.xx-primary-btn {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
border: none !important;
border-radius: var(--radius-md) !important;
font-weight: 700 !important;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25) !important;
transition: all 0.2s !important;
}
.xx-primary-btn:hover {
box-shadow: 0 12px 28px rgba(79, 70, 229, 0.3) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: white !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
color: var(--slate, #0f172a) !important;
border-radius: var(--radius-md) !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.xx-ghost-btn:hover {
border-color: var(--indigo, #4f46e5) !important;
color: var(--indigo, #4f46e5) !important;
}
/* V21 输入框 */
.xx-search-input {
border-radius: var(--radius-md) !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
padding: 8px 16px !important;
}
.xx-search-input:hover,
.xx-search-input:focus {
border-color: var(--indigo, #4f46e5) !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
}
/* V21 Select */
.xx-select {
border-radius: var(--radius-md) !important;
}
.xx-select:hover,
.xx-select:focus {
border-color: var(--indigo, #4f46e5) !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
}
/* V21 Tag */
.xx-tag {
border-radius: var(--radius-xs) !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 4px 10px !important;
}
.xx-tag.info {
background: rgba(59, 130, 246, 0.1) !important;
color: #3b82f6 !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
}
.xx-tag.success {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
}
.xx-tag.warning {
background: rgba(245, 158, 11, 0.1) !important;
color: #f59e0b !important;
border: 1px solid rgba(245, 158, 11, 0.2) !important;
}
.xx-tag.error {
background: rgba(239, 68, 68, 0.1) !important;
color: #ef4444 !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
}
.xx-tag.debug {
background: rgba(100, 116, 139, 0.1) !important;
color: #64748b !important;
border: 1px solid rgba(100, 116, 139, 0.2) !important;
}
/* V21 Progress */
.xx-progress-primary .ant-progress-circle .ant-progress-text {
color: var(--indigo, #4f46e5) !important;
font-weight: 700 !important;
}
.xx-progress-success .ant-progress-circle .ant-progress-text {
color: var(--green, #10b981) !important;
font-weight: 700 !important;
}
.xx-progress-warning .ant-progress-circle .ant-progress-text {
color: var(--amber, #f59e0b) !important;
font-weight: 700 !important;
}
/* 图表容器 */
.xx-chart-container {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
}
.xx-chart-container .ant-card-head {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-chart-container .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--slate, #0f172a);
}
/* 筛选器区域 */
.xx-filter-bar {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 20px 24px;
margin-bottom: 24px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
/* 资源监控卡片 */
.xx-resource-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
text-align: center;
}
.xx-resource-card-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
}
.xx-resource-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 600;
margin-bottom: 16px;
}
/* 日期选择器 */
.xx-date-picker {
border-radius: var(--radius-md) !important;
}
/* Drawer */
.xx-drawer .ant-drawer-header {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-drawer .ant-drawer-title {
font-weight: 800;
font-size: 18px;
color: var(--slate, #0f172a);
}
/* 日志详情 */
.xx-log-detail {
padding: 4px 0;
}
.xx-log-detail-label {
font-size: 13px;
font-weight: 700;
color: var(--slate, #0f172a);
margin-bottom: 4px;
}
.xx-log-detail-value {
font-size: 14px;
color: var(--muted, #64748b);
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.xx-log-detail-code {
font-family: "JetBrains Mono", "Fira Code", monospace;
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
word-break: break-all;
}
/* Result 页面居中 */
.xx-result-center {
display: flex;
justify-content: center;
@@ -487,31 +25,3 @@
.xx-result-center .ant-result {
padding: 48px;
}
/* 刷新时间显示 */
.xx-refresh-time {
font-size: 13px;
color: var(--muted, #64748b);
margin-right: 12px;
}
/* 图表配色覆盖 */
.recharts-text {
fill: #64748b !important;
font-size: 12px !important;
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: rgba(226, 232, 240, 0.8) !important;
}
/* 图表 Legend */
.recharts-legend-wrapper {
padding-top: 16px !important;
}
.recharts-legend-item-text {
color: #64748b !important;
font-size: 13px !important;
}
+56 -422
View File
@@ -1,451 +1,85 @@
/**
* V21
* KPI + + + 使 +
* 使 mock CSS V21
* CSS V21
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button, Tag } from "@/components/ui";
import {
VideoCameraOutlined,
AppstoreOutlined,
ThunderboltOutlined,
DatabaseOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { DatabaseOutlined } from "@ant-design/icons";
import "./dashboard.css";
/* ============================================================
* Mock
* ============================================================ */
interface KpiItem {
key: string;
icon: string;
iconGradient: string;
value: string;
label: string;
trend: string;
trendDirection: "up" | "down" | "neutral";
accent: string;
}
/* ── 主组件 ─────────────────────────────────────────────── */
const kpiData: KpiItem[] = [
{
key: "projects",
icon: "video",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
value: "12",
label: "项目总数",
trend: "↑ 2 本月新增",
trendDirection: "up",
accent: "#6366f1",
},
{
key: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
value: "486",
label: "素材总数",
trend: "↑ 38 本月上传",
trendDirection: "up",
accent: "#0ea5e9",
},
{
key: "generations",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
value: "156",
label: "本月生成数",
trend: "↑ 23% 较上月",
trendDirection: "up",
accent: "#10b981",
},
{
key: "storage",
icon: "database",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
value: "2.4GB",
label: "存储空间",
trend: "已用 24%",
trendDirection: "neutral",
accent: "#f59e0b",
},
];
interface QuickEntry {
id: string;
icon: string;
iconGradient: string;
title: string;
description: string;
path: string;
}
const quickEntries: QuickEntry[] = [
{
id: "titles",
icon: "filetext",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
title: "标题库",
description: "24条标题 · 5个分类",
path: "/app/titles",
},
{
id: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
title: "素材库",
description: "486个素材 · 3个素材库",
path: "/app/assets",
},
{
id: "generate",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
title: "一键生成",
description: "开始创作新视频",
path: "/app/generate",
},
{
id: "products",
icon: "video",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
title: "成片库",
description: "89个成片 · 3个待复核",
path: "/app/products",
},
];
type TaskStatus = "completed" | "processing" | "pending" | "failed";
interface RecentTask {
id: string;
name: string;
type: string;
template: string;
status: TaskStatus;
date: string;
duration?: string;
}
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
};
const recentTasks: RecentTask[] = [
{
id: "t-1",
name: "产品介绍视频_春季促销",
type: "视频生成",
template: "商品展示模板",
status: "completed",
date: "2026-07-01 09:30",
duration: "2分18秒",
},
{
id: "t-2",
name: "品牌宣传片_终版",
type: "视频生成",
template: "品牌宣传模板",
status: "processing",
date: "2026-07-01 10:15",
},
{
id: "t-3",
name: "用户评价合集",
type: "视频生成",
template: "评价展示模板",
status: "completed",
date: "2026-06-30 16:42",
duration: "1分45秒",
},
{
id: "t-4",
name: "新品发布预告",
type: "视频生成",
template: "新品预告模板",
status: "pending",
date: "2026-06-30 14:20",
},
{
id: "t-5",
name: "活动回顾_618大促",
type: "视频生成",
template: "活动回顾模板",
status: "failed",
date: "2026-06-29 11:05",
},
];
interface ChartItem {
label: string;
value: number;
}
const weeklyData: ChartItem[] = [
{ label: "周一", value: 18 },
{ label: "周二", value: 25 },
{ label: "周三", value: 32 },
{ label: "周四", value: 28 },
{ label: "周五", value: 42 },
{ label: "周六", value: 15 },
{ label: "周日", value: 8 },
];
interface Announcement {
id: string;
tag: "update" | "notice" | "activity";
tagLabel: string;
title: string;
date: string;
}
const announcements: Announcement[] = [
{
id: "a-1",
tag: "update",
tagLabel: "更新",
title: "系统已升级至 v2.0,新增批量生成功能",
date: "2026-07-01",
},
{
id: "a-2",
tag: "activity",
tagLabel: "活动",
title: "7月创作挑战赛已开启,参与赢积分奖励",
date: "2026-06-28",
},
{
id: "a-3",
tag: "notice",
tagLabel: "公告",
title: "7月3日凌晨 2:00-4:00 系统维护通知",
date: "2026-06-25",
},
];
/* ============================================================
*
* ============================================================ */
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return "夜深了";
if (hour < 12) return "早上好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
};
const formatDate = () => {
const d = new Date();
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${weekDays[d.getDay()]}`;
};
/** 图标名称 → Ant Design 组件映射 */
const iconMap: Record<string, React.ReactNode> = {
video: <VideoCameraOutlined />,
appstore: <AppstoreOutlined />,
thunderbolt: <ThunderboltOutlined />,
database: <DatabaseOutlined />,
filetext: <FileTextOutlined />,
};
/* ============================================================
*
* ============================================================ */
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const maxChart = Math.max(...weeklyData.map((d) => d.value));
return (
<div className="xx-dashboard-page">
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
<div className="xx-dashboard-welcome">
<h2>{getGreeting()}</h2>
<p>{formatDate()} </p>
</div>
{/* ── KPI 卡片网格 ─────────────────────────────────────── */}
{/* KPI 卡片网格 */}
<div className="xx-kpi-grid">
{kpiData.map((item) => (
<div
key={item.key}
className="xx-kpi-card"
style={{ "--kpi-accent": item.accent } as React.CSSProperties}
>
<div
className="xx-kpi-icon"
style={{ background: item.iconGradient }}
>
{iconMap[item.icon] ?? item.icon}
</div>
<div className="xx-kpi-value">{item.value}</div>
<div className="xx-kpi-label">{item.label}</div>
<span
className={`xx-kpi-trend xx-kpi-trend--${item.trendDirection}`}
>
{item.trend}
</span>
</div>
))}
</div>
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
<div className="xx-dashboard-main">
{/* 左列 */}
<div className="xx-dashboard-left-col">
{/* 最近任务 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<button onClick={() => navigate("/app/history")}></button>
</div>
<div className="xx-task-list">
{recentTasks.map((task) => (
<div key={task.id} className="xx-task-item">
<div className="xx-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
<Tag
variant={
task.status === "completed"
? "success"
: task.status === "processing"
? "info"
: task.status === "failed"
? "error"
: "warning"
}
>
{statusLabel[task.status]}
</Tag>
<div className="xx-task-time">
<span>{task.date}</span>
{task.status === "completed"
? `耗时 ${task.duration}`
: task.status === "processing"
? "生成中..."
: task.status === "failed"
? "请重试"
: "等待中"}
</div>
<div className="xx-task-action">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/app/history")}
>
</Button>
</div>
</div>
))}
</div>
</div>
{/* 使用统计图表 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<span className="xx-chart-total">
{weeklyData.reduce((s, d) => s + d.value, 0)}
</span>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-bar-wrapper">
<div
className="xx-chart-bar"
style={{
height: `${(d.value / maxChart) * 100}%`,
}}
>
<span className="xx-chart-bar-value">{d.value}</span>
</div>
</div>
))}
</div>
<div className="xx-chart-labels">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-label">
{d.label}
</div>
))}
</div>
</div>
</div>
</div>
{/* 右列 — 公告 + 存储用量 */}
<div className="xx-dashboard-section xx-dashboard-section--start">
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
{announcements.map((a) => (
<div key={a.id} className="xx-announcement-item">
<span
className={`xx-announcement-tag xx-announcement-tag--${a.tag}`}
>
{a.tagLabel}
</span>
<div className="xx-announcement-content">
<h4>{a.title}</h4>
<time>{a.date}</time>
</div>
</div>
))}
</div>
{/* 存储用量 */}
<div className="xx-storage-section">
<div className="xx-storage-section-title"></div>
<div className="xx-storage-bar">
<div className="xx-storage-bar-track">
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
</div>
<div className="xx-storage-bar-label">
<span>2.4 GB </span>
<span>10 GB </span>
</div>
</div>
</div>
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
{/* ── 快速入口 ─────────────────────────────────────────── */}
{/* 快速入口 */}
<div className="xx-quick-entry-section">
<div className="xx-quick-entry-header">
<h3 className="xx-quick-entry-title"></h3>
</div>
<div className="xx-quick-grid">
{quickEntries.map((entry) => (
<div
key={entry.id}
className="xx-quick-card"
onClick={() => navigate(entry.path)}
>
<div
className="xx-quick-card-icon"
style={{ background: entry.iconGradient }}
>
{iconMap[entry.icon] ?? entry.icon}
</div>
<h3>{entry.title}</h3>
<p>{entry.description}</p>
</div>
))}
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
</div>
{/* 最近任务 */}
<section className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
</Button>
</div>
<div className="xx-task-list">
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
</section>
{/* 使用统计 */}
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3>使</h3>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
<div className="xx-dashboard-empty" style={{ width: "100%" }}>
<DatabaseOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<p></p>
</div>
</div>
</div>
</section>
{/* 公告 */}
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
<div className="xx-announcement-item">
<span className="xx-announcement-tag xx-announcement-tag--notice"></span>
<div className="xx-announcement-content">
<h4>使 SaaS </h4>
<time></time>
</div>
</div>
</div>
</section>
</div>
);
};
+12 -24
View File
@@ -69,22 +69,6 @@ const VOICE_GENDER_ICON: Record<string, string> = {
neutral: "✨",
};
/* 线 Mock
* TODO: 后端暂无时间线场景数据 API使
* timeline/scene API
*/
interface TimelineScene {
scene: string;
time: string;
duration: number;
}
const MOCK_TIMELINE: TimelineScene[] = [
{ scene: "主讲口播 · 开场钩子", time: "0-8s", duration: 8 },
{ scene: "产品特写 · B-roll", time: "8-20s", duration: 12 },
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
];
/* ── 步骤定义 ── */
const STEPS = [
{ key: 1, label: "选择模板" },
@@ -1758,15 +1742,19 @@ const GeneratePage: React.FC = () => {
<div className="xx-preview-title"></div>
{/* 时间线列表 */}
<div className="xx-preview-timeline">
{MOCK_TIMELINE.map((item, idx) => (
<div key={idx} className="xx-timeline-item">
<div className="num">{idx + 1}</div>
<span className="scene-name">{item.scene}</span>
<span>{item.time}</span>
{generated ? (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
</div>
))}
</div>
</div>
) : (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
</div>
</div>
)}
{/* 生成操作按钮 */}
<div className="xx-generate-actions">
-48
View File
@@ -612,54 +612,6 @@
flex: 1;
}
/* ============================================================
按钮匹配原型 .btn .ghost / .btn .primary
============================================================ */
.xx-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 42px;
padding: 0 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
border: none;
outline: none;
white-space: nowrap;
}
.xx-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.xx-btn-primary {
background: var(--gradient-primary);
color: var(--text-inverse);
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22);
}
.xx-btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28);
}
.xx-btn-ghost {
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.xx-btn-ghost:hover:not(:disabled) {
border-color: var(--info-border);
color: var(--primary-dark);
background: var(--primary-soft);
}
/* ============================================================
右侧预览区 generate-preview
============================================================ */
+27 -118
View File
@@ -36,40 +36,24 @@ type TitleType = "hot" | "normal" | "creative";
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel";
type Frequency = "all" | "high" | "medium" | "low";
interface CategoryItem {
id: string;
name: string;
count: number;
}
interface TitleData {
id: string;
content: string;
type: TitleType;
industry: Industry;
category: string;
usageCount: number;
isFavorited: boolean;
createdAt: string;
}
/* ============================================================
* Mock
* ============================================================ */
const MOCK_CATEGORIES: CategoryItem[] = [
{ id: "cat-all", name: "全部标题", count: 15 },
{ id: "cat-1", name: "美食探店", count: 4 },
{ id: "cat-2", name: "科技数码", count: 3 },
{ id: "cat-3", name: "生活日常", count: 4 },
{ id: "cat-4", name: "美妆穿搭", count: 2 },
{ id: "cat-5", name: "教育学习", count: 2 },
];
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
@@ -250,9 +234,8 @@ const TitleCard: React.FC<{
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* 分类数据 */
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all");
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
@@ -265,6 +248,23 @@ const TitleLibrary: React.FC = () => {
[apiTitles],
);
/* 从真实标题数据动态派生分类(无需后端分类 API) */
const categories = useMemo(() => {
const cats = new Map<string, number>();
apiTitles.forEach((t) => {
const cat = t.category || "未分类";
cats.set(cat, (cats.get(cat) || 0) + 1);
});
return [
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
];
}, [apiTitles]);
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
@@ -301,10 +301,6 @@ const TitleLibrary: React.FC = () => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState("");
/* 新建分类 */
const [createCatModalOpen, setCreateCatModalOpen] = useState(false);
const [newCatName, setNewCatName] = useState("");
/* 新建标题 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false);
const [newTitleContent, setNewTitleContent] = useState("");
@@ -322,19 +318,11 @@ const TitleLibrary: React.FC = () => {
const filteredTitles = useMemo(() => {
let list = titles;
/* 按分类过滤("全部标题" 不过滤) */
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
if (activeCatId !== "cat-all") {
const catName = activeCategory?.name || "";
const catToIndustry: Record<string, Industry> = {
: "food",
: "tech",
: "general",
穿: "beauty",
: "education",
};
const mappedIndustry = catToIndustry[catName];
if (mappedIndustry) {
list = list.filter((t) => t.industry === mappedIndustry);
if (catName) {
list = list.filter((t) => t.category === catName);
}
}
@@ -428,32 +416,6 @@ const TitleLibrary: React.FC = () => {
[deleteMutation],
);
/* 新建分类 */
const handleCreateCategory = () => {
if (!newCatName.trim()) {
message.warning("请输入分类名称");
return;
}
const cat: CategoryItem = {
id: `cat-${Date.now()}`,
name: newCatName.trim(),
count: 0,
};
setCategories((prev) => [...prev, cat]);
setActiveCatId(cat.id);
setCreateCatModalOpen(false);
setNewCatName("");
message.success(`分类 "${cat.name}" 创建成功`);
};
/* 删除分类 */
const handleDeleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
if (activeCatId === id) {
setActiveCatId("cat-all");
}
message.success("分类已删除");
};
/* 新建标题 */
const handleCreateTitle = () => {
@@ -541,38 +503,12 @@ const TitleLibrary: React.FC = () => {
</h4>
<span>{cat.count} </span>
</div>
{cat.id !== "cat-all" && (
<Popconfirm
title={`确定删除分类 "${cat.name}"`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteCategory(cat.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-title-category-delete"
onClick={(e) => e.stopPropagation()}
title="删除分类"
>
<DeleteOutlined />
</button>
</Popconfirm>
)}
</div>
</div>
))}
{/* 新建分类 */}
<div
className="xx-title-category-add"
onClick={() => setCreateCatModalOpen(true)}
>
<PlusOutlined />
</div>
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
</div>
{/* ─── 右侧:内容区 ─── */}
@@ -678,34 +614,7 @@ const TitleLibrary: React.FC = () => {
</div>
</div>
{/* ─── 新建分类弹窗 ─── */}
<AntModal
title="新建分类"
open={createCatModalOpen}
onCancel={() => setCreateCatModalOpen(false)}
onOk={handleCreateCategory}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div style={{ padding: "8px 0" }}>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="请输入分类名称"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
maxLength={30}
/>
</div>
</AntModal>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
@@ -18,7 +18,7 @@ import {
CloseCircleOutlined,
} from "@ant-design/icons";
import PageHead from "@/components/layout/PageHead";
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
import CloneModal from "@/components/voice/CloneModal";
import {
getVoiceClones,
deleteVoiceClone,
@@ -356,7 +356,7 @@ const VoiceClone: React.FC = () => {
)}
{/* 克隆音色弹窗 */}
<CloneVoiceModal
<CloneModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={() => {
-7
View File
@@ -170,13 +170,6 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
},
{
path: "accounts",
lazy: () =>
+1
View File
@@ -17,6 +17,7 @@ class WorkerSettings(BaseSettings):
database_pool_recycle: int = 3600
environment: str = "development"
auto_create_schema: bool = False
redis_url: str = "redis://redis:6379/0"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
render_engine: str = "legacy"
+31 -15
View File
@@ -113,8 +113,8 @@ from video_processing.oss_helpers import (
get_signed_download_url,
upload_to_oss,
)
from video_processing.unified_render_service import UnifiedRenderService
from video_processing.render_engine_resolver import ENGINE_LEGACY, ENGINE_UNIFIED
from video_processing.unified_render_service import UnifiedRenderService
# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ────────────────────────────────
@@ -619,7 +619,8 @@ def _render_with_legacy_engine(
import subprocess
main_clips = [
c for c in virtual_clips
c
for c in virtual_clips
if c.clip_type in ("main", "b_roll", "background")
or (c.clip_type == "main" and c.config.get("role") == "b_roll")
]
@@ -674,17 +675,27 @@ def _render_with_legacy_engine(
filter_complex = ";".join(fc_parts)
command = [
FFMPEG_BIN, "-y",
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex", filter_complex,
"-map", video_label,
"-map", audio_label,
"-c:v", "libx264",
"-crf", "23",
"-preset", "medium",
"-c:a", "aac",
"-b:a", "192k",
"-movflags", "+faststart",
"-filter_complex",
filter_complex,
"-map",
video_label,
"-map",
audio_label,
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
str(output_path),
]
@@ -694,7 +705,9 @@ def _render_with_legacy_engine(
except subprocess.CalledProcessError as e:
logger.error(
"[task_id=%s] [渲染] legacy 引擎 FFmpeg 失败: %s\nfilter_complex: %s",
task_id, e, filter_complex[:500],
task_id,
e,
filter_complex[:500],
)
raise
@@ -880,7 +893,9 @@ def generate_video(self, task_id: str) -> dict:
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] legacy 引擎完成: 耗时=%.1fs, 时长=%.2fs",
task_id, render_elapsed, render_duration,
task_id,
render_elapsed,
render_duration,
)
else:
# 新引擎:UnifiedRenderService 图层架构
@@ -901,7 +916,8 @@ def generate_video(self, task_id: str) -> dict:
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
task_id, render_elapsed,
task_id,
render_elapsed,
)
if gen_task:
+1 -1
View File
@@ -112,7 +112,7 @@
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliiyuncs.com` |
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliyuncs.com` |
| `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) |
| `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) |
| `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` |
-1
View File
@@ -1 +0,0 @@
"""Packages root."""
-1
View File
@@ -1 +0,0 @@
"""Adapters package for external implementations."""
+1 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint, create_engine
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import declarative_base
Base = declarative_base()
+1
View File
@@ -64,6 +64,7 @@ __all__ = [
"CreateAssetUseCase",
"CreateGenerationTaskCommand",
"CreateGenerationTaskUseCase",
"GetGenerationTaskUseCase",
"CreateJobCommand",
"CreateJobUseCase",
"CreateProjectCommand",
+1 -2
View File
@@ -12,10 +12,9 @@ JWT 处理器委托层
payload = jwt_handler.verify_access_token(token)
"""
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
from packages.application.auth.jwt_service import JWTConfig, JWTService
class JWTHandler:
+4 -4
View File
@@ -208,10 +208,10 @@ def _get_jwt_service():
kw = dict(secret_key=settings.JWT_SECRET_KEY)
if hasattr(settings, "JWT_ALGORITHM"):
kw["algorithm"] = settings.JWT_ALGORITHM
if hasattr(settings, "ACCESS_TOKEN_EXPIRE_MINUTES"):
kw["access_token_expire_minutes"] = settings.ACCESS_TOKEN_EXPIRE_MINUTES
if hasattr(settings, "REFRESH_TOKEN_EXPIRE_DAYS"):
kw["refresh_token_expire_days"] = settings.REFRESH_TOKEN_EXPIRE_DAYS
if hasattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_MINUTES"):
kw["access_token_expire_minutes"] = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
if hasattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_DAYS"):
kw["refresh_token_expire_days"] = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
_jwt_service_instance = JWTService(JWTConfig(**kw))
return _jwt_service_instance
+1 -1
View File
@@ -293,7 +293,7 @@ class LogoutUseCase:
try:
if request.logout_all_devices:
# 删除所有设备的 session
count = self.session_store.delete_all_user_sessions(request.user_id)
self.session_store.delete_all_user_sessions(request.user_id)
return True, None
else:
# 删除当前 session
@@ -85,8 +85,6 @@ class PasswordHasher:
True 如果需要重新哈希
"""
try:
hashed_bytes = hashed_password.encode("utf-8")
current_rounds = bcrypt.getsalt(hashed_bytes)
# 提取当前的 cost factor
# bcrypt hash 格式: $2b$rounds$salt+hash
@@ -3,7 +3,7 @@
"""
import secrets
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
+1 -1
View File
@@ -3,7 +3,7 @@
"""
from math import ceil
from typing import Generic, List, Optional, TypeVar
from typing import Generic, List, TypeVar
from pydantic import BaseModel, Field
-2
View File
@@ -7,9 +7,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from packages.domain.job import Job, JobStatus, JobType
from packages.ports.job_repository import JobRepository
-1
View File
@@ -9,7 +9,6 @@ from typing import List, Optional
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.domain.recipe import Recipe, RecipeItem
-1
View File
@@ -1 +0,0 @@
"""TTS Job application layer."""
@@ -148,7 +148,6 @@ class TTSStreamingService:
# 并发合成所有分段,按顺序流式推送
queue: asyncio.Queue[tuple[int, Optional[bytes], Optional[str]]] = asyncio.Queue()
completed_count = 0
async def _synthesize_one(idx: int, seg_text: str) -> None:
"""合成单个分段并放入队列。"""
+1 -1
View File
@@ -24,7 +24,7 @@ from packages.application.cosyvoice_service import (
CosyVoiceError,
CosyVoiceService,
)
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
from packages.application.tts_job.audio_merger import AudioMerger
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.ports.tts_job_repository import TTSJobRepository
@@ -2,7 +2,6 @@
from __future__ import annotations
import uuid
from typing import List, Optional
from packages.domain.voice_clone_profile import VoiceCloneProfile
+2 -3
View File
@@ -10,7 +10,7 @@
from __future__ import annotations
import logging
from typing import Any, Optional
from typing import Optional
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
@@ -21,9 +21,8 @@ from packages.application.voice_clone.use_cases import (
CreateVoiceCloneUseCase,
RetryVoiceCloneUseCase,
VoiceCloneNotFoundError,
VoiceCloneNotRetryableError,
)
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
from packages.domain.voice_clone_profile import VoiceCloneProfile
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
logger = logging.getLogger(__name__)
-1
View File
@@ -13,7 +13,6 @@ else:
pass
from typing import Any
from uuid import uuid4
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import copy
from enum import Enum
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
+1 -1
View File
@@ -24,7 +24,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Set
from typing import Dict, Optional
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain import AssetLibrary
class AssetLibraryRepository(ABC):
+1 -1
View File
@@ -24,7 +24,7 @@ class SharedSettings(BaseSettings):
celery_result_backend: str = "redis://localhost:6379/1"
# OSS Aliyun
oss_endpoint: str = "oss-cn-hangzhou.aliiyuncs.com"
oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com"
oss_access_key_id: str = ""
oss_access_key_secret: str = ""
oss_bucket_name: str = "xiaoxia-autocut"
+1
View File
@@ -247,3 +247,4 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
-496
View File
@@ -1,496 +0,0 @@
"""
仪表盘 API 集成测试
覆盖端点
- GET /dashboard/overview 仪表盘概览
验证返回数据结构空数据场景数据汇总正确性
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.api.routes.dashboard import router
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_generation_task_repository,
get_project_repository,
get_title_library_repository,
get_voice_library_repository,
)
from packages.domain.entities import Project, User
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ---------------------------------------------------------------------------
# 1. 内存 Repository
# ---------------------------------------------------------------------------
class InMemoryProjectRepository:
def __init__(self):
self._projects: dict[str, Project] = {}
def save(self, project: Project) -> None:
self._projects[project.id] = project
def find_by_id(self, project_id: str):
return self._projects.get(project_id)
def find_by_owner_user_id(self, owner_user_id: str):
return [p for p in self._projects.values() if p.owner_user_id == owner_user_id]
def find_accessible_projects(self, user_id: str):
return [p for p in self._projects.values() if p.owner_user_id == user_id]
def count_by_owner(self, owner_user_id: str) -> int:
return len(self.find_by_owner_user_id(owner_user_id))
def delete(self, project_id: str) -> bool:
if project_id in self._projects:
del self._projects[project_id]
return True
return False
class InMemoryAssetRepository:
def __init__(self):
self._assets = []
def add_asset(self, project_id: str, storage_size: int = 0):
self._assets.append({"project_id": project_id, "storage_size": storage_size})
def count_by_project_ids(self, project_ids: list[str]) -> int:
return sum(1 for a in self._assets if a["project_id"] in project_ids)
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
return sum(a["storage_size"] for a in self._assets if a["project_id"] in project_ids)
# 其他方法占位
def create(self, asset):
return asset
def find_by_id(self, asset_id):
return None
def find_by_project(self, project_id, **kwargs):
return []
def find_by_library(self, library_id, **kwargs):
return []
def update(self, asset):
return asset
def delete(self, asset_id):
return False
def batch_delete(self, asset_ids):
return 0
def search_candidates(self, **kwargs):
return []
def find_by_tag_ids(self, tag_ids):
return []
def count_by_project(self, project_id):
return 0
def find_by_library_and_file_type(self, library_id, file_type):
return []
def find_by_library_and_file_hash(self, library_id, file_hash):
return None
class InMemoryGenerationTaskRepository:
def __init__(self):
self._tasks = {}
def add_task(self, task: GenerationTask):
self._tasks[task.id] = task
def count_by_user(self, user_id: str) -> int:
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list:
user_tasks = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
# 按 created_at 倒序
user_tasks.sort(key=lambda t: t.created_at, reverse=True)
return user_tasks[:limit]
# 其他方法占位
def create(self, task):
return task
def get(self, task_id):
return None
def list_by_project(self, project_id):
return []
def list_by_user(self, user_id):
return []
def list_by_source_edit_plan(self, plan_id):
return []
def update(self, task):
return task
class InMemoryTitleLibraryRepository:
def __init__(self):
self._items = {}
def add_item(self, user_id: str):
from uuid import uuid4
item_id = uuid4().hex
self._items[item_id] = {"id": item_id, "user_id": user_id}
return item_id
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
return len([i for i in self._items.values() if i["user_id"] == user_id])
# 其他方法占位
def list_by_user(self, user_id, **kwargs):
return []
def get(self, title_id, user_id):
return None
def create(self, item):
return item
def update(self, item):
return item
def delete(self, title_id, user_id):
return False
class InMemoryVoiceLibraryRepository:
def __init__(self):
self._items = {}
def add_item(self, user_id: str):
from uuid import uuid4
item_id = uuid4().hex
self._items[item_id] = {"id": item_id, "user_id": user_id}
return item_id
def count_by_user(self, user_id: str) -> int:
return len([i for i in self._items.values() if i["user_id"] == user_id])
# 其他方法占位
def list_by_user(self, user_id, **kwargs):
return []
def get(self, voice_id, user_id):
return None
def create(self, item):
return item
def update(self, item):
return item
def delete(self, voice_id, user_id):
return False
# ---------------------------------------------------------------------------
# 2. 辅助函数
# ---------------------------------------------------------------------------
def _make_user(**overrides) -> User:
defaults = dict(
id="user-test-001",
email="test@example.com",
display_name="Test User",
username="testuser",
subscription_plan="free",
subscription_status="active",
max_projects=3,
max_storage_gb=10,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
def _make_project(project_id: str, owner_user_id: str = "user-test-001") -> Project:
return Project(
id=project_id,
name=f"Project {project_id}",
owner_user_id=owner_user_id,
description="",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def _make_generation_task(
task_id: str,
user_id: str = "user-test-001",
status: GenerationTaskStatus = GenerationTaskStatus.COMPLETED,
created_at: datetime | None = None,
) -> GenerationTask:
return GenerationTask(
id=task_id,
project_id="proj-1",
asset_library_id="lib-1",
created_by_user_id=user_id,
status=status,
error_message="",
created_at=created_at or datetime.now(timezone.utc),
started_at=datetime.now(timezone.utc) if status != GenerationTaskStatus.PENDING else None,
completed_at=datetime.now(timezone.utc) if status == GenerationTaskStatus.COMPLETED else None,
)
# ---------------------------------------------------------------------------
# 3. Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def project_repo():
repo = InMemoryProjectRepository()
repo.save(_make_project("proj-1", "user-test-001"))
repo.save(_make_project("proj-2", "user-test-001"))
repo.save(_make_project("proj-other", "other-user"))
return repo
@pytest.fixture
def asset_repo():
return InMemoryAssetRepository()
@pytest.fixture
def generation_task_repo():
return InMemoryGenerationTaskRepository()
@pytest.fixture
def title_library_repo():
return InMemoryTitleLibraryRepository()
@pytest.fixture
def voice_library_repo():
return InMemoryVoiceLibraryRepository()
@pytest.fixture
def client(project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/dashboard")
def _override_current_user():
return AuthenticatedUser(user=_make_user())
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 4. GET /overview — 仪表盘概览
# ---------------------------------------------------------------------------
class TestDashboardOverview:
"""仪表盘概览端点测试。"""
def test_empty_data_returns_zeros(self, client):
"""空数据时所有计数为 0。"""
resp = client.get("/dashboard/overview")
assert resp.status_code == 200
data = resp.json()
assert data["total_assets"] == 0
assert data["used_storage_bytes"] == 0
assert data["total_titles"] == 0
assert data["total_voices"] == 0
assert data["total_tasks"] == 0
assert data["total_products"] == 2 # fixture 中有 2 个项目
assert data["recent_tasks"] == []
def test_assets_count_and_storage(self, client, asset_repo):
"""素材统计正确。"""
asset_repo.add_asset("proj-1", 1024)
asset_repo.add_asset("proj-1", 2048)
asset_repo.add_asset("proj-2", 4096)
# 其他用户的不计入
asset_repo.add_asset("proj-other", 9999)
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_assets"] == 3
assert data["used_storage_bytes"] == 1024 + 2048 + 4096
def test_title_library_count(self, client, title_library_repo):
"""标题库统计正确。"""
title_library_repo.add_item("user-test-001")
title_library_repo.add_item("user-test-001")
title_library_repo.add_item("user-test-001")
title_library_repo.add_item("other-user")
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_titles"] == 3
def test_voice_library_count(self, client, voice_library_repo):
"""配音库统计正确。"""
voice_library_repo.add_item("user-test-001")
voice_library_repo.add_item("other-user")
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_voices"] == 1
def test_generation_tasks_count(self, client, generation_task_repo):
"""生成任务统计正确。"""
generation_task_repo.add_task(_make_generation_task("task-1"))
generation_task_repo.add_task(_make_generation_task("task-2"))
generation_task_repo.add_task(_make_generation_task("task-other", user_id="other-user"))
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_tasks"] == 2
def test_recent_tasks_limited_to_5(self, client, generation_task_repo):
"""最近任务最多返回 5 个。"""
for i in range(10):
task = _make_generation_task(f"task-{i}")
generation_task_repo.add_task(task)
resp = client.get("/dashboard/overview")
data = resp.json()
assert len(data["recent_tasks"]) <= 5
def test_recent_tasks_have_correct_fields(self, client, generation_task_repo):
"""最近任务包含正确字段。"""
task = _make_generation_task("task-1", status=GenerationTaskStatus.COMPLETED)
generation_task_repo.add_task(task)
resp = client.get("/dashboard/overview")
data = resp.json()
assert len(data["recent_tasks"]) == 1
item = data["recent_tasks"][0]
for field in ["id", "task_type", "status", "current_step", "error_message", "updated_at"]:
assert field in item, f"缺少字段: {field}"
assert item["task_type"] == "generation"
def test_subscription_info(self, client):
"""订阅信息正确。"""
resp = client.get("/dashboard/overview")
data = resp.json()
assert "subscription" in data
sub = data["subscription"]
assert "plan" in sub
assert "is_active" in sub
assert sub["plan"] == "free"
assert sub["is_active"] is True
def test_pro_user_subscription(
self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo
):
"""Pro 用户订阅信息正确。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/dashboard")
test_app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(
user=_make_user(subscription_plan="pro", subscription_status="active")
)
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
c = TestClient(test_app)
resp = c.get("/dashboard/overview")
assert resp.status_code == 200
assert resp.json()["subscription"]["plan"] == "pro"
assert resp.json()["subscription"]["is_active"] is True
test_app.dependency_overrides.clear()
def test_total_products_count(self, client, project_repo):
"""项目(产品)数量正确。"""
resp = client.get("/dashboard/overview")
data = resp.json()
assert data["total_products"] == 2
# 新增一个项目后
project_repo.save(_make_project("proj-3", "user-test-001"))
resp2 = client.get("/dashboard/overview")
assert resp2.json()["total_products"] == 3
def test_unauthorized_returns_401(
self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo
):
"""未授权访问返回 401/403。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/dashboard")
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
c = TestClient(test_app)
resp = c.get("/dashboard/overview")
assert resp.status_code in (401, 403)
test_app.dependency_overrides.clear()
def test_recent_tasks_status_mapping(self, client, generation_task_repo):
"""不同状态的任务显示正确的当前步骤。"""
# 已完成任务
completed_task = _make_generation_task("task-completed", status=GenerationTaskStatus.COMPLETED)
generation_task_repo.add_task(completed_task)
resp = client.get("/dashboard/overview")
tasks = resp.json()["recent_tasks"]
completed = [t for t in tasks if t["id"] == "task-completed"][0]
assert completed["status"] == "completed"
assert "完成" in completed["current_step"] or "completed" in completed["current_step"].lower()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -1,554 +0,0 @@
"""
生成视频管理 API 集成测试
覆盖端点
- GET /generated-videos 列出生成视频
- GET /generated-videos/{video_id} 获取生成视频详情
- PATCH /generated-videos/{video_id}/review 更新审核状态
- GET /generated-videos/{video_id}/download-url 获取下载地址
使用 FastAPI TestClient + dependency_overrides 模式
导入真实路由模块mock 所有外部依赖
"""
from __future__ import annotations
import os
import sys
from dataclasses import replace
from datetime import datetime, timezone
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.api.routes.generated_videos import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_generated_video_repository, get_project_repository
from packages.domain.entities import Project, User
from packages.domain.generated_video import GeneratedVideo
# ---------------------------------------------------------------------------
# 1. 内存 Repository + 辅助函数
# ---------------------------------------------------------------------------
class InMemoryGeneratedVideoRepository:
"""内存中的生成视频 Repository。"""
def __init__(self):
self._items: dict[str, GeneratedVideo] = {}
def create(self, video: GeneratedVideo) -> GeneratedVideo:
self._items[video.id] = video
return video
def get(self, video_id: str) -> GeneratedVideo | None:
return self._items.get(video_id)
def update(self, video: GeneratedVideo) -> GeneratedVideo:
self._items[video.id] = video
return video
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
return [v for v in self._items.values() if v.project_id == project_id]
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
return [v for v in self._items.values() if v.generation_task_id == generation_task_id]
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
return []
class InMemoryProjectRepository:
"""内存中的项目 Repository。"""
def __init__(self):
self._projects: dict[str, Project] = {}
def save(self, project: Project) -> None:
self._projects[project.id] = project
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]:
return [p for p in self._projects.values() if p.owner_user_id == owner_user_id]
def find_accessible_projects(self, user_id: str) -> list[Project]:
return [p for p in self._projects.values() if p.owner_user_id == user_id]
def count_by_owner(self, owner_user_id: str) -> int:
return len(self.find_by_owner_user_id(owner_user_id))
def delete(self, project_id: str) -> bool:
if project_id in self._projects:
del self._projects[project_id]
return True
return False
class MockStorageService:
"""Mock OSS 存储服务。"""
def get_download_url(self, file_url: str) -> str:
return f"https://cdn.example.com/download/{file_url}?token=abc123"
def _make_user(**overrides) -> User:
defaults = dict(
id="user-test-001",
email="test@example.com",
display_name="Test User",
username="testuser",
subscription_plan="free",
subscription_status="active",
max_projects=3,
max_storage_gb=10,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
def _make_project(project_id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
return Project(
id=project_id,
name=f"Project {project_id}",
owner_user_id=owner_user_id,
description="",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def _make_video(
project_id: str = "proj-1",
name: str = "output.mp4",
status: str = "completed",
review_status: str = "pending_review",
**kwargs,
) -> GeneratedVideo:
return GeneratedVideo.create(
project_id=project_id,
generation_task_id=kwargs.pop("generation_task_id", "task-1"),
name=name,
file_url=kwargs.pop("file_url", f"generated/{name}"),
file_size=kwargs.pop("file_size", 1024000),
duration=kwargs.pop("duration", 30.5),
width=kwargs.pop("width", 1920),
height=kwargs.pop("height", 1080),
fps=kwargs.pop("fps", 30.0),
thumbnail_url=kwargs.pop("thumbnail_url", None),
generation_params=kwargs.pop("generation_params", {"resolution": "1080p"}),
)
# ---------------------------------------------------------------------------
# 2. Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def video_repo():
return InMemoryGeneratedVideoRepository()
@pytest.fixture
def project_repo():
repo = InMemoryProjectRepository()
# 默认创建一个项目
repo.save(_make_project("proj-1", "user-test-001"))
repo.save(_make_project("proj-2", "user-test-001"))
repo.save(_make_project("proj-other", "other-user"))
return repo
@pytest.fixture
def storage_service():
return MockStorageService()
@pytest.fixture
def client(video_repo, project_repo, storage_service):
"""创建带有依赖覆盖的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/generated-videos")
def _override_current_user():
return AuthenticatedUser(user=_make_user())
def _override_video_repo():
return video_repo
def _override_project_repo():
return project_repo
def _override_storage():
return storage_service
test_app.dependency_overrides[get_current_user] = _override_current_user
test_app.dependency_overrides[get_generated_video_repository] = _override_video_repo
test_app.dependency_overrides[get_project_repository] = _override_project_repo
test_app.dependency_overrides[get_storage_service] = _override_storage
yield TestClient(test_app)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 3. GET / — 列出生成视频
# ---------------------------------------------------------------------------
class TestListGeneratedVideos:
"""列出生成视频端点测试。"""
def test_empty_list(self, client):
"""无视频时返回空列表。"""
resp = client.get("/generated-videos")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
def test_list_all_user_videos(self, client, video_repo, project_repo):
"""列出当前用户所有项目的视频。"""
v1 = _make_video(project_id="proj-1", name="video1.mp4")
v2 = _make_video(project_id="proj-2", name="video2.mp4")
v3 = _make_video(project_id="proj-other", name="other.mp4") # 其他用户
video_repo.create(v1)
video_repo.create(v2)
video_repo.create(v3)
resp = client.get("/generated-videos")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
names = {item["name"] for item in data["items"]}
assert names == {"video1.mp4", "video2.mp4"}
def test_filter_by_project_id(self, client, video_repo):
"""按 project_id 筛选视频。"""
v1 = _make_video(project_id="proj-1", name="a.mp4")
v2 = _make_video(project_id="proj-2", name="b.mp4")
video_repo.create(v1)
video_repo.create(v2)
resp = client.get("/generated-videos?project_id=proj-1")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 1
assert data["items"][0]["name"] == "a.mp4"
def test_filter_by_nonexistent_project_returns_404(self, client):
"""筛选不存在的项目返回 404。"""
resp = client.get("/generated-videos?project_id=nonexistent")
assert resp.status_code == 404
def test_list_includes_download_url(self, client, video_repo):
"""列表响应应包含下载地址。"""
v = _make_video(file_url="generated/test.mp4")
video_repo.create(v)
resp = client.get("/generated-videos")
assert resp.status_code == 200
item = resp.json()["items"][0]
assert "download_url" in item
assert item["download_url"] is not None
assert "cdn.example.com" in item["download_url"]
def test_list_response_fields(self, client, video_repo):
"""列表响应包含所有必需字段。"""
v = _make_video()
video_repo.create(v)
resp = client.get("/generated-videos")
item = resp.json()["items"][0]
for field in [
"id",
"project_id",
"generation_task_id",
"name",
"file_url",
"file_size",
"duration",
"width",
"height",
"fps",
"status",
"review_status",
"generation_params",
"download_url",
]:
assert field in item, f"缺少字段: {field}"
def test_unauthorized_returns_401(self, video_repo, project_repo, storage_service):
"""未授权访问返回 401/403。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/generated-videos")
# 不覆盖 get_current_user,使用默认(会拒绝无 token 请求)
test_app.dependency_overrides[get_generated_video_repository] = lambda: video_repo
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_storage_service] = lambda: storage_service
c = TestClient(test_app)
resp = c.get("/generated-videos")
# 无 token 时 fastapi HTTPBearer auto_error=False 会返回 None
# get_current_user 会抛 401
assert resp.status_code in (401, 403)
test_app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 4. GET /{video_id} — 获取生成视频详情
# ---------------------------------------------------------------------------
class TestGetGeneratedVideo:
"""获取生成视频详情端点测试。"""
def test_get_existing_video(self, client, video_repo):
"""获取存在的视频返回详情。"""
v = _make_video(name="detail.mp4", duration=45.0)
video_repo.create(v)
resp = client.get(f"/generated-videos/{v.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == v.id
assert data["name"] == "detail.mp4"
assert data["duration"] == 45.0
assert data["status"] == "completed"
def test_get_includes_download_url(self, client, video_repo):
"""详情响应包含下载地址。"""
v = _make_video(file_url="generated/detail.mp4")
video_repo.create(v)
resp = client.get(f"/generated-videos/{v.id}")
data = resp.json()
assert "download_url" in data
assert "cdn.example.com" in data["download_url"]
def test_get_nonexistent_returns_404(self, client):
"""获取不存在的视频返回 404。"""
resp = client.get("/generated-videos/nonexistent-video-id")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower()
def test_get_thumbnail_url(self, client, video_repo):
"""有缩略图时返回缩略图 URL。"""
v = _make_video(thumbnail_url="thumbs/test.jpg")
video_repo.create(v)
resp = client.get(f"/generated-videos/{v.id}")
data = resp.json()
assert data["thumbnail_url"] == "thumbs/test.jpg"
def test_get_generation_params(self, client, video_repo):
"""返回生成参数。"""
params = {"resolution": "4k", "style": "cinematic"}
v = _make_video(generation_params=params)
video_repo.create(v)
resp = client.get(f"/generated-videos/{v.id}")
data = resp.json()
assert data["generation_params"]["resolution"] == "4k"
assert data["generation_params"]["style"] == "cinematic"
# ---------------------------------------------------------------------------
# 5. PATCH /{video_id}/review — 更新审核状态
# ---------------------------------------------------------------------------
class TestUpdateReviewStatus:
"""更新审核状态端点测试。"""
def test_approve_video(self, client, video_repo):
"""审核通过。"""
v = _make_video(review_status="pending_review")
video_repo.create(v)
resp = client.patch(
f"/generated-videos/{v.id}/review",
json={"review_status": "approved"},
)
assert resp.status_code == 200
data = resp.json()
assert data["review_status"] == "approved"
# 验证 repository 已更新
updated = video_repo.get(v.id)
assert updated.review_status == "approved"
def test_reject_video(self, client, video_repo):
"""审核拒绝。"""
v = _make_video(review_status="pending_review")
video_repo.create(v)
resp = client.patch(
f"/generated-videos/{v.id}/review",
json={"review_status": "rejected"},
)
assert resp.status_code == 200
assert resp.json()["review_status"] == "rejected"
def test_set_pending_review(self, client, video_repo):
"""设置为待审核。"""
v = _make_video(review_status="approved")
video_repo.create(v)
resp = client.patch(
f"/generated-videos/{v.id}/review",
json={"review_status": "pending_review"},
)
assert resp.status_code == 200
assert resp.json()["review_status"] == "pending_review"
def test_nonexistent_video_returns_404(self, client):
"""更新不存在的视频返回 404。"""
resp = client.patch(
"/nonexistent-id/review",
json={"review_status": "approved"},
)
assert resp.status_code == 404
def test_invalid_status_returns_422(self, client, video_repo):
"""无效审核状态返回 422。"""
v = _make_video()
video_repo.create(v)
resp = client.patch(
f"/generated-videos/{v.id}/review",
json={"review_status": "invalid_status"},
)
assert resp.status_code == 422
def test_missing_status_returns_422(self, client, video_repo):
"""缺少 review_status 字段返回 422。"""
v = _make_video()
video_repo.create(v)
resp = client.patch(f"/generated-videos/{v.id}/review", json={})
assert resp.status_code == 422
def test_update_returns_updated_fields(self, client, video_repo):
"""更新后返回完整的视频信息。"""
v = _make_video(name="review_test.mp4")
video_repo.create(v)
resp = client.patch(
f"/generated-videos/{v.id}/review",
json={"review_status": "approved"},
)
data = resp.json()
assert data["name"] == "review_test.mp4"
assert "id" in data
assert "download_url" in data
# ---------------------------------------------------------------------------
# 6. GET /{video_id}/download-url — 获取下载地址
# ---------------------------------------------------------------------------
class TestGetDownloadUrl:
"""获取下载地址端点测试。"""
def test_get_download_url_success(self, client, video_repo):
"""获取下载地址成功。"""
v = _make_video(file_url="generated/video.mp4")
video_repo.create(v)
resp = client.get(f"/generated-videos/{v.id}/download-url")
assert resp.status_code == 200
data = resp.json()
assert data["video_id"] == v.id
assert "download_url" in data
assert "cdn.example.com" in data["download_url"]
def test_nonexistent_video_returns_404(self, client):
"""获取不存在视频的下载地址返回 404。"""
resp = client.get("/generated-videos/nonexistent-id/download-url")
assert resp.status_code == 404
def test_download_url_format(self, client, video_repo):
"""下载地址格式正确。"""
v = _make_video(file_url="my-video.mp4")
video_repo.create(v)
resp = client.get(f"/generated-videos/{v.id}/download-url")
url = resp.json()["download_url"]
assert url.startswith("https://")
assert "token=" in url
# ---------------------------------------------------------------------------
# 7. 跨端点场景
# ---------------------------------------------------------------------------
class TestCrossEndpointScenarios:
"""跨端点集成场景。"""
def test_create_list_detail_review_flow(self, client, video_repo):
"""列表 → 详情 → 审核 完整流程。"""
# 准备数据
v = _make_video(name="flow.mp4", review_status="pending_review")
video_repo.create(v)
# 1. 列表
list_resp = client.get("/generated-videos")
assert list_resp.status_code == 200
assert len(list_resp.json()["items"]) == 1
# 2. 详情
detail_resp = client.get(f"/generated-videos/{v.id}")
assert detail_resp.status_code == 200
assert detail_resp.json()["name"] == "flow.mp4"
assert detail_resp.json()["review_status"] == "pending_review"
# 3. 审核通过
review_resp = client.patch(
f"/generated-videos/{v.id}/review",
json={"review_status": "approved"},
)
assert review_resp.status_code == 200
assert review_resp.json()["review_status"] == "approved"
# 4. 再次查看详情确认
detail_resp2 = client.get(f"/generated-videos/{v.id}")
assert detail_resp2.json()["review_status"] == "approved"
# 5. 获取下载地址
dl_resp = client.get(f"/generated-videos/{v.id}/download-url")
assert dl_resp.status_code == 200
assert dl_resp.json()["video_id"] == v.id
def test_multiple_videos_pagination_simulation(self, client, video_repo):
"""多个视频时列表正确返回所有视频。"""
for i in range(5):
v = _make_video(project_id="proj-1", name=f"video_{i}.mp4")
video_repo.create(v)
resp = client.get("/generated-videos")
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 5
names = {item["name"] for item in items}
assert len(names) == 5 # 全部不同
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+3 -3
View File
@@ -55,7 +55,7 @@ class TestOSSConfigDefaults:
def test_oss_endpoint_default(self):
settings = _fresh_settings()
assert settings.OSS_ENDPOINT == "oss-cn-hangzhou.aliiyuncs.com"
assert settings.OSS_ENDPOINT == "oss-cn-hangzhou.aliyuncs.com"
def test_oss_access_key_id_default_empty(self):
settings = _fresh_settings()
@@ -88,8 +88,8 @@ class TestOSSConfigEnvOverride:
"""环境变量能正确覆盖 OSS 配置字段。"""
def test_oss_endpoint_override(self):
settings = _fresh_settings(OSS_ENDPOINT="oss-cn-shanghai.aliiyuncs.com")
assert settings.OSS_ENDPOINT == "oss-cn-shanghai.aliiyuncs.com"
settings = _fresh_settings(OSS_ENDPOINT="oss-cn-shanghai.aliyuncs.com")
assert settings.OSS_ENDPOINT == "oss-cn-shanghai.aliyuncs.com"
def test_oss_access_key_id_override(self):
settings = _fresh_settings(OSS_ACCESS_KEY_ID="test-key-id")
-427
View File
@@ -1,427 +0,0 @@
"""模板管理 API 单元测试 — Phase 8 任务 2.03.
覆盖 5 个端点
GET /api/v1/edit-templates 列表分页 + 筛选
GET /api/v1/edit-templates/{id} 详情
POST /api/v1/edit-templates 创建
PUT /api/v1/edit-templates/{id} 更新
DELETE /api/v1/edit-templates/{id} 软删除
使用 FastAPI TestClient + Stub Repository + dependency_overrides.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Optional
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from packages.domain.config_schemas import normalize_template_config
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
# ── Stub Repository ───────────────────────────────────────────────────────────
class StubEditTemplateRepository:
"""内存中模拟 EditTemplate 仓储"""
def __init__(self) -> None:
self._store: dict[str, EditTemplate] = {}
def list_all(
self,
*,
template_type: Optional[str] = None,
status: Optional[EditTemplateStatus] = None,
skip: int = 0,
limit: int = 50,
) -> list[EditTemplate]:
items = list(self._store.values())
if template_type:
items = [t for t in items if t.template_type == template_type]
if status:
items = [t for t in items if t.status == status]
items.sort(key=lambda t: t.created_at, reverse=True)
return items[skip : skip + limit]
def list_active(
self,
*,
template_type: Optional[str] = None,
skip: int = 0,
limit: int = 50,
) -> list[EditTemplate]:
return self.list_all(template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit)
def get(self, template_id: str) -> Optional[EditTemplate]:
return self._store.get(template_id)
def create(self, template: EditTemplate) -> EditTemplate:
self._store[template.id] = template
return template
def update(self, template: EditTemplate) -> EditTemplate:
if template.id not in self._store:
raise ValueError(f"EditTemplate {template.id} not found")
self._store[template.id] = template
return template
def delete(self, template_id: str) -> bool:
if template_id in self._store:
del self._store[template_id]
return True
return False
def count(
self,
*,
template_type: Optional[str] = None,
status: Optional[EditTemplateStatus] = None,
) -> int:
items = list(self._store.values())
if template_type:
items = [t for t in items if t.template_type == template_type]
if status:
items = [t for t in items if t.status == status]
return len(items)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
is_admin: bool = True
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
@pytest.fixture
def stub_repo() -> StubEditTemplateRepository:
return StubEditTemplateRepository()
@pytest.fixture
def app(stub_repo: StubEditTemplateRepository) -> FastAPI:
"""构建测试 FastAPI 应用,注入 Stub Repository"""
import app.services.edit_template_service as service_module
from app.api.routes.edit_templates import router
from app.auth import get_current_user
from app.dependencies import get_db_session
# 替换服务模块中的 Repository 类
original_template_repo_cls = service_module.SQLAlchemyEditTemplateRepository
original_clip_config_repo_cls = service_module.SQLAlchemyTemplateClipConfigRepository
service_module.SQLAlchemyEditTemplateRepository = lambda session: stub_repo
service_module.SQLAlchemyTemplateClipConfigRepository = lambda session: stub_repo
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/edit-templates")
# 覆盖依赖
def override_get_db_session():
yield MagicMock()
def override_get_current_user():
return FakeAuthenticatedUser()
test_app.dependency_overrides[get_db_session] = override_get_db_session
test_app.dependency_overrides[get_current_user] = override_get_current_user
yield test_app
# 恢复
service_module.SQLAlchemyEditTemplateRepository = original_template_repo_cls
service_module.SQLAlchemyTemplateClipConfigRepository = original_clip_config_repo_cls
test_app.dependency_overrides.clear()
@pytest.fixture
def client(app: FastAPI) -> TestClient:
return TestClient(app)
def _make_template(name: str = "测试模板", **kwargs: Any) -> EditTemplate:
return EditTemplate.create(name=name, **kwargs)
# ── GET /api/v1/edit-templates (列表) ─────────────────────────────────────────
class TestListTemplates:
def test_empty_list(self, client: TestClient) -> None:
resp = client.get("/api/v1/edit-templates")
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["total"] == 0
assert data["page"] == 1
def test_list_with_items(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
for i in range(3):
stub_repo.create(_make_template(f"模板{i}"))
resp = client.get("/api/v1/edit-templates")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert len(data["items"]) == 3
def test_pagination(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
for i in range(5):
stub_repo.create(_make_template(f"模板{i}"))
resp = client.get("/api/v1/edit-templates?page=1&page_size=2")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 2
assert data["total"] == 5
assert data["page"] == 1
assert data["page_size"] == 2
def test_filter_by_type(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
stub_repo.create(_make_template("Vlog模板", template_type="vlog"))
stub_repo.create(_make_template("短视频模板", template_type="short"))
stub_repo.create(_make_template("另一个Vlog", template_type="vlog"))
resp = client.get("/api/v1/edit-templates?template_type=vlog")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert all(item["template_type"] == "vlog" for item in data["items"])
def test_filter_by_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t1 = _make_template("活跃模板")
stub_repo.create(t1)
t2 = _make_template("停用模板", status=EditTemplateStatus.INACTIVE)
stub_repo.create(t2)
resp = client.get("/api/v1/edit-templates?status=active")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "活跃模板"
def test_invalid_status_filter(self, client: TestClient) -> None:
resp = client.get("/api/v1/edit-templates?status=invalid")
assert resp.status_code == 400
def test_invalid_page(self, client: TestClient) -> None:
resp = client.get("/api/v1/edit-templates?page=0")
assert resp.status_code == 422
# ── GET /api/v1/edit-templates/{id} (详情) ────────────────────────────────────
class TestGetTemplate:
def test_get_existing(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("详情模板", description="这是描述", template_type="vlog")
stub_repo.create(t)
resp = client.get(f"/api/v1/edit-templates/{t.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == t.id
assert data["name"] == "详情模板"
assert data["description"] == "这是描述"
assert data["template_type"] == "vlog"
assert data["status"] == "active"
def test_get_not_found(self, client: TestClient) -> None:
resp = client.get("/api/v1/edit-templates/nonexistent-id")
assert resp.status_code == 404
assert "不存在" in resp.json()["detail"]
# ── POST /api/v1/edit-templates (创建) ────────────────────────────────────────
class TestCreateTemplate:
def test_create_basic(self, client: TestClient) -> None:
resp = client.post("/api/v1/edit-templates", json={"name": "新模板"})
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "新模板"
assert data["description"] == ""
assert data["template_type"] == "default"
assert data["status"] == "active"
assert data["sort_weight"] == 0
assert "id" in data
def test_create_with_all_fields(self, client: TestClient) -> None:
body = {
"name": "完整模板",
"description": "完整描述",
"template_type": "vlog",
"config": {"key": "value"},
"preview_url": "https://example.com/preview.mp4",
"sort_weight": 10,
}
resp = client.post("/api/v1/edit-templates", json=body)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "完整模板"
assert data["description"] == "完整描述"
assert data["template_type"] == "vlog"
assert data["config"] == normalize_template_config({"key": "value"})
assert data["preview_url"] == "https://example.com/preview.mp4"
assert data["sort_weight"] == 10
def test_create_empty_name(self, client: TestClient) -> None:
resp = client.post("/api/v1/edit-templates", json={"name": ""})
assert resp.status_code == 422 # Pydantic min_length=1
def test_create_whitespace_name(self, client: TestClient) -> None:
resp = client.post("/api/v1/edit-templates", json={"name": " "})
assert resp.status_code == 400 # domain validation
def test_create_missing_name(self, client: TestClient) -> None:
resp = client.post("/api/v1/edit-templates", json={})
assert resp.status_code == 422
def test_create_negative_sort_weight(self, client: TestClient) -> None:
resp = client.post("/api/v1/edit-templates", json={"name": "模板", "sort_weight": -1})
assert resp.status_code == 422
# ── PUT /api/v1/edit-templates/{id} (更新) ────────────────────────────────────
class TestUpdateTemplate:
def test_update_name(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("旧名称")
stub_repo.create(t)
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名称"})
assert resp.status_code == 200
assert resp.json()["name"] == "新名称"
def test_update_multiple_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板")
stub_repo.create(t)
body = {"name": "更新后", "description": "新描述", "sort_weight": 5}
resp = client.put(f"/api/v1/edit-templates/{t.id}", json=body)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "更新后"
assert data["description"] == "新描述"
assert data["sort_weight"] == 5
def test_update_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板")
stub_repo.create(t)
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"status": "inactive"})
assert resp.status_code == 200
assert resp.json()["status"] == "inactive"
def test_update_invalid_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板")
stub_repo.create(t)
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"status": "bogus"})
assert resp.status_code == 400
def test_update_not_found(self, client: TestClient) -> None:
resp = client.put("/api/v1/edit-templates/nonexistent", json={"name": "x"})
assert resp.status_code == 404
def test_partial_update_preserves_others(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("原名", description="原描述", template_type="vlog")
stub_repo.create(t)
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名"})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "新名"
assert data["description"] == "原描述"
assert data["template_type"] == "vlog"
def test_update_empty_body(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板")
stub_repo.create(t)
resp = client.put(f"/api/v1/edit-templates/{t.id}", json={})
assert resp.status_code == 200
assert resp.json()["name"] == "模板"
# ── DELETE /api/v1/edit-templates/{id} (软删除) ───────────────────────────────
class TestDeleteTemplate:
def test_soft_delete(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("待删除")
stub_repo.create(t)
resp = client.delete(f"/api/v1/edit-templates/{t.id}")
assert resp.status_code == 204
# 软删除后仍存在,但状态为 inactive
updated = stub_repo.get(t.id)
assert updated is not None
assert updated.status == EditTemplateStatus.INACTIVE
def test_soft_delete_not_found(self, client: TestClient) -> None:
resp = client.delete("/api/v1/edit-templates/nonexistent")
assert resp.status_code == 404
def test_soft_delete_idempotent(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板")
stub_repo.create(t)
# 第一次删除
resp1 = client.delete(f"/api/v1/edit-templates/{t.id}")
assert resp1.status_code == 204
# 第二次删除(已经是 inactive,但仍可再次设为 inactive
resp2 = client.delete(f"/api/v1/edit-templates/{t.id}")
assert resp2.status_code == 204
def test_deleted_not_in_active_list(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板")
stub_repo.create(t)
client.delete(f"/api/v1/edit-templates/{t.id}")
resp = client.get("/api/v1/edit-templates?status=active")
data = resp.json()
assert data["total"] == 0
# ── Response Schema 验证 ──────────────────────────────────────────────────────
class TestResponseSchema:
def test_response_has_all_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None:
t = _make_template("模板", description="描述", template_type="vlog")
stub_repo.create(t)
resp = client.get(f"/api/v1/edit-templates/{t.id}")
data = resp.json()
expected_keys = {
"id",
"name",
"description",
"template_type",
"editing_mode",
"config",
"preview_url",
"sort_weight",
"status",
"created_at",
"updated_at",
}
assert set(data.keys()) == expected_keys
def test_list_response_structure(self, client: TestClient) -> None:
resp = client.get("/api/v1/edit-templates")
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert isinstance(data["items"], list)
+67 -23
View File
@@ -19,9 +19,10 @@ from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from pathlib import Path
import pytest
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
@@ -68,11 +69,12 @@ class _TestClip:
def test_resolver_unified_when_enabled_100_percent():
"""flag 全局开启(percentage=100)时,返回 unified。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100))
@@ -83,11 +85,12 @@ def test_resolver_unified_when_enabled_100_percent():
def test_resolver_legacy_when_flag_disabled():
"""flag 全局关闭时,返回默认引擎 legacy。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=False, percentage=100))
@@ -98,11 +101,12 @@ def test_resolver_legacy_when_flag_disabled():
def test_resolver_whitelist_overrides_percentage_0():
"""白名单用户即使 percentage=0 也走 unified。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(
@@ -121,11 +125,12 @@ def test_resolver_whitelist_overrides_percentage_0():
def test_resolver_percentage_0_all_legacy():
"""percentage=0 且无白名单时,全部走 legacy。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=0))
@@ -137,11 +142,12 @@ def test_resolver_percentage_0_all_legacy():
def test_resolver_default_unified_when_flag_off():
"""默认引擎设为 unified 且 flag 关闭时,返回 unified。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=False, percentage=0))
@@ -170,11 +176,23 @@ def test_legacy_engine_single_clip_keeps_original_fps():
# 生成 1 秒 30fps 测试视频(带音频)
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=red:s=640x360:d=1:r=30",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo:d=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=640x360:d=1:r=30",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-shortest",
str(input_path),
],
check=True,
@@ -222,11 +240,23 @@ def test_legacy_engine_two_clips_concat_duration():
color = "red" if idx == 0 else "blue"
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c={color}:s=640x360:d=1:r=30",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo:d=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"color=c={color}:s=640x360:d=1:r=30",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-shortest",
str(inp),
],
check=True,
@@ -264,11 +294,23 @@ def test_legacy_engine_broll_mode_supported():
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=green:s=640x360:d=1:r=30",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo:d=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=green:s=640x360:d=1:r=30",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-shortest",
str(input_path),
],
check=True,
@@ -276,8 +318,10 @@ def test_legacy_engine_broll_mode_supported():
)
clip = _TestClip(
asset_id="asset-1", duration=1.0,
clip_type="main", config={"role": "b_roll"},
asset_id="asset-1",
duration=1.0,
clip_type="main",
config={"role": "b_roll"},
)
asset_path_map = {"asset-1": input_path}
@@ -169,50 +169,11 @@ class TestGeneratedVideoRepositoryNoNPlus1:
assert result == []
# ─── 3. 成片库 API 端点可用性确认 ───
# ─── 3. 成片库 Schema 及替代端点可用性确认 ───
class TestGeneratedVideosAPIAvailability:
"""确认成片库 API 路由注册正确,端点可正常返回数据"""
def test_generated_videos_routes_registered(self):
"""成片库路由已注册到 router。"""
from app.api.routes.generated_videos import router as gv_router
# 直接检查 generated_videos router 自身注册的路由
paths = [r.path for r in gv_router.routes if hasattr(r, "path")]
assert len(paths) > 0, "generated_videos router 没有注册任何路由"
# 验证关键端点存在
assert "" in paths, "列表端点不存在"
assert "/{video_id}" in paths, "详情端点不存在"
def test_generated_videos_list_endpoint_exists(self):
"""GET /generated-videos 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "" in paths, "GET /generated-videos 列表端点不存在"
def test_generated_videos_detail_endpoint_exists(self):
"""GET /generated-videos/{video_id} 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在"
def test_generated_videos_review_endpoint_exists(self):
"""PATCH /generated-videos/{video_id}/review 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/{video_id}/review" in paths, "PATCH review 端点不存在"
def test_generated_videos_download_url_endpoint_exists(self):
"""GET /generated-videos/{video_id}/download-url 端点存在。"""
from apps.api.app.api.routes.generated_videos import router
paths = [r.path for r in router.routes if hasattr(r, "path")]
assert "/{video_id}/download-url" in paths, "download-url 端点不存在"
class TestGeneratedVideosSchemaAndAlternativeEndpoints:
"""确认成片库 Schema 字段完整,替代端点(generation_tasks)可用"""
def test_generated_video_response_schema_complete(self):
"""GeneratedVideoResponse 包含所有必要字段。"""