Compare commits

...

61 Commits

Author SHA1 Message Date
Audit Bot e0a24f636d fix: 配置管理规范化 - 修复 .env.production.example 变量名不一致问题
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
主要修复:
- BASE_URL → APP_BASE_URL(与代码 Settings.APP_BASE_URL 一致)
- CORS_ORIGINS (JSON数组) → CORS_ORIGINS_RAW (逗号分隔,与代码一致)
- 新增 APP_ENV(validate_release_env.py 标记为必需)
- 新增 OSS 配置:OSS_ENDPOINT, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET_NAME
- 新增 OSS_DIRECT_UPLOAD_MAX_MB, OSS_DIRECT_UPLOAD_EXPIRE_SECONDS
- 新增 ENABLE_REDIS_SESSIONS, ENABLE_EMAIL_DELIVERY, SMTP_USE_TLS, SMTP_FROM_NAME
- 新增 GENERATED_FILES_DIR, GENERATED_FILES_URL_PREFIX, PUBLIC_API_BASE_URL
- 新增 CELERY_BROKER_URL, CELERY_RESULT_BACKEND
- SMTP 占位符格式改为 CHANGE_ME_ 前缀(通过验证脚本检查)
- SENTRY_DSN 注释掉(代码中未实际使用)
2026-06-30 15:50:49 +08:00
xiaoxia b410b65e22 ci: trigger workflow validation
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-30 15:49:59 +08:00
xiaoxia e4f8b8d492 Merge pull request 'test: 补充表单上传端点单元测试' (#123) from test/unit-tests-core-modules into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-30 15:35:46 +08:00
Audit Bot b1f27b0ef6 test: 扩展表单上传测试到24个用例
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
根据灵应的规格补充测试覆盖:
- 正常路径(5): 视频/音频/图片上传成功、创建导入任务、响应包含URL
- 文件名校验(5): 路径遍历防护、反斜杠处理、空文件名、特殊字符、无扩展名
- 异常路径(8): 不支持文件类型、项目/素材库不存在、存储服务错误、缺少参数
- 多格式支持(3): 多种视频(4种)/音频(5种)/图片(6种)格式
- MIME验证(3): 有效类型、空类型(400)、不支持类型(415)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-30 15:28:15 +08:00
Audit Bot 16b18dc2fd test: 补充表单上传端点单元测试
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- 新增 tests/unit/test_form_upload_routes.py,覆盖10个测试场景
- 修复 upload.py 表单参数处理:从 Annotated[Model, Form()] 改为独立 Form() 参数
- 测试覆盖:正常上传成功、缺少文件字段、缺少必填参数、项目/素材库不存在、OSS未配置
- 所有测试通过,与现有 upload/chunked_upload 测试风格保持一致

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-30 13:12:49 +08:00
xiaoxia 56707a834c Merge pull request 'test: 添加核心模块单元测试 (config/upload/chunked_upload/asset_diagnosis)' (#122) from test/unit-tests-core-modules into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-30 13:03:48 +08:00
Audit Bot c0c049b2e1 test: 添加核心模块单元测试 (config/upload/chunked_upload/asset_diagnosis)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- test_config_oss.py: 22 个测试覆盖 OSS 配置默认值、环境变量覆盖、字段名一致性
- test_upload_routes.py: 9 个测试覆盖上传路由 find_by_project 调用正确性
- test_chunked_upload_routes.py: 8 个测试覆盖分片上传初始化、常量校验
- test_asset_diagnosis.py: 7 个测试覆盖资产诊断逻辑和路由层调用

全部 46 个测试通过。

Closes #55
2026-06-30 12:57:04 +08:00
xiaoxia 1dd612c89c feat: 添加部署后冒烟测试脚本
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
覆盖5个核心端点:upload/direct/prepare, upload/chunk/init, dashboard/overview, assets, generation/tasks
支持 --json 输出和认证测试模式
2026-06-30 12:39:26 +08:00
xiaoxia b6ead113cf Merge pull request 'fix: 上传端点 500 根因修复 (list_by_project → find_by_project) + OSS 诊断能力' (#120) from fix/upload-size-limit-2000mb into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Tests / lint (pull_request) Failing after 217h42m35s
Tests / test (pull_request) Failing after 217h43m38s
PR#120: fix list_by_project → find_by_project (3 files)
2026-06-30 10:25:44 +08:00
Audit Bot 37152a0729 fix: list_by_project → find_by_project (修复上传/诊断端点 500)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
根因:upload.py、chunked_upload.py、asset_diagnosis.py 调用了
asset_library_repository.list_by_project(),但 SQLAlchemy 实现
只有 find_by_project(),导致 AttributeError → 500。

同步修复 InMemory 实现的方法名以保持一致。
2026-06-30 10:14:08 +08:00
Audit Bot 05c9137ece fix: 上传端点异常处理 + OSS 诊断能力
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
问题:PR#119 修复变量名后上传端点仍然 500,说明根因不仅是变量名错误

修复:
1. upload.py — 所有 3 个上传端点添加完整异常捕获和日志
   - prepare_direct_upload: 捕获 RuntimeError→503 + 其他异常→500+日志
   - complete_direct_upload: file_exists() 异常捕获→503(之前未捕获→500)
   - upload_asset: upload_file() 异常捕获→503/500+日志(之前未捕获→500)

2. storage.py — OSSStorageService.__init__ 添加启动诊断日志
   - 凭证缺失时记录 missing env vars
   - oss2 SDK 未安装时记录错误
   - bucket 初始化成功/失败时记录

3. health.py — /ready 端点增加 OSS 检查
   - 检查凭证是否配置
   - 检查 oss2 SDK 是否可用
   - 调用 get_bucket_info() 验证连通性

部署后请调用 GET /api/v1/ready 查看 OSS 状态
2026-06-30 09:56:20 +08:00
xiaoxia bae9b509de Merge pull request 'fix: 修复上传端点 500 错误 — 配置字段名拼写错误' (#119) from fix/upload-size-limit-2000mb into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-30 09:30:08 +08:00
Audit Bot e6ecce79ee fix: rename OSS_DIRECT_UPLOAD_EXPRESS_SECRET to OSS_DIRECT_UPLOAD_EXPIRE_SECONDS
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Root cause of upload endpoint 500 errors:
- config.py defined OSS_DIRECT_UPLOAD_EXPRESS_SECRET (wrong name)
- upload.py accessed settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS (correct name)
- AttributeError on every call to prepare_direct_upload → 500

This fixes both /api/v1/upload/prepare-direct-upload and any other
upload endpoints that reference the expire seconds config.
2026-06-30 09:24:27 +08:00
xiaoxia 6dbb281acf Merge pull request 'fix: 上传文件大小限制从 800MB 提升至 2000MB' (#117) from fix/upload-size-limit-2000mb into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-30 07:50:46 +08:00
CI Test d934e0e322 chore(ci): add automatic Docker image cleanup after builds
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
- Add scripts/cleanup_old_images.sh: keeps only last 2 versions
  per service (api/worker/web), removes old local tags and registry
  blobs via Registry API, prunes dangling images
- Add cleanup step to deploy.yml build-production-runtime-images job
  with if:always() to ensure cleanup runs even on build failure
- Prevents disk space exhaustion from accumulated Docker images
  and Registry blobs on the build server
2026-06-30 06:58:48 +08:00
Audit Bot 304354208a fix(assets): add project_id to OSS direct upload and align size limit to 2048MB
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
P1: prepareDirectUpload and completeDirectUpload now require project_id,
uploadAssetDirect calls getOrCreateDefaultProject() to obtain it automatically.
P2: MAX_FILE_SIZE changed from 2*1024^3 to 2048*1024*1024 to align with
backend OSS_DIRECT_UPLOAD_MAX_MB=2000.
2026-06-30 00:07:27 +08:00
Audit Bot b5e27a5e9e feat(assets): add 2GB file size validation and OSS direct upload for large files
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- Add MAX_FILE_SIZE (2GB) validation in handleUpload with friendly error message
- Route files >100MB through OSS presigned direct upload (uploadAssetDirect)
- Keep small file form upload (uploadAsset) for files <=100MB
- Update upload hint text to show '单文件不超过 2GB' limit
- Add directUploadMutation with proper loading state tracking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 22:13:08 +08:00
Audit Bot b98c83582b fix: 上传文件大小限制从 800MB 提升至 2000MB
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
P0 修复:
- config.py: OSS_DIRECT_UPLOAD_MAX_MB 默认值从 800 改为 2000
- config.py: 添加 AliasChoices 支持 MAX_UPLOAD_SIZE_MB 环境变量
  (.env.production 中 MAX_UPLOAD_SIZE_MB=2000 现在可被正确读取)

P1 修复:
- infra/nginx/xiaoxia-saas.conf: client_max_body_size 800m → 2g(3处)
- infra/docker/nginx-production.conf: client_max_body_size 800m → 2g
2026-06-29 22:11:22 +08:00
xiaoxia afea0a7566 Merge pull request 'fix: 删除 AssetModel 幽灵列 storage_key(修复 P0 端点 500)' (#115) from fix/p0-endpoint-500-root-cause into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Tests / lint (pull_request) Failing after 231h21m54s
Tests / test (pull_request) Failing after 231h21m55s
2026-06-29 21:12:06 +08:00
Audit Bot 7b08029e2e fix: 删除 AssetModel 幽灵列 storage_key(数据库中不存在)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
根因:AssetModel 定义了 storage_key 列,但 assets 表从未包含此列。
SQLAlchemy 的 session.query(AssetModel) 生成 SELECT 时包含
assets.storage_key,导致 PostgreSQL 报错 column does not exist。

影响端点:
- GET /api/v1/assets?library_id=xxx → 500
- POST /api/v1/generation/tasks → 500(调用 find_by_library 验证素材)
- GET /api/v1/dashboard/overview → 500(调用 count_by_project_ids 统计)

修复:删除 models.py 中 AssetModel 的 storage_key 列定义。
域实体的 storage_key 字段通过 _to_domain() 从 model.file_url 映射,不受影响。
2026-06-29 20:47:40 +08:00
xiaoxia 6b5959bafd Merge pull request 'fix: 修复 3 个 P0 端点 500 错误根因(async/sync 不匹配 + edit_plan_id 残留)' (#114) from fix/p0-endpoint-500-root-cause into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 20:28:26 +08:00
Audit Bot 7783f36172 fix: 从迁移 015 中移除残留的 edit_plan_id(代码审计 P1)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
迁移 011 已删除 edit_plan_id 列,迁移 015 不应再 add_column。
同时从 downgrade() 中移除对应的 drop_column。
2026-06-29 20:23:23 +08:00
Audit Bot f9e8d6efdc fix: 修复 3 个 P0 端点 500 错误的真正根因
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Bug 1 — GET /api/v1/assets → 500:
  asset_repository 所有方法为 async def,但调用方为同步上下文,
  未 await 导致返回 coroutine 对象而非实际数据。
  修复:全部改为 def(同步),与 SQLAlchemy 同步 Session 一致。

Bug 2 — POST /api/v1/generation/tasks → 500:
  GenerationTaskModel 仍定义 edit_plan_id 列,但迁移 011 已从数据库
  删除该列,INSERT 时报 column does not exist。
  修复:从 model、repository、domain entity、application use case、
  所有路由中彻底移除 edit_plan_id 引用。

Bug 3 — GET /api/v1/dashboard/overview → 500:
  generation_task_repository._to_domain() 访问 model.edit_plan_id,
  该列已被迁移 011 删除,触发 AttributeError。
  修复:同上,移除 edit_plan_id 引用。

涉及文件:
  - packages/adapters/sqlalchemy_impl/asset_repository.py (async→sync)
  - packages/ports/asset_repository.py (async→sync 接口)
  - packages/adapters/sqlalchemy_impl/models.py (移除 edit_plan_id 列)
  - packages/adapters/sqlalchemy_impl/generation_task_repository.py
  - packages/domain/generation_task.py
  - packages/application/generation_tasks.py
  - apps/api/app/api/routes/generation_tasks.py
  - apps/api/app/api/routes/task_center.py
2026-06-29 20:10:51 +08:00
xiaoxia 401a55cfcb Merge pull request 'fix: 清理 entities.py 重复实体定义,消除域模型冲突' (#113) from fix/domain-entity-cleanup into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 19:40:53 +08:00
Audit Bot f80a311082 fix: 清理 entities.py 重复实体定义,统一使用独立域模块
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
移除 entities.py 中与其他独立域文件重复的 dataclass 定义:
- ClassificationJob → 统一从 classification.py 导入
- GenerationTask → 统一从 generation_task.py 导入
- GeneratedVideo → 统一从 generated_video.py 导入
- EditTemplate → 已废弃,无引用

重复定义导致字段不一致(如 GenerationTask 缺少 template_id/asset_ids,
GeneratedVideo.file_size 类型 float vs int),可能引发运行时错误。

Fixes: #150

Co-Authored-By: 灵应 <437848575@qq.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 19:29:21 +08:00
xiaoxia 4c1139b681 Merge pull request 'fix: 修复 projects 500 + 实现 dashboard/overview 端点' (#112) from fix/projects-500-and-dashboard into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 18:35:45 +08:00
Audit Bot d2578a9093 fix: PR#112 P2 建议修复
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
P2-1: list_by_user[:5] → list_recent_by_user(user_id, limit=5) SQL层LIMIT
P2-2: DashboardOverviewResponse 补充 subscription 字段(plan + is_active)

- packages/ports/generation_task_repository.py: 新增 list_recent_by_user
- packages/adapters/sqlalchemy_impl/generation_task_repository.py: 实现
- apps/api/app/schemas/dashboard.py: 新增 SubscriptionInfo + subscription 字段
- apps/api/app/api/routes/dashboard.py: 使用新接口 + 填充 subscription
2026-06-29 18:23:49 +08:00
Audit Bot e723025889 fix: 修复 projects 500 + 实现 dashboard/overview 端点
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Bug 1: GET /api/v1/projects → 500
- _to_project_response() 缺少 owner_user_id 和 shared_users 字段
- 补全 ProjectResponse schema 必填字段

Bug 2: GET /api/v1/dashboard/overview → 404
- 新建 dashboard schema + route
- 添加 count_by_user / count_by_project_ids / sum_storage_by_project_ids 仓储方法
- 注册 /api/v1/dashboard 路由
2026-06-29 18:12:39 +08:00
xiaoxia 3c6b8aa362 Merge pull request 'fix: tasks.ts 类型定义对齐后端 schema(P2 修复)' (#111) from fix/tasks-response-type-alignment into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 17:44:52 +08:00
Audit Bot 265e49587f fix: tasks.ts 类型定义对齐后端 schema(P2 修复)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- CreateGenerationTaskResponse: task_id→id, message→error_message, 补全所有字段
- TaskItem: 补充 project_id, template_id 字段

对齐后端 GenerationTaskResponse 和 UserTaskResponse schema
2026-06-29 17:39:47 +08:00
xiaoxia 70ad41bf94 Merge pull request 'fix: tasks.ts mock → 真实 API 迁移,对接后端方案 A' (#110) from fix/tasks-mock-to-real-api into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 17:35:10 +08:00
Audit Bot c2a522babf fix: tasks.ts mock → 真实 API 迁移,对接后端方案 A(PR #109)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- createGenerationTask: 关闭 mock,调用 POST /generation/tasks
- getUserTasks: 关闭 mock,调用 GET /tasks(用户级列表)
- retryTask: 关闭 mock,调用 POST /tasks/{task_id}/retry
- 删除所有 mock 数据和 USE_MOCK 开关
2026-06-29 17:29:56 +08:00
xiaoxia 35446732ea Merge pull request 'feat: 扩展生成任务API支持模板模式(方案A)' (#109) from feat/generation-api-extension into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 17:29:06 +08:00
Audit Bot d2097744e4 fix: retry 端点增加归属校验 (P1 安全修复)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- generation_tasks.py: retry_generation_task 增加 created_by_user_id 校验
- task_center.py: retry_task_by_id 增加 created_by_user_id 校验
- 非任务创建者返回 403 Access denied
2026-06-29 17:19:13 +08:00
Audit Bot ea9e536740 feat: 扩展生成任务API支持模板模式
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
方案A: 后端扩展3处API改动,支持前端模板中心化模型。

1. POST /api/v1/generation/tasks — project_id改为可选,新增template_id/asset_ids/title_ids/voice_ids
2. GET /api/v1/generation/tasks + GET /api/v1/tasks — 新增用户级列表(跨project)
3. POST /api/v1/tasks/{task_id}/retry + POST /api/v1/generation/tasks/{task_id}/retry — 简化重试

改动涉及:
- Domain: GenerationTask新增6个字段,放宽create()校验
- Application: CreateGenerationTaskCommand新增字段
- Ports: GenerationTaskRepository新增list_by_user()
- Adapter: SQLAlchemy模型+仓储实现新字段和list_by_user
- Schema: 双模式校验(project模式/模板模式)
- Routes: generation_tasks + task_center双路由注册
- Migration: 015_add_generation_task_extensions

向后兼容:所有旧端点和参数不变。
2026-06-29 17:05:53 +08:00
CI Test 8c678b9cd0 fix: TypeScript类型错误修复 - warning→warnings, seg.id非空断言
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 16:40:06 +08:00
xiaoxia 8c4c428cdc fix(api): editingPlanner 对接后端 /templates API,删除遗留 editPlans.ts (#108)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
Squash merge PR#108
- editingPlanner.ts: mock → 真实 /templates API(7个函数)
- editPlans.ts: 已删除
- 代码审计通过
2026-06-29 16:33:23 +08:00
xiaoxia 9631272cfb Merge pull request 'feat: 剪辑计划编辑器前端实现' (#106) from feat/editing-planner-frontend into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Tests / lint (pull_request) Failing after 237h12m30s
Tests / test (pull_request) Failing after 237h13m33s
2026-06-29 15:28:45 +08:00
Audit Bot 067439a1dd fix: 修复 PR#106 代码审计问题
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
P0-1: TemplateMode 改为英文枚举值(pip/voice_over/one_take/voice_pip),显示层通过 MODE_LABELS 映射中文
P0-2: EditingPlanner 通过 useSearchParams 读取 ?template=xxx&generate=1,自动加载模板并打开发成弹窗
P1-3: 848行组件拆分为 5 个子组件(TemplatePanel/TimelinePanel/SettingsPanel/SaveModal/GenerateModal)
P1-4: GenerateFromTemplatePayload voiceover_id(string) → voiceover_duration(number)
P1-5: SaveModal 分类字段 Input → Select,关联后端 categories API
P1-6: SaveTemplatePayload 补充 estimated_duration 字段
P2: TimelinePanel Slider 约束 min≤max

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 15:18:33 +08:00
xiaoxia e8cd246422 Merge pull request 'feat: 剪辑计划编辑器后端 — 模板 CRUD + 分类 + 生成校验' (#105) from feat/editing-planner-backend into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 15:05:51 +08:00
Audit Bot 78524a04eb fix: 修复复审2个建议 (models注释 + N+1查询 + 事务边界)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- models.py: 注释中旧模式名对齐 EditingMode 枚举
- list_by_user: 批量加载 segments 避免 N+1 查询
- create(): flush 替代 commit,create_segments 统一提交事务
2026-06-29 14:53:21 +08:00
Audit Bot 9b6a0b8b14 fix: 修复代码审计5个问题 (P0-1,P0-2,P1-3,P1-4,P1-5)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
P0-1: 剪辑模式使用 EditingMode 枚举,移除硬编码字符串
  - voice_over_mix → voice_over, person_narration → voice_pip
  - use_cases.py 导入 EditingMode 枚举替代硬编码 VALID_MODES

P0-2: 补充 packages/application/template/__init__.py

P1-3: Use Cases 依赖 TemplateRepositoryPort 而非 SQLAlchemyTemplateRepository

P1-4: generate 端点重命名为 validate(只校验不生成)
  - GenerateFromTemplateUseCase → ValidateTemplateUseCase
  - POST /{id}/generate → POST /{id}/validate

P1-5: 软删除模板时级联清理关联 segments,避免孤儿数据

测试全部通过 (21/21)
2026-06-29 14:44:24 +08:00
Audit Bot fff47a7a3d feat: 剪辑计划编辑器前端实现
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- 新增剪辑计划编辑器页面 (三栏布局)
  - 左侧:模板面板(搜索/筛选/加载模板)
  - 中间:预览区 + 时间线(片段拖拽排序)
  - 右侧:标题/字幕/BGM 设置面板
- 支持 4 种模式切换(画中画/人物口播/一镜到底/口播+混剪)
- 新增「我的模板」页面(卡片视图/编辑/复制/删除/生成)
- 新增 API 模块(mock 数据,后端就绪后替换)
- 更新路由和导航菜单

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 14:35:27 +08:00
Audit Bot 62c8d1cdff feat: 剪辑计划编辑器后端 — 模板 CRUD + 分类 + 生成校验
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- domain: Template, TemplateSegment, TemplateCategory 实体
- ports: TemplateRepositoryPort Protocol
- adapters: SQLAlchemyTemplateRepository + 3 个 ORM Model
- application: CRUD use cases + GenerateFromTemplateUseCase
- 业务规则: one_take=1片段, voice_over_mix=每段需material_type, 配音±30%警告
- schemas: Pydantic request/response models
- routes: /api/v1/templates CRUD + /generate + /categories
- alembic: 014_add_template_tables (templates/template_segments/template_categories)
- tests: 21 个单元测试全部通过
2026-06-29 14:14:58 +08:00
xiaoxia 2849aef47e Merge pull request 'feat: 配方复用功能后端实现' (#102) from feat/recipe-reuse-backend into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Tests / lint (pull_request) Failing after 240h12m11s
Tests / test (pull_request) Failing after 240h13m13s
2026-06-29 12:32:50 +08:00
xiaoxia d5c502f3cc Merge pull request 'docs: API 参考文档 v0.1.88 + CHANGELOG v0.1.77-v0.1.88' (#100) from docs/api-v0.1.88-update into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 12:32:45 +08:00
xiaoxia 47d77ea29c Merge pull request 'fix: 前端交互全面审计 — 上传422修复 + 进度反馈 + 防重复提交' (#99) from fix/frontend-interaction-audit into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-29 12:32:27 +08:00
Audit Bot f2e123a017 feat: 配方复用功能后端实现
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- domain: Recipe/RecipeItem 实体
- ports: RecipeRepository Protocol
- adapters: SQLAlchemy RecipeRepository 实现 + ORM models
- application: 6个 UseCase (CRUD + Use) + Commands
- API: 6个端点 POST/GET/PATCH/DELETE /recipes + POST /recipes/{id}/use
- feature flag: recipe_reuse 仅 basic/premium 可用
- Alembic 迁移: recipes + recipe_items 表
- 单元测试: 覆盖所有 UseCase
2026-06-29 10:59:05 +08:00
API文档维护 5154395edb docs: API 参考文档 v0.1.88 + CHANGELOG 更新
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- 新增 API 参考文档(v0.1.88),覆盖 17 个模块、50+ 端点
- 更新 CHANGELOG,记录 v0.1.77 ~ v0.1.88 的全部变更
- 重点更新:查重 API、订阅 API、标题库 API、配音库 API

变更内容:
- 认证 API:7 个端点
- 项目 API:3 个端点
- 任务中心 API:2 个端点
- 素材诊断 API:1 个端点
- 素材库 API:2 个端点
- 素材 API:3 个端点
- 导入/分类任务 API:4 个端点
- 上传 API:3 个端点
- 分片上传 API:4 个端点
- 生成任务 API:3 个端点
- 成片 API:4 个端点
- 标题库 API:5 个端点
- 配音库 API:5 个端点
- 查重 API:5 个端点
- 订阅 API:5 个端点
2026-06-29 10:53:32 +08:00
Audit Bot 53910e5f20 fix: 前端交互全面审计 — 上传422修复 + 进度反馈 + 防重复提交
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
🔴 P0 Bug:
- AssetLibrary: handleUpload 补充 project_id,调用 getOrCreateDefaultProject() 解决 422

🟡 P0 交互:
- AssetLibrary: 上传时显示 Spin + 禁用 Dragger + 文案提示「正在上传」
- client.ts: 新增 413/415/503 状态码专属提示

🔵 全面交互排查:
- GeneratePage: 生成中禁用步骤导航按钮防误操作
- Billing: 自动续费 Switch 添加 loading 状态防连点
- TemplateLibrary: 收藏按钮 mutation 进行中 disabled 防重复
- TitleLibrary: 空状态增加「新建标题」和「批量导入」引导按钮
2026-06-29 10:48:39 +08:00
xiaoxia f10a526497 Merge pull request 'fix: 全面补充前端交互状态反馈(P0 第一批)' (#97) from fix/frontend-feedback-improvement into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Tests / lint (pull_request) Failing after 242h28m20s
Tests / test (pull_request) Failing after 242h29m22s
2026-06-29 10:11:51 +08:00
Audit Bot fb935981a5 fix: P1 防止全局拦截器与组件 onError 双重 toast
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- 所有 React Query onError 中的 message.error 添加 __msgShown 检查
- 所有 try/catch 中的 message.error 添加 __msgShown 检查
- 涉及 14 个页面文件:AssetLibrary, TitleLibrary, VoiceLibrary,
  DuplicationUpload, TaskHistory, GeneratePage, ProductLibrary,
  Billing, UpgradeSubscription, Login, Register, ForgotPassword,
  ResetPassword, TemplateLibrary
- 修复 TemplateLibrary 缺少 message import 的问题

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 09:42:37 +08:00
Audit Bot 4143018faa fix: 全面补充前端交互状态反馈
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- client.ts: 添加全局错误拦截器,自动提取后端 detail/message/msg 字段展示 toast
  - 处理网络异常、超时、5xx 等场景
  - 通过 __msgShown 标记避免与组件 onError 重复弹提示
- 6 个 delete mutation 补充 onError 回退提示(AssetLibrary、TitleLibrary、
  VoiceLibrary、ProductLibrary、DuplicationResults×2)
- GeneratePage: 5 个 query 增加 loading + error 状态展示
- TemplateLibrary: favMutation 增加收藏/取消收藏成功提示及失败回退
- Dashboard: 增加 isError 错误状态展示,避免静默显示全零数据
- DuplicationDetail: 增加 isError 状态,区分加载失败与记录不存在
2026-06-29 09:35:08 +08:00
xiaoxia 6190752bb3 Merge pull request 'fix: 素材库新建自动获取/创建默认项目以提供 project_id' (#94) from fix/asset-library-project-id into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
Tests / lint (pull_request) Failing after 243h20m43s
Tests / test (pull_request) Failing after 243h21m45s
2026-06-29 09:11:06 +08:00
Audit Bot 5eacac3abf fix: 修正 getProjects 响应解析 — 后端返回 {items:[...]} 而非裸数组
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- BackendProjectResponse 对齐后端 ProjectResponse(仅 id/name/description)
- getProjects 改为 response.data.items 解析
- 修复代码审计 PR#94 审查发现的 P0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-29 07:25:41 +08:00
Audit Bot 841b501c30 fix: 素材库新建自动获取/创建默认项目以提供 project_id
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- 新增 api/projects.ts:项目 API 模块,含 getOrCreateDefaultProject
- 修改 createAssetLibrary:自动解析 project_id 再发送请求
- 后端 CreateAssetLibraryRequest 要求 project_id,前端之前未发送导致 422
- 修复 P0:新建素材库点击确定无响应

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-28 22:01:38 +08:00
API文档维护Agent 573e4485b7 fix: 同步改进CORS配置 - 始终包含生产域名
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
与main分支PR #93保持一致:无论CORS_ORIGINS_RAW环境变量是否设置,
都确保 https://saas.xiaoxiajianji.com 在allow_origins中。
2026-06-28 21:59:39 +08:00
xiaoxia 2ee3992a94 Merge pull request 'fix: 修复标题库新建/编辑功能 — 前后端字段名不匹配导致422' (#90) from fix/p0-create-titles-assets into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-28 21:39:32 +08:00
xiaoxia bf44bd8d9a Merge pull request 'fix: 修复标题/配音创建500、素材库列表500、CORS配置' (#91) from fix/api-500-errors-cors into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
2026-06-28 21:33:31 +08:00
API文档维护Agent 47533e7037 fix: 修复标题/配音创建500、素材库列表500、CORS缺失
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
- titles.py/voices.py: get_by_id → find_by_id(方法名错误导致AttributeError)
- project_repository.py: cast shared_users to JSONB for @> operator(JSON列不支持@>)
- main.py: CORS allow_origins 添加 https://saas.xiaoxiajianji.com
2026-06-28 21:33:07 +08:00
Audit Bot 54cbfe2adf fix: 修复标题库新建/编辑功能 — 前后端字段名不匹配导致422
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
根因:前端发送 { content, category } 但后端 CreateTitleLibraryRequest
期望 { name (必填), text (必填), category }。字段名不匹配导致
Pydantic 校验失败返回 422,modal 不关闭,数据不保存。

修复:
- api/titles.ts: 请求时将 content 映射为 text + 自动截取 name
- api/titles.ts: 响应时将后端 text 字段映射回前端 content
- api/titles.ts: updateTitle 改用 PUT(后端实际方法)
- 页面代码无需改动,映射在 API 层透明处理

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-28 21:28:55 +08:00
103 changed files with 9493 additions and 436 deletions
+36 -12
View File
@@ -1,30 +1,54 @@
# 生产环境配置模板(实际使用时复制为 .env.production)
# ==================== 基础配置 ====================
APP_ENV=production
ENVIRONMENT=production
DEBUG=false
USE_IN_MEMORY_DB=false
LOG_LEVEL=WARNING
# 数据库(必须修改)
# ==================== 数据库(必须修改)====================
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
# Redis(必须修改)
# ==================== Redis(必须修改)====================
REDIS_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0
ENABLE_REDIS_SESSIONS=false
# JWT(必须修改,至少 32 字符)
# ==================== JWT(必须修改,至少 32 字符)====================
JWT_SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS
# SMTP(必须配置)
# ==================== 邮件(必须配置)====================
ENABLE_EMAIL_DELIVERY=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
SMTP_USER=CHANGE_ME_SMTP_USER
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
SMTP_FROM_EMAIL=noreply@yourdomain.com
SMTP_FROM_NAME=小虾 SaaS
SMTP_USE_TLS=true
# 应用配置
BASE_URL=https://yourdomain.com
# ==================== 应用配置 ====================
APP_BASE_URL=https://yourdomain.com
# CORS(修改为实际域名)
CORS_ORIGINS=["https://yourdomain.com","https://app.yourdomain.com"]
# ==================== CORS(修改为实际域名,逗号分隔)====================
CORS_ORIGINS_RAW=https://yourdomain.com,https://app.yourdomain.com
# 监控(可选)
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
# ==================== 阿里云 OSS(必须配置)====================
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=CHANGE_ME_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET=CHANGE_ME_ACCESS_KEY_SECRET
OSS_BUCKET_NAME=xiaoxia-autocut
OSS_DIRECT_UPLOAD_MAX_MB=2000
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== 生成文件 ====================
GENERATED_FILES_DIR=/app/generated
GENERATED_FILES_URL_PREFIX=/generated-files
PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com
# ==================== Celery ====================
CELERY_BROKER_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0
CELERY_RESULT_BACKEND=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/1
# ==================== 监控(可选)====================
# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
+15
View File
@@ -169,6 +169,21 @@ jobs:
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
- name: Cleanup old Docker images
if: always()
shell: sh
run: |
set -eu
if [ -f scripts/cleanup_old_images.sh ]; then
chmod +x scripts/cleanup_old_images.sh
scripts/cleanup_old_images.sh
else
echo "Cleanup script not found, doing basic prune..."
docker image prune -f 2>/dev/null || true
fi
echo "Disk usage after cleanup:"
df -h / | tail -1
deploy-production:
name: Deploy Production
runs-on: runtime-builder:host
+210 -11
View File
@@ -1,19 +1,218 @@
# Changelog
## [v0.1.88] - 2026-06-29
All notable changes to this project will be documented in this file.
### Phase 2 前端优化 - 完成 ✅
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
**前端交互全面优化:**
- 素材上传添加 project_id 参数
- Drager 组件显示上传列表
- 按钮防重复提交
- 前端交互状态反馈补充(P0 第一批)
---
## [v0.1.87] - 2026-06-29
### Bug 修复
- Docker compose 修复 mem_limit 冲突
---
## [v0.1.86] - 2026-06-29
### CI/CD 优化
- CI 优化
---
## [v0.1.85] - 2026-06-29
### CI/CD 优化
- CI 优化
---
## [v0.1.84] - 2026-06-29
### CI/CD 优化
- CI runner label 匹配修复
---
## [v0.1.83] - 2026-06-29
### CI/CD 优化
- CI SSH debug 修正
---
## [v0.1.82] - 2026-06-29
### CI/CD 优化
- CI SSH debug 修正
---
## [v0.1.81] - 2026-06-29
### CI/CD 优化
- CI runner label 匹配修复
---
## [v0.1.80] - 2026-06-28
### Bug 修复
- 修复 redirect_slashes + 标题字段匹配
---
## [v0.1.79] - 2026-06-28
### Deployment
- Re-trigger deployment
---
## [v0.1.78] - 2026-06-28
### Bug 修复
- 修复 500 错误
- CORS 配置修复
- redirect_slashes 禁用
---
## [v0.1.77] - 2026-06-28
### Bug 修复
- 修复标题库新建/编辑 — 前后端字段名不匹配导致 422
---
### Phase 2 功能合并(v0.1.77 ~ v0.1.88)
**新增功能 PR:**
- PR#74: Phase 1 核心重构 — 标题库 API、配音库 API、去 Project 层清理
- PR#75: Phase 2 查重功能前端页面
- PR#76: Phase 2 查重功能后端 API(5 个端点)
- PR#77: Phase 2 订阅管理前端页面
- PR#78: Phase 2 订阅管理后端 API(5 个端点)
- PR#79: 修复一键生成页面废弃 API 调用
- PR#80: 回退域对象 extra_meta → metadata
- PR#81: 删除查重 API 错误的 204 返回
- PR#82: 查重上传接口错误信息不再泄露内部异常(安全审计)
- PR#83: 订阅 + 查重单元测试(63 用例)
- PR#84: 订阅管理前端对接真实 API
- PR#85: 禁用 redirect_slashes 修复 307 重定向
- PR#90: 标题库字段名修复
- PR#91: 标题/配音创建 500 修复 + CORS
- PR#94: 素材库新建自动获取默认 project_id
- PR#97: 前端交互状态反馈全面补充
---
- Docker compose 修复 mem_limit 冲突
---
## [v0.1.86] - 2026-06-29
### CI/CD 优化
- CI 优化
---
## [v0.1.85] - 2026-06-29
### CI/CD 优化
- CI 优化
---
## [v0.1.84] - 2026-06-29
### CI/CD 优化
- CI runner label 匹配修复
---
## [v0.1.83] - 2026-06-29
### CI/CD 优化
- CI SSH debug 修正
---
## [v0.1.82] - 2026-06-29
### CI/CD 优化
- CI SSH debug 修正
---
## [v0.1.81] - 2026-06-29
### CI/CD 优化
- CI runner label 匹配修复
---
## [v0.1.80] - 2026-06-28
### Bug 修复
- 修复 redirect_slashes + 标题字段匹配
---
## [v0.1.79] - 2026-06-28
### Deployment
- Re-trigger deployment
---
## [v0.1.78] - 2026-06-28
### Bug 修复
- 修复 500 错误
- CORS 配置修复
- redirect_slashes 禁用
---
## [v0.1.77] - 2026-06-28
### Bug 修复
- 修复标题库新建/编辑 — 前后端字段名不匹配导致 422
---
## [Unreleased]
## [1.2.0] - 2026-06-19
### Phase 7: 核心视频剪辑业务 - 完成 ✅
**完成进度:** 100%
**状态:** 已完成并验证
#### Added
**素材管理:**
+64
View File
@@ -0,0 +1,64 @@
"""Phase 2 - 配方复用:recipes + recipe_items
Revision ID: 013
Revises: 012
Create Date: 2026-06-29
This migration creates two new tables:
1. recipes — 配方主表
2. recipe_items — 配方素材项表
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "013"
down_revision = "012"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── 1. Create recipes table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS recipes (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT NOT NULL DEFAULT '',
template_id VARCHAR(36) NOT NULL DEFAULT '',
generation_params JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)"
))
# ── 2. Create recipe_items table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS recipe_items (
id VARCHAR(36) PRIMARY KEY,
recipe_id VARCHAR(36) NOT NULL,
item_type VARCHAR(20) NOT NULL,
item_id VARCHAR(36) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS recipe_items"))
conn.execute(sa.text("DROP TABLE IF EXISTS recipes"))
@@ -0,0 +1,87 @@
"""Phase 3 - 剪辑计划模板:templates + template_segments + template_categories
Revision ID: 014
Revises: 013
Create Date: 2026-06-29
This migration creates three new tables:
1. templates — 剪辑计划模板主表
2. template_segments — 模板片段表
3. template_categories — 模板分类表
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "014"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── 1. Create templates table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS templates (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(200) NOT NULL,
mode VARCHAR(30) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT '',
tags JSONB NOT NULL DEFAULT '[]',
title_config JSONB NOT NULL DEFAULT '{}',
subtitle_config JSONB NOT NULL DEFAULT '{}',
bgm_config JSONB NOT NULL DEFAULT '{}',
estimated_duration FLOAT NOT NULL DEFAULT 0.0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)"
))
# ── 2. Create template_segments table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS template_segments (
id VARCHAR(36) PRIMARY KEY,
template_id VARCHAR(36) NOT NULL,
segment_order INTEGER NOT NULL,
duration_min FLOAT NOT NULL,
duration_max FLOAT NOT NULL,
material_type VARCHAR(20),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_template_segments_template_id "
"ON template_segments(template_id)"
))
# ── 3. Create template_categories table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS template_categories (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_template_categories_user_id "
"ON template_categories(user_id)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS template_categories"))
conn.execute(sa.text("DROP TABLE IF EXISTS template_segments"))
conn.execute(sa.text("DROP TABLE IF EXISTS templates"))
@@ -0,0 +1,34 @@
"""add generation task extensions
Revision ID: 015
Revises: 014
Create Date: 2026-06-29
"""
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from alembic import op
# revision identifiers
revision = "015"
down_revision = "014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generation_tasks", sa.Column("template_id", sa.String(36), nullable=False, server_default=""))
op.add_column("generation_tasks", sa.Column("asset_ids", mysql.JSON(), nullable=False, server_default="[]"))
op.add_column("generation_tasks", sa.Column("title_ids", mysql.JSON(), nullable=False, server_default="[]"))
op.add_column("generation_tasks", sa.Column("voice_ids", mysql.JSON(), nullable=False, server_default="[]"))
op.create_index(op.f("ix_generation_tasks_template_id"), "generation_tasks", ["template_id"])
def downgrade() -> None:
op.drop_index(op.f("ix_generation_tasks_template_id"), table_name="generation_tasks")
op.drop_column("generation_tasks", "voice_ids")
op.drop_column("generation_tasks", "title_ids")
op.drop_column("generation_tasks", "asset_ids")
op.drop_column("generation_tasks", "template_id")
+18
View File
@@ -1,3 +1,4 @@
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
from app.api.routes.asset_libraries import router as asset_libraries_router
from app.api.routes.assets import router as assets_router
@@ -6,7 +7,9 @@ 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.duplication import router as duplication_router
from app.api.routes.generated_videos import router as generated_videos_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.templates import router as templates_router
from app.api.routes.titles import router as titles_router
from app.api.routes.voices import router as voices_router
from app.api.routes.generation_tasks import router as generation_tasks_router
@@ -98,3 +101,18 @@ 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"],
)
+1 -1
View File
@@ -202,7 +202,7 @@ def get_project_asset_diagnosis(
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
libraries = asset_library_repository.list_by_project(project_id)
libraries = asset_library_repository.find_by_project(project_id)
assets: list[Asset] = []
for library in libraries:
assets.extend(asset_repository.list_by_library(library.id))
+1 -1
View File
@@ -101,7 +101,7 @@ def _require_project_and_library(
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.list_by_project(project_id)
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")
+92
View File
@@ -0,0 +1,92 @@
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,
)
+109 -16
View File
@@ -16,6 +16,7 @@ from app.schemas.generated_video import (
from app.schemas.generation_task import (
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
from fastapi import APIRouter, Depends, HTTPException
@@ -45,6 +46,10 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -79,6 +84,43 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
asset_library_repository: Any,
asset_repository: Any,
authenticated_user: AuthenticatedUser,
) -> tuple[str, str]:
"""解析 project_id 和 asset_library_id。
支持两种模式:
- 显式传入(向后兼容)
- 从 asset_ids 反查 asset_library(模板模式)
返回 (project_id, asset_library_id)。
"""
project_id = request.project_id.strip()
asset_library_id = request.asset_library_id.strip()
# 模板模式:project_id 未提供时,从 asset_ids 反查所属 project
if not project_id and request.asset_ids:
first_asset_id = request.asset_ids[0]
asset = asset_repository.find_by_id(first_asset_id)
if asset is not None:
project_id = asset.project_id
if not asset_library_id:
asset_library_id = asset.library_id
# 向后兼容校验:project_id 已提供时验证权限
if project_id:
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(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
return project_id, asset_library_id
@router.post("/tasks", response_model=GenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
@@ -88,26 +130,30 @@ def create_generation_task(
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> GenerationTaskResponse:
project = project_repository.find_by_id(request.project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
library = asset_library_repository.get(request.asset_library_id)
if library is None or library.project_id != request.project_id:
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found")
assets = asset_repository.list_by_library(request.asset_library_id)
_ensure_library_has_ready_video_assets(assets)
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=request.project_id,
asset_library_id=request.asset_library_id,
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=request.asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
@@ -115,6 +161,17 @@ def create_generation_task(
return _to_generation_task_response(task)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListGenerationTasksResponse:
"""用户级生成任务列表(跨 project)。"""
tasks = generation_task_repository.list_by_user(authenticated_user.user.id)
items = [_to_generation_task_response(task) for task in tasks]
return ListGenerationTasksResponse(items=items)
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
def get_generation_task(
task_id: str,
@@ -126,7 +183,8 @@ def get_generation_task(
task = use_case.execute(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
return _to_generation_task_response(task)
@@ -141,7 +199,42 @@ def list_generation_results(
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
def retry_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""简化重试:通过 task_id 直接重试失败任务。"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
return _to_generation_task_response(retried)
+33
View File
@@ -24,6 +24,7 @@ async def readiness_check():
checks = {
"database": await _check_database(),
"redis": await _check_redis(),
"oss": _check_oss(),
}
all_healthy = all(check["status"] == "healthy" for check in checks.values())
response = {
@@ -97,6 +98,38 @@ async def _check_redis() -> dict:
}
def _check_oss() -> dict:
try:
from app.core.storage import get_storage_service
svc = get_storage_service()
if not svc.access_key_id or not svc.access_key_secret:
return {
"status": "unhealthy",
"type": "oss",
"message": "OSS credentials not configured (OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET missing)",
}
if svc.bucket is None:
return {
"status": "unhealthy",
"type": "oss",
"message": "OSS SDK (oss2) not installed or bucket client init failed",
}
# Try a lightweight OSS API call to verify connectivity & credentials
svc.bucket.get_bucket_info()
return {
"status": "healthy",
"type": "oss",
"message": f"OSS connected: endpoint={svc.endpoint} bucket={svc.bucket_name}",
}
except Exception as error:
return {
"status": "unhealthy",
"type": "oss",
"message": f"OSS check failed: {type(error).__name__}: {error}",
}
async def _check_migrations() -> dict:
if settings.USE_IN_MEMORY_DB:
return {
+2
View File
@@ -22,8 +22,10 @@ router = APIRouter()
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
owner_user_id=item.owner_user_id,
name=item.name,
description=item.description,
shared_users=item.shared_users,
)
+216
View File
@@ -0,0 +1,216 @@
"""Recipe CRUD + use routes."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
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 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_class=Response)
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
],
)
+136 -45
View File
@@ -7,7 +7,12 @@ from app.dependencies import (
get_ingest_job_repository,
get_project_repository,
)
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
from app.schemas.task_center import (
ListProjectTasksResponse,
ListTasksResponse,
ProjectTaskResponse,
UserTaskResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from packages.application import (
@@ -34,28 +39,136 @@ def _humanize_task_error(error_message: str) -> str:
return f"任务失败:{raw}"
def _status_value(status) -> str:
"""安全获取状态值(兼容 StrEnum 和 plain string)。"""
return status.value if hasattr(status, "value") else str(status)
def _generation_step(task) -> str:
if task.status.value == "pending":
s = _status_value(task.status)
if s == "pending":
return "等待 Worker 执行"
if task.status.value == "running":
if s == "running":
return "正在生成成片"
if task.status.value == "completed":
if s == "completed":
return "生成完成"
if task.status.value == "failed":
if s == "failed":
return "生成失败"
return task.status.value
return s
def _ingest_step(job) -> str:
if job.status.value == "pending":
s = _status_value(job.status)
if s == "pending":
return "等待导入"
if job.status.value == "processing":
if s == "processing":
return "正在分析素材"
if job.status.value == "completed":
if s == "completed":
return "导入完成"
if job.status.value == "failed":
if s == "failed":
return "导入失败"
return job.status.value
return s
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
return ProjectTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
status=_status_value(task.status),
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
source_id=task.id,
template_id=task.template_id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListTasksResponse:
"""用户级任务列表(跨 project),合并 ingest + generation 任务。"""
user_id = authenticated_user.user.id
items: list[UserTaskResponse] = []
for task in generation_task_repository.list_by_user(user_id):
items.append(
UserTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
template_id=task.template_id,
status=_status_value(task.status),
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
return ListTasksResponse(items=items)
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
def retry_task_by_id(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> UserTaskResponse:
"""简化重试:通过 task_id 直接重试失败的生成任务。"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
return UserTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
project_id=retried.project_id,
template_id=retried.template_id,
status=_status_value(retried.status),
progress=retried.progress,
current_step=_generation_step(retried),
source_id=retried.id,
created_at=retried.created_at,
updated_at=retried.created_at,
)
# ── 项目级端点 ──
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
@@ -77,34 +190,19 @@ def list_project_tasks(
id=f"ingest:{job.id}",
task_type="ingest",
project_id=job.project_id,
status=job.status.value,
progress=100.0 if job.status.value == "completed" else 0.0,
status=_status_value(job.status),
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
current_step=_ingest_step(job),
error_message=job.error_message,
user_message=_humanize_task_error(job.error_message),
retryable=job.status.value == "failed",
retryable=_status_value(job.status) == "failed",
source_id=job.id,
created_at=job.created_at,
updated_at=job.updated_at,
)
)
for task in generation_task_repository.list_by_project(project_id):
items.append(
ProjectTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
status=task.status.value,
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=task.status.value == "failed",
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
items.append(_generation_task_to_project_response(task))
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
return ListProjectTasksResponse(items=items)
@@ -121,7 +219,7 @@ def retry_project_task(
task = generation_task_repository.get(source_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.status.value != "failed":
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
@@ -130,27 +228,20 @@ def retry_project_task(
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
edit_plan_id=task.edit_plan_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
return ProjectTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
project_id=retried.project_id,
status=retried.status.value,
progress=retried.progress,
current_step=_generation_step(retried),
source_id=retried.id,
created_at=retried.created_at,
updated_at=retried.created_at,
)
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
if job is None:
raise HTTPException(status_code=404, detail="Ingest job not found")
if job.status.value != "failed":
if _status_value(job.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute(
@@ -165,7 +256,7 @@ def retry_project_task(
id=f"ingest:{retried.id}",
task_type="ingest",
project_id=retried.project_id,
status=retried.status.value,
status=_status_value(retried.status),
progress=0,
current_step=_ingest_step(retried),
source_id=retried.id,
+289
View File
@@ -0,0 +1,289 @@
"""Template CRUD + generate + category routes."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.template import (
CategoryResponse,
CreateCategoryRequest,
CreateTemplateRequest,
ListCategoriesResponse,
ListTemplatesResponse,
SegmentResponse,
TemplateResponse,
UpdateTemplateRequest,
ValidateTemplateRequest,
ValidateTemplateResponse,
GenerateWarningResponse,
)
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
from packages.application.template.commands import (
CreateCategoryCommand,
CreateTemplateCommand,
SegmentCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.application.template.use_cases import (
CreateCategoryUseCase,
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTemplatesUseCase,
NotFoundError,
UpdateTemplateUseCase,
ValidateTemplateUseCase,
ValidationError,
)
router = APIRouter()
def _get_template_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTemplateRepository:
return SQLAlchemyTemplateRepository(session)
def _segment_to_response(seg) -> SegmentResponse:
return SegmentResponse(
id=seg.id,
template_id=seg.template_id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
created_at=seg.created_at,
updated_at=seg.updated_at,
)
def _to_response(template) -> TemplateResponse:
return TemplateResponse(
id=template.id,
user_id=template.user_id,
name=template.name,
mode=template.mode,
category=template.category,
tags=template.tags,
title_config=template.title_config,
subtitle_config=template.subtitle_config,
bgm_config=template.bgm_config,
estimated_duration=template.estimated_duration,
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
is_active=template.is_active,
created_at=template.created_at,
updated_at=template.updated_at,
)
# ── Template CRUD ──
@router.get("", response_model=ListTemplatesResponse)
def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
user_id = authenticated_user.user.id
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit)
total = template_repository.count_by_user(user_id)
return ListTemplatesResponse(
items=[_to_response(t) for t in templates],
total=total,
)
@router.get("/{template_id}", response_model=TemplateResponse)
def get_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
request: CreateTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
command = CreateTemplateCommand(
user_id=user_id,
name=request.name,
mode=request.mode,
category=request.category,
tags=request.tags,
title_config=request.title_config,
subtitle_config=request.subtitle_config,
bgm_config=request.bgm_config,
estimated_duration=request.estimated_duration,
segments=[
SegmentCommand(
segment_order=s.segment_order,
duration_min=s.duration_min,
duration_max=s.duration_max,
material_type=s.material_type,
)
for s in request.segments
],
)
use_case = CreateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template)
@router.patch("/{template_id}", response_model=TemplateResponse)
def update_template(
template_id: str,
request: UpdateTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
command = UpdateTemplateCommand(
template_id=template_id,
user_id=user_id,
name=request.name,
mode=request.mode,
category=request.category,
tags=request.tags,
title_config=request.title_config,
subtitle_config=request.subtitle_config,
bgm_config=request.bgm_config,
estimated_duration=request.estimated_duration,
segments=(
[
SegmentCommand(
segment_order=s.segment_order,
duration_min=s.duration_min,
duration_max=s.duration_max,
material_type=s.material_type,
)
for s in request.segments
]
if request.segments is not None
else None
),
)
use_case = UpdateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteTemplateUseCase(template_repository)
deleted = use_case.execute(template_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return Response(status_code=204)
# ── Validate template ──
@router.post("/{template_id}/validate", response_model=ValidateTemplateResponse)
def validate_template(
template_id: str,
request: ValidateTemplateRequest = ValidateTemplateRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ValidateTemplateResponse:
user_id = authenticated_user.user.id
command = ValidateTemplateCommand(
template_id=template_id,
user_id=user_id,
voiceover_duration=request.voiceover_duration,
)
use_case = ValidateTemplateUseCase(template_repository)
try:
result = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return ValidateTemplateResponse(
template=_to_response(result.template),
warnings=[
GenerateWarningResponse(code=w.code, message=w.message, details=w.details)
for w in result.warnings
],
)
# ── Category CRUD ──
@router.get("/categories/list", response_model=ListCategoriesResponse)
def list_categories(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListCategoriesResponse:
user_id = authenticated_user.user.id
use_case = ListCategoriesUseCase(template_repository)
categories = use_case.execute(user_id)
return ListCategoriesResponse(
items=[
CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at)
for c in categories
],
)
@router.post("/categories", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(
request: CreateCategoryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> CategoryResponse:
user_id = authenticated_user.user.id
command = CreateCategoryCommand(user_id=user_id, name=request.name)
use_case = CreateCategoryUseCase(template_repository)
category = use_case.execute(command)
return CategoryResponse(
id=category.id, user_id=category.user_id, name=category.name, created_at=category.created_at,
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteCategoryUseCase(template_repository)
deleted = use_case.execute(category_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return Response(status_code=204)
+1 -1
View File
@@ -51,7 +51,7 @@ def _to_response(item) -> TitleLibraryItemResponse:
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.get_by_id(user_id)
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
+37 -10
View File
@@ -1,3 +1,4 @@
import logging
from typing import Any
from uuid import uuid4
@@ -24,6 +25,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
logger = logging.getLogger(__name__)
router = APIRouter()
# 允许上传的文件 MIME 类型
@@ -70,7 +73,7 @@ def _require_project_and_library(
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.list_by_project(project_id)
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")
@@ -131,7 +134,14 @@ async def prepare_direct_upload(
expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS,
)
except RuntimeError as error:
logger.error("OSS not configured for direct upload prepare: %s", error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
except Exception as error:
logger.exception("Unexpected error in direct upload prepare: %s", error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to prepare upload: {type(error).__name__}",
) from error
return DirectUploadPrepareResponse(
upload_url=str(payload["url"]),
@@ -162,7 +172,15 @@ async def complete_direct_upload(
normalized_key = storage_service._normalize_storage_key(request.storage_key)
if not normalized_key.startswith("uploads/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
if not storage_service.file_exists(normalized_key):
try:
file_exists = storage_service.file_exists(normalized_key)
except Exception as error:
logger.exception("OSS error checking file existence for key=%s: %s", normalized_key, error)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage service unavailable",
) from error
if not file_exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
job = _submit_ingest_job(
@@ -181,7 +199,8 @@ async def complete_direct_upload(
description="上传素材文件(multipart/form-data),支持视频、音频、图片。触发导入流水线自动处理。",
)
async def upload_asset(
form_data: Annotated[UploadAssetRequest, Form()],
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
@@ -190,8 +209,6 @@ async def upload_asset(
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
project_id = form_data.project_id
library_id = form_data.library_id
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# P2-5: 服务端验证 MIME 类型
@@ -201,11 +218,21 @@ async def upload_asset(
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
storage_key = f"uploads/{file_id}/{safe_filename}"
file_url = storage_service.upload_file(
file.file,
storage_key,
content_type=validated_content_type,
)
try:
file_url = storage_service.upload_file(
file.file,
storage_key,
content_type=validated_content_type,
)
except RuntimeError as error:
logger.error("OSS not configured for upload: %s", error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
except Exception as error:
logger.exception("Unexpected error uploading file to OSS: %s", error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload file: {type(error).__name__}",
) from error
job = _submit_ingest_job(
project_id=project_id,
+1 -1
View File
@@ -55,7 +55,7 @@ def _to_response(item) -> VoiceLibraryItemResponse:
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.get_by_id(user_id)
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
+6 -3
View File
@@ -1,7 +1,7 @@
import os
from typing import Optional
from pydantic import field_validator
from pydantic import AliasChoices, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -83,8 +83,11 @@ class Settings(BaseSettings):
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
OSS_DIRECT_UPLOAD_MAX_MB: int = 800
OSS_DIRECT_UPLOAD_EXPRESS_SECRET: int = 900
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
default=2000,
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
LOG_LEVEL: str = "INFO"
CORS_ORIGINS_RAW: str = (
+31 -10
View File
@@ -28,17 +28,38 @@ class OSSStorageService:
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
if settings.OSS_ACCESS_KEY_ID and settings.OSS_ACCESS_KEY_SECRET:
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
if has_key_id and has_key_secret:
if oss2 is not None:
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
try:
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.OSS_ACCESS_KEY_ID
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
self.endpoint = settings.OSS_ENDPOINT
+30
View File
@@ -0,0 +1,30 @@
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)
+35 -3
View File
@@ -1,12 +1,35 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class CreateGenerationTaskRequest(BaseModel):
project_id: str = Field(..., min_length=1)
asset_library_id: str = Field(..., min_length=1)
"""创建生成任务请求。
支持两种模式(至少提供一种):
- 项目模式:project_id + asset_library_id(向后兼容)
- 模板模式:template_id + asset_ids / title_ids / voice_ids
"""
project_id: str = ""
asset_library_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
created_by_user_id: str = ""
# ── 模板模式新增字段 ──
template_id: str = ""
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
has_project = bool(self.project_id.strip())
has_template = bool(self.template_id.strip())
if not has_project and not has_template:
raise ValueError("project_id 或 template_id 至少需要提供一个")
has_library = bool(self.asset_library_id.strip())
has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids)
if not has_library and not has_assets:
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
return self
class GenerationTaskResponse(BaseModel):
@@ -15,7 +38,16 @@ class GenerationTaskResponse(BaseModel):
asset_library_id: str
strategy_id: str
voice_library_id: str
template_id: str = ""
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
status: str
progress: float
result_count: int
error_message: str
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
items: list[GenerationTaskResponse]
+84
View File
@@ -0,0 +1,84 @@
"""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
+23
View File
@@ -14,9 +14,32 @@ class ProjectTaskResponse(BaseModel):
user_message: str = ""
retryable: bool = False
source_id: str = ""
template_id: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class ListProjectTasksResponse(BaseModel):
items: list[ProjectTaskResponse] = Field(default_factory=list)
class UserTaskResponse(BaseModel):
"""用户级任务响应(跨 project,用于模板模式)。"""
id: str
task_type: str
project_id: str = ""
template_id: str = ""
status: str
progress: float
current_step: str
error_message: str = ""
user_message: str = ""
retryable: bool = False
source_id: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class ListTasksResponse(BaseModel):
"""用户级任务列表响应(GET /api/v1/tasks)。"""
items: list[UserTaskResponse] = Field(default_factory=list)
+111
View File
@@ -0,0 +1,111 @@
"""Template API schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Segment ──
class SegmentResponse(BaseModel):
id: str
template_id: str
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None
created_at: datetime
updated_at: datetime
class SegmentRequest(BaseModel):
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None
# ── Template Response ──
class TemplateResponse(BaseModel):
id: str
user_id: str
name: str
mode: str
category: str = ""
tags: List[str] = Field(default_factory=list)
title_config: Dict[str, Any] = Field(default_factory=dict)
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
bgm_config: Dict[str, Any] = Field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentResponse] = Field(default_factory=list)
is_active: bool = True
created_at: datetime
updated_at: datetime
class ListTemplatesResponse(BaseModel):
items: List[TemplateResponse]
total: int = 0
# ── Template Request ──
class CreateTemplateRequest(BaseModel):
name: str
mode: str
category: str = ""
tags: List[str] = Field(default_factory=list)
title_config: Dict[str, Any] = Field(default_factory=dict)
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
bgm_config: Dict[str, Any] = Field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentRequest] = Field(default_factory=list)
class UpdateTemplateRequest(BaseModel):
name: Optional[str] = None
mode: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
title_config: Optional[Dict[str, Any]] = None
subtitle_config: Optional[Dict[str, Any]] = None
bgm_config: Optional[Dict[str, Any]] = None
estimated_duration: Optional[float] = None
segments: Optional[List[SegmentRequest]] = None
# ── Validate ──
class ValidateTemplateRequest(BaseModel):
voiceover_duration: Optional[float] = None # 配音实际时长(秒)
class GenerateWarningResponse(BaseModel):
code: str
message: str
details: Dict[str, Any] = Field(default_factory=dict)
class ValidateTemplateResponse(BaseModel):
template: TemplateResponse
warnings: List[GenerateWarningResponse] = Field(default_factory=list)
# ── Category ──
class CategoryResponse(BaseModel):
id: str
user_id: str
name: str
created_at: datetime
class CreateCategoryRequest(BaseModel):
name: str
class ListCategoriesResponse(BaseModel):
items: List[CategoryResponse]
+5 -4
View File
@@ -39,10 +39,11 @@ if settings.DEBUG:
allow_origins = settings.CORS_ORIGINS # Allow localhost in debug mode
else:
# In production, filter out any wildcard "*" origins
allow_origins = [origin for origin in settings.CORS_ORIGINS if origin != "*"]
if not allow_origins:
# Default to production domain if no valid origins configured
allow_origins = ["https://xiaoxiajianji.com"]
allow_origins = list({origin for origin in settings.CORS_ORIGINS if origin != "*"})
# Always ensure production domains are included
for domain in ("https://xiaoxiajianji.com", "https://saas.xiaoxiajianji.com"):
if domain not in allow_origins:
allow_origins.append(domain)
app.add_middleware(
CORSMiddleware,
+15 -2
View File
@@ -3,6 +3,7 @@
* Phase 1 重构:去掉 project_id,素材直接归属用户
*/
import apiClient from './client';
import { getOrCreateDefaultProject } from './projects';
/** 素材条目 */
export interface AssetItem {
@@ -93,12 +94,17 @@ export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
return response.data.items || [];
};
/** 创建素材库 */
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
export const createAssetLibrary = async (data: {
name: string;
kind: 'video' | 'voice' | 'image';
}): Promise<AssetLibraryItem> => {
const response = await apiClient.post('/asset-libraries', data);
// 后端要求 project_id,前端自动管理默认项目
const project = await getOrCreateDefaultProject();
const response = await apiClient.post('/asset-libraries', {
project_id: project.id,
...data,
});
return response.data;
};
@@ -148,6 +154,7 @@ export const uploadAsset = async (
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
project_id: string;
library_id: string;
filename: string;
content_type: string;
@@ -166,6 +173,7 @@ export const prepareDirectUpload = async (data: {
/** 直传完成确认 */
export const completeDirectUpload = async (data: {
project_id: string;
library_id: string;
storage_key: string;
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
@@ -178,7 +186,11 @@ export const uploadAssetDirect = async (data: {
file: File;
library_id: string;
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject();
const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || 'application/octet-stream',
@@ -200,6 +212,7 @@ export const uploadAssetDirect = async (data: {
}
return completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
});
+41 -2
View File
@@ -3,6 +3,7 @@
* 封装 Axios 实例,配置拦截器和 Token 管理
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { message } from 'antd';
import { useAuthStore } from '@/store/authStore';
// 创建 Axios 实例
@@ -28,14 +29,52 @@ apiClient.interceptors.request.use(
}
);
// 响应拦截器:处理未授权状态
// 响应拦截器:统一错误提示 + 处理未授权状态
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
// 401 → 清除登录态
if (error.response?.status === 401) {
useAuthStore.getState().clearAuth();
}
// 提取后端返回的错误信息(detail / message / msg)
const data = error.response?.data;
const serverMsg = data?.detail || data?.message || data?.msg;
let handled = false;
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
message.error('请求超时,请检查网络后重试');
handled = true;
} else if (!error.response) {
message.error('网络连接异常,请检查网络设置');
handled = true;
} else if (serverMsg) {
message.error(serverMsg);
handled = true;
} else {
const status = error.response?.status;
if (status === 413) {
message.error('文件过大,请缩小后重试');
handled = true;
} else if (status === 415) {
message.error('不支持的文件格式');
handled = true;
} else if (status === 503) {
message.error('服务暂不可用,请稍后再试');
handled = true;
} else if (status && status >= 500) {
message.error('服务器繁忙,请稍后再试');
handled = true;
}
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
}
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
if (handled) {
(error as any).__msgShown = true;
}
return Promise.reject(error);
}
);
-98
View File
@@ -1,98 +0,0 @@
/**
* 编辑计划 API
* Phase 1 重构:去掉 projectId,编辑计划直接归属用户
*/
import apiClient from './client';
/** 编辑模式 */
export type EditingMode = 'one-take' | 'pip' | 'voiceover' | 'voice_pip';
/** 编辑模板 */
export interface EditTemplateItem {
id: string;
name: string;
description: string;
target_duration: number;
clip_count: number;
is_active: boolean;
category?: string;
thumbnail_url?: string;
}
/** 编辑计划片段 */
export interface EditPlanClipItem {
id: string;
asset_id: string;
asset_name: string;
sequence: number;
start_time: number;
duration: number;
reason: string;
layer?: 'main' | 'pip' | 'broll';
thumbnail_url?: string;
}
/** 编辑计划 */
export interface EditPlanItem {
id: string;
template_id: string;
asset_library_id: string;
title_id: string;
status: string;
editing_mode?: EditingMode;
summary: string;
clips: EditPlanClipItem[];
created_at?: string;
updated_at?: string;
}
// ─── 编辑计划 ──────────────────────────────────────────────
/** 获取当前用户的编辑计划列表 */
export const getEditPlans = async (): Promise<EditPlanItem[]> => {
const response = await apiClient.get('/edit-plans');
return response.data.items || [];
};
/** 获取单个编辑计划 */
export const getEditPlan = async (planId: string): Promise<EditPlanItem> => {
const response = await apiClient.get(`/edit-plans/${planId}`);
return response.data;
};
/** 创建编辑计划 */
export const createEditPlan = async (data: {
asset_library_id: string;
template_id?: string;
title_id?: string;
}): Promise<EditPlanItem> => {
const response = await apiClient.post('/edit-plans', data);
return response.data;
};
/** 智能编排 - 自动生成编辑计划 */
export const autoGenerateEditPlan = async (params: {
template_id: string;
asset_ids?: string[];
title_ids?: string[];
voice_ids?: string[];
editing_mode?: EditingMode;
target_duration?: number;
}): Promise<EditPlanItem> => {
const response = await apiClient.post('/edit-plans/auto-generate', params);
return response.data;
};
/** 更新编辑计划 */
export const updateEditPlan = async (
planId: string,
data: Partial<EditPlanItem>
): Promise<EditPlanItem> => {
const response = await apiClient.patch(`/edit-plans/${planId}`, data);
return response.data;
};
/** 删除编辑计划 */
export const deleteEditPlan = async (planId: string): Promise<void> => {
await apiClient.delete(`/edit-plans/${planId}`);
};
+194
View File
@@ -0,0 +1,194 @@
/**
* 剪辑计划编辑器 API
* 对接后端 /api/v1/templates 路由
*/
import apiClient from './client';
/* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */
export type TemplateMode = 'pip' | 'voice_over' | 'one_take' | 'voice_pip';
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: '画中画',
voice_over: '人物口播',
one_take: '一镜到底',
voice_pip: '口播+混剪',
};
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: 'blue',
voice_over: 'green',
one_take: 'orange',
voice_pip: 'purple',
};
/** 标题配置 */
export interface TitleConfig {
ai_auto_select: boolean;
content: string;
font_preset: string;
font_color: string;
font_size: number;
position: string;
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean;
position: string;
font: string;
color: string;
size: number;
animation: string;
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean;
music_id: string;
}
/** 模板片段 */
export interface TemplateSegment {
id?: string;
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string | null;
}
/** 剪辑模板 */
export interface EditingTemplate {
id: string;
name: string;
mode: TemplateMode;
category: string;
tags: string[];
title_config: TitleConfig;
subtitle_config: SubtitleConfig;
bgm_config: BgmConfig;
estimated_duration: number;
segments: TemplateSegment[];
is_active?: boolean;
created_at: string;
updated_at: string;
}
/** 模板分类 */
export interface TemplateCategory {
id: string;
name: string;
created_at?: string;
}
/** 创建/更新模板请求体 */
export interface SaveTemplatePayload {
name: string;
mode: TemplateMode;
category: string;
tags: string[];
title_config: TitleConfig;
subtitle_config: SubtitleConfig;
bgm_config: BgmConfig;
estimated_duration: number;
segments: Omit<TemplateSegment, 'id'>[];
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: Record<string, unknown>;
}
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
template: EditingTemplate;
warnings: ValidateWarning[];
}
/** 列表响应(带分页) */
export interface ListTemplatesResponse {
items: EditingTemplate[];
total: number;
}
/** 分类列表响应 */
export interface ListCategoriesResponse {
items: TemplateCategory[];
}
// ============ API 函数 ============
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string;
tag?: string;
skip?: number;
limit?: number;
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>('/templates', {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
});
let list = response.data.items;
if (params?.category) list = list.filter((t) => t.category === params.category);
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
return list;
};
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
return response.data;
};
/** 创建模板 */
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>('/templates', data);
return response.data;
};
/** 更新模板 */
export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data);
return response.data;
};
/** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`);
};
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>('/templates/categories/list');
return response.data.items;
};
/** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async (
templateId: string,
data: GenerateFromTemplatePayload,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`,
data,
);
return response.data;
};
+60
View File
@@ -0,0 +1,60 @@
/**
* 项目相关 API
* 素材库需要 project_id,前端自动管理默认项目
*/
import apiClient from './client';
export interface ProjectItem {
id: string;
name: string;
description: string;
}
/** 后端 ProjectResponse 只返回 id, name, description */
interface BackendProjectResponse {
id: string;
name: string;
description: string;
}
/** 后端 ListProjectsResponse 返回 { items: [...] } */
interface BackendListProjectsResponse {
items: BackendProjectResponse[];
}
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
id: item.id,
name: item.name,
description: item.description,
});
/** 获取当前用户的项目列表 */
export const getProjects = async (): Promise<ProjectItem[]> => {
const response = await apiClient.get<BackendListProjectsResponse>('/projects');
return (response.data.items || []).map(toProjectItem);
};
/** 创建项目 */
export const createProject = async (data: {
name: string;
description?: string;
}): Promise<ProjectItem> => {
const response = await apiClient.post<BackendProjectResponse>('/projects', {
name: data.name,
description: data.description || '',
});
return toProjectItem(response.data);
};
/** 获取或创建默认项目(素材库需要 project_id) */
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
const projects = await getProjects();
if (projects.length > 0) {
return projects[0];
}
// 没有项目时自动创建默认项目
return createProject({
name: '默认项目',
description: '系统自动创建的默认项目',
});
};
+37 -29
View File
@@ -1,13 +1,20 @@
/**
* 任务相关 API
* Phase 1 重构:去掉 projectId,任务直接归属用户
* 对接后端方案 A 扩展后的端点(PR #109)
* - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式)
* - GET /api/v1/tasks — 用户级任务列表(跨 project)
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
*/
import apiClient from './client';
/** 任务条目 */
/* ──────────── 类型定义 ──────────── */
/** 任务条目(对应用户级 UserTaskResponse) */
export interface TaskItem {
id: string;
task_type: 'ingest' | 'generation' | string;
project_id: string;
template_id: string;
status: string;
progress: number;
current_step: string;
@@ -19,18 +26,6 @@ export interface TaskItem {
updated_at?: string | null;
}
/** 获取当前用户的所有任务(生成记录) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const response = await apiClient.get('/tasks');
return response.data.items || [];
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const response = await apiClient.post(`/tasks/${taskId}/retry`);
return response.data;
};
/** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest {
template_id: string;
@@ -39,31 +34,44 @@ export interface CreateGenerationTaskRequest {
voice_ids: string[];
}
/** 创建生成任务响应 */
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
export interface CreateGenerationTaskResponse {
task_id: string;
id: string;
project_id: string;
asset_library_id: string;
strategy_id: string;
voice_library_id: string;
template_id: string;
asset_ids: string[];
title_ids: string[];
voice_ids: string[];
status: string;
message: string;
progress: number;
result_count: number;
error_message: string;
}
// TODO: 后端生成接口适配扁平化架构后切换为 false
const USE_MOCK = true;
/* ──────────── API 函数 ──────────── */
/** 创建生成任务(一键生成) */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, 800));
return {
task_id: `task_${Date.now()}`,
status: 'pending',
message: '生成任务已创建',
};
}
const response = await apiClient.post<CreateGenerationTaskResponse>(
const { data } = await apiClient.post<CreateGenerationTaskResponse>(
'/generation/tasks',
params,
);
return response.data;
return data;
};
/** 获取当前用户的所有任务(跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get('/tasks');
return data.items || [];
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
return data;
};
+73 -11
View File
@@ -1,10 +1,11 @@
/**
* 标题相关 API
* Phase 1 新增:全局标题库
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
*/
import apiClient from './client';
/** 标题条目 */
/** 标题条目(前端展示用) */
export interface TitleItem {
id: string;
content: string;
@@ -16,7 +17,50 @@ export interface TitleItem {
updated_at?: string;
}
/** 创建标题请求 */
/** 后端标题响应格式 */
interface BackendTitleResponse {
id: string;
user_id: string;
name: string;
text: string;
category: string;
description: string;
tags: string[];
usage_count: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
/** 后端创建标题请求格式 */
interface BackendCreateTitleRequest {
name: string;
text: string;
category: string;
description?: string;
tags?: string[];
}
/** 后端更新标题请求格式 */
interface BackendUpdateTitleRequest {
name?: string;
text?: string;
category?: string;
description?: string;
tags?: string[];
}
/** 将后端响应映射为前端 TitleItem */
const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
id: item.id,
content: item.text,
category: item.category,
word_count: item.text?.length || 0,
created_at: item.created_at,
updated_at: item.updated_at,
});
/** 创建标题请求(前端接口,保持向后兼容) */
export interface CreateTitleRequest {
content: string;
category?: string;
@@ -24,25 +68,43 @@ export interface CreateTitleRequest {
/** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => {
const response = await apiClient.get('/titles');
return response.data.items || response.data || [];
const response = await apiClient.get<{ items: BackendTitleResponse[] }>('/titles');
return (response.data.items || []).map(toTitleItem);
};
/** 创建标题 */
export const createTitle = async (
data: CreateTitleRequest
data: CreateTitleRequest,
): Promise<TitleItem> => {
const response = await apiClient.post('/titles', data);
return response.data;
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取
const payload: BackendCreateTitleRequest = {
name: data.content.slice(0, 255),
text: data.content.slice(0, 500),
category: data.category || 'default',
};
const response = await apiClient.post<BackendTitleResponse>('/titles', payload);
return toTitleItem(response.data);
};
/** 更新标题 */
export const updateTitle = async (
titleId: string,
data: Partial<CreateTitleRequest>
data: Partial<CreateTitleRequest>,
): Promise<TitleItem> => {
const response = await apiClient.patch(`/titles/${titleId}`, data);
return response.data;
const payload: BackendUpdateTitleRequest = {};
if (data.content !== undefined) {
payload.name = data.content.slice(0, 255);
payload.text = data.content.slice(0, 500);
}
if (data.category !== undefined) {
payload.category = data.category;
}
// 后端用 PUT,非 PATCH
const response = await apiClient.put<BackendTitleResponse>(
`/titles/${titleId}`,
payload,
);
return toTitleItem(response.data);
};
/** 删除标题 */
@@ -52,7 +114,7 @@ export const deleteTitle = async (titleId: string): Promise<void> => {
/** 批量导入标题 */
export const batchImportTitles = async (
titles: string[]
titles: string[],
): Promise<{ imported_count: number }> => {
const response = await apiClient.post('/titles/batch-import', { titles });
return response.data;
@@ -18,6 +18,8 @@ import {
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
EditOutlined,
FolderOutlined,
} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
@@ -40,6 +42,8 @@ const NAV_ITEMS: NavItem[] = [
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> },
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> },
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> },
{ key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: <EditOutlined /> },
{ key: 'my-templates', label: '我的模板', path: '/my-templates', icon: <FolderOutlined /> },
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
+57 -13
View File
@@ -37,11 +37,18 @@ import {
getAssets,
deleteAsset,
uploadAsset,
uploadAssetDirect,
} from '@/api/assets';
import { getOrCreateDefaultProject } from '@/api/projects';
const { Title, Text } = Typography;
const { Dragger } = Upload;
/** 最大文件大小:2048MB(与后端 OSS_DIRECT_UPLOAD_MAX_MB=2000 对齐,留少量余量) */
const MAX_FILE_SIZE = 2048 * 1024 * 1024; // 2147483648 bytes
/** 大文件阈值:100MB,超过此值走 OSS 直传通道 */
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; // 104857600 bytes
/** 素材库类型图标 */
const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
switch (kind) {
@@ -71,6 +78,7 @@ const AssetLibrary: React.FC = () => {
const [newLibKind, setNewLibKind] = useState<'video' | 'voice' | 'image'>(
'video'
);
const [uploading, setUploading] = useState(false);
// 获取素材库列表
const { data: libraries = [], isLoading: libsLoading } = useQuery({
@@ -94,12 +102,10 @@ const AssetLibrary: React.FC = () => {
setNewLibName('');
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
},
onError: () => {
message.error('创建失败');
},
onError: (err: any) => { if (!err?.__msgShown) message.error('创建失败') },
});
// 上传素材
// 上传素材(小文件表单上传)
const uploadMutation = useMutation({
mutationFn: uploadAsset,
onSuccess: () => {
@@ -107,9 +113,18 @@ const AssetLibrary: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
},
onError: () => {
message.error('上传失败');
onError: (err: any) => { if (!err?.__msgShown) message.error('上传失败') },
});
// 大文件直传(OSS 预签名)
const directUploadMutation = useMutation({
mutationFn: uploadAssetDirect,
onSuccess: () => {
message.success('上传成功');
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('上传失败') },
});
// 删除素材
@@ -120,6 +135,7 @@ const AssetLibrary: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
});
/** 处理上传 */
@@ -128,10 +144,35 @@ const AssetLibrary: React.FC = () => {
message.warning('请先选择素材库');
return false;
}
const formData = new FormData();
formData.append('file', file);
formData.append('library_id', activeLibrary);
await uploadMutation.mutateAsync(formData);
// 文件大小校验:上限 2GB
if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 2GB');
return false;
}
setUploading(true);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
// 大文件(>100MB)走 OSS 预签名直传通道
await directUploadMutation.mutateAsync({
file,
library_id: activeLibrary,
});
} else {
// 小文件走表单上传
const project = await getOrCreateDefaultProject();
const formData = new FormData();
formData.append('file', file);
formData.append('library_id', activeLibrary);
formData.append('project_id', project.id);
await uploadMutation.mutateAsync(formData);
}
} catch {
// mutation.onError 已处理错误提示
} finally {
setUploading(false);
}
return false;
};
@@ -208,14 +249,17 @@ const AssetLibrary: React.FC = () => {
beforeUpload={handleUpload}
showUploadList={false}
multiple
disabled={uploading || uploadMutation.isPending || directUploadMutation.isPending}
style={{ marginBottom: 24 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
{uploading ? <Spin /> : <InboxOutlined />}
</p>
<p className="ant-upload-text">
{uploading ? '正在上传,请稍候...' : '点击或拖拽文件到此区域上传'}
</p>
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p className="ant-upload-hint">
支持 {kindLabel[currentLib?.kind || 'video']} 格式文件
支持 {kindLabel[currentLib?.kind || 'video']} 格式文件,单文件不超过 2GB
</p>
</Dragger>
)}
+1 -1
View File
@@ -20,7 +20,7 @@ const ForgotPassword: React.FC = () => {
message.success('重置邮件已发送!');
},
onError: (error: any) => {
message.error(error.response?.data?.message || '发送失败,请重试');
if (!error?.__msgShown) message.error(error.response?.data?.message || '发送失败,请重试');
},
});
+1 -1
View File
@@ -27,7 +27,7 @@ const Login: React.FC = () => {
message.success('登录成功!');
navigate('/');
} catch (error: any) {
message.error(error.response?.data?.message || '登录失败,请检查邮箱和密码');
if (!error?.__msgShown) message.error(error.response?.data?.message || '登录失败,请检查邮箱和密码');
}
};
+1 -1
View File
@@ -29,7 +29,7 @@ const Register: React.FC = () => {
});
message.success('注册成功!请查收验证邮件。');
} catch (error: any) {
message.error(error.response?.data?.message || '注册失败,请重试');
if (!error?.__msgShown) message.error(error.response?.data?.message || '注册失败,请重试');
}
};
+1 -1
View File
@@ -22,7 +22,7 @@ const ResetPassword: React.FC = () => {
setTimeout(() => navigate('/login'), 2000);
},
onError: (error: any) => {
message.error(error.response?.data?.message || '重置失败,请重试');
if (!error?.__msgShown) message.error(error.response?.data?.message || '重置失败,请重试');
},
});
+10 -1
View File
@@ -16,6 +16,7 @@ import {
Button,
Progress,
Space,
Alert,
} from 'antd';
import {
FileOutlined,
@@ -60,7 +61,7 @@ const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const { data, isLoading } = useQuery({
const { data, isLoading, isError } = useQuery({
queryKey: ['dashboard-overview'],
queryFn: getDashboardOverview,
});
@@ -111,6 +112,14 @@ const Dashboard: React.FC = () => {
);
}
if (isError) {
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<Alert type="error" message="加载数据失败" description="仪表盘数据获取失败,请刷新页面重试。" showIcon />
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<Title level={3} style={{ marginBottom: 24 }}>
@@ -228,7 +228,7 @@ const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: detail, isLoading } = useQuery({
const { data: detail, isLoading, isError } = useQuery({
queryKey: ['duplication-detail', id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
@@ -242,6 +242,18 @@ const DuplicationDetail: React.FC = () => {
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Empty description="加载查重记录失败">
<Button onClick={() => navigate('/duplication/results')}>
返回列表
</Button>
</Empty>
</div>
);
}
if (!detail) {
return (
<div style={{ padding: 24 }}>
@@ -105,6 +105,7 @@ const DuplicationResults: React.FC = () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['duplication-records'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
});
// 重新查重
@@ -114,6 +115,7 @@ const DuplicationResults: React.FC = () => {
message.success('已重新提交查重');
queryClient.invalidateQueries({ queryKey: ['duplication-records'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('重新查重失败') },
});
/** 批量删除 */
@@ -53,9 +53,9 @@ const DuplicationUpload: React.FC = () => {
});
message.success('查重任务已提交');
},
onError: () => {
onError: (err: any) => {
setUploading(false);
message.error('上传失败,请重试');
if (!err?.__msgShown) message.error('上传失败,请重试');
},
});
@@ -0,0 +1,164 @@
/* ═══════════════════════════════════════════════════
* 剪辑计划编辑器样式
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* ═══════════════════════════════════════════════════ */
.ep-editor {
display: flex;
flex-direction: column;
height: 100%;
gap: 0;
}
/* ─── 顶部工具栏 ─── */
.ep-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
/* ─── 三栏主体 ─── */
.ep-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ─── 左侧:模板面板 ─── */
.ep-left {
width: 260px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ep-tpl-card {
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.ep-tpl-card-active {
border-color: var(--ant-color-primary, #4f46e5) !important;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
/* ─── 中间:预览 + 时间线 ─── */
.ep-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
background: #fff;
}
/* 预览行 */
.ep-preview-row {
display: flex;
gap: 24px;
align-items: flex-start;
margin-bottom: 24px;
}
.ep-preview-box {
display: flex;
flex-direction: column;
align-items: center;
}
.ep-preview-frame {
width: 160px;
height: 284px;
background: #f5f5f5;
border: 2px dashed #d9d9d9;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ep-cover-btns {
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
/* 时间线 */
.ep-timeline {
background: #fafafa;
border-radius: 12px;
padding: 16px;
border: 1px solid #f0f0f0;
}
.ep-seg-card {
transition: box-shadow 0.2s, border-color 0.2s;
cursor: grab;
}
.ep-seg-card:active {
cursor: grabbing;
}
.ep-seg-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* ─── 右侧:设置面板 ─── */
.ep-right {
width: 280px;
flex-shrink: 0;
background: #fafafa;
border-left: 1px solid #f0f0f0;
padding: 16px;
overflow-y: auto;
}
.ep-settings-group {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.ep-settings-group:last-child {
border-bottom: none;
margin-bottom: 0;
}
/* ─── 响应式 ─── */
@media (max-width: 1200px) {
.ep-left {
width: 220px;
}
.ep-right {
width: 240px;
}
}
@media (max-width: 900px) {
.ep-body {
flex-direction: column;
}
.ep-left,
.ep-right {
width: 100%;
max-height: 300px;
border-right: none;
border-left: none;
border-bottom: 1px solid #f0f0f0;
}
.ep-preview-row {
flex-wrap: wrap;
}
}
@@ -0,0 +1,438 @@
/**
* 剪辑计划编辑器
* 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板
* 支持 4 种模式切换(画中画 / 人物口播 / 一镜到底 / 口播+混剪)
*
* P0-2: 读取 URL 参数 ?template=xxx&generate=1
* P1-3: 拆分为子组件
* P1-4: voiceover_id → voiceover_duration
* P1-5: 分类 Input → Select(在 SaveModal 中实现)
* P1-6: SaveTemplatePayload 补充 estimated_duration
*/
import React, { useState, useEffect } from 'react';
import './EditingPlanner.css';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Space, message } from 'antd';
import {
SaveOutlined,
VideoCameraOutlined,
AppstoreOutlined,
UserOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import {
getEditingTemplates,
getTemplateCategories,
createEditingTemplate,
updateEditingTemplate,
generateFromTemplate,
MODE_LABELS,
type EditingTemplate,
type TemplateSegment,
type TemplateMode,
type TitleConfig,
type SubtitleConfig,
type BgmConfig,
} from '@/api/editingPlanner';
/* ── 子组件 ── */
import TemplatePanel from './components/TemplatePanel';
import TimelinePanel from './components/TimelinePanel';
import SettingsPanel from './components/SettingsPanel';
import SaveModal from './components/SaveModal';
import GenerateModal from './components/GenerateModal';
/* ──────────── 常量 ──────────── */
const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [
{ key: 'pip', icon: <AppstoreOutlined />, desc: '多画面叠加' },
{ key: 'voice_over', icon: <UserOutlined />, desc: '人物讲解为主' },
{ key: 'one_take', icon: <VideoCameraOutlined />, desc: '连续不中断' },
{ key: 'voice_pip', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' },
];
const DEFAULT_TITLE: TitleConfig = {
ai_auto_select: true,
content: '',
font_preset: '思源黑体',
font_color: '#ffffff',
font_size: 32,
position: 'top',
};
const DEFAULT_SUBTITLE: SubtitleConfig = {
enabled: true,
position: 'bottom',
font: '思源黑体',
color: '#ffffff',
size: 24,
animation: 'fade',
};
const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: '' };
/** 计算预估时长 = Σ 片段时长范围中值 */
const calcEstimatedDuration = (segs: TemplateSegment[]) =>
Math.round(segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0));
let _segId = 0;
const newSegId = () => `seg-new-${++_segId}`;
/* ──────────── 组件 ──────────── */
const EditingPlanner: React.FC = () => {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
/* ── P0-2: URL 参数 ── */
const urlTemplateId = searchParams.get('template');
const urlGenerate = searchParams.get('generate');
/* ── 数据查询 ── */
const [searchText, setSearchText] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const { data: templates = [], isLoading: tplLoading } = useQuery({
queryKey: ['editing-templates', filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
});
const { data: categories = [] } = useQuery({
queryKey: ['template-categories'],
queryFn: getTemplateCategories,
});
/* ── 编辑器状态 ── */
const [currentMode, setCurrentMode] = useState<TemplateMode>('pip');
const [segments, setSegments] = useState<TemplateSegment[]>([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
]);
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null);
const [titleConfig, setTitleConfig] = useState<TitleConfig>({ ...DEFAULT_TITLE });
const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({ ...DEFAULT_SUBTITLE });
const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM });
/* ── UI 状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [draftName, setDraftName] = useState('');
const [draftCategory, setDraftCategory] = useState('');
const [draftTags, setDraftTags] = useState('');
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(null);
const [dragIdx, setDragIdx] = useState<number | null>(null);
/* ── P0-2: 自动加载 URL 指定的模板 ── */
useEffect(() => {
if (urlTemplateId && templates.length > 0 && !loadedTemplateId) {
const tpl = templates.find((t) => t.id === urlTemplateId);
if (tpl) {
loadTemplate(tpl);
// 如果 URL 有 generate=1,自动打开发成弹窗
if (urlGenerate === '1') {
setGenerateModalOpen(true);
}
}
}
}, [urlTemplateId, templates, loadedTemplateId, urlGenerate]);
/* ── Mutations ── */
const createMutation = useMutation({
mutationFn: createEditingTemplate,
onSuccess: () => {
message.success('模板已保存');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
setSaveModalOpen(false);
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('保存失败');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => updateEditingTemplate(id, data),
onSuccess: () => {
message.success('模板已更新');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
setSaveModalOpen(false);
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('保存失败');
},
});
const generateMutation = useMutation({
mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) =>
generateFromTemplate(templateId, { voiceover_duration: duration }),
onSuccess: (data) => {
const msg = data.warnings && data.warnings.length > 0 ? `生成任务已提交(${data.warnings.map(w => w.message).join('; ')})` : '生成任务已提交';
message.success(msg);
setGenerateModalOpen(false);
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('生成失败');
},
});
const saving = createMutation.isPending || updateMutation.isPending;
/* ──────────── 片段操作 ──────────── */
const addSegment = () => {
if (currentMode === 'one_take') return;
setSegments((prev) => [
...prev,
{
id: newSegId(),
segment_order: prev.length + 1,
duration_min: 5,
duration_max: 15,
material_type: currentMode === 'voice_pip' ? '人物' : null,
},
]);
};
const removeSegment = (id: string) => {
if (currentMode === 'one_take') return;
setSegments((prev) =>
prev.filter((s) => s.id !== id).map((s, i) => ({ ...s, segment_order: i + 1 })),
);
};
const updateSegment = (id: string, patch: Partial<TemplateSegment>) => {
setSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
};
const handleDragStart = (idx: number) => setDragIdx(idx);
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) return;
setSegments((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIdx, 1);
next.splice(idx, 0, moved);
return next.map((s, i) => ({ ...s, segment_order: i + 1 }));
});
setDragIdx(idx);
};
const handleDragEnd = () => setDragIdx(null);
/* ──────────── 模式切换 ──────────── */
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode);
if (mode === 'one_take') {
// 锁定为 1 个片段
setSegments([
{
id: newSegId(),
segment_order: 1,
duration_min: 10,
duration_max: 20,
material_type: null,
},
]);
} else if (mode === 'voice_pip') {
// 确保每个片段有 material_type
setSegments((prev) =>
prev.map((s) => ({
...s,
material_type: s.material_type || '人物',
})),
);
}
};
/* ──────────── 模板操作 ──────────── */
const loadTemplate = (tpl: EditingTemplate) => {
setLoadedTemplateId(tpl.id);
setCurrentMode(tpl.mode);
setSegments(tpl.segments.map((s) => ({ ...s })));
setTitleConfig({ ...tpl.title_config });
setSubtitleConfig({ ...tpl.subtitle_config });
setBgmConfig({ ...tpl.bgm_config });
};
const resetEditor = () => {
setLoadedTemplateId(null);
setCurrentMode('pip');
setSegments([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
]);
setTitleConfig({ ...DEFAULT_TITLE });
setSubtitleConfig({ ...DEFAULT_SUBTITLE });
setBgmConfig({ ...DEFAULT_BGM });
};
const openSaveModal = () => {
if (segments.length === 0) {
message.warning('请至少添加一个片段');
return;
}
setDraftName(loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.name || '' : '');
setDraftCategory(
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.category || '' : '',
);
setDraftTags(
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(', ') || '' : '',
);
setSaveModalOpen(true);
};
const handleSave = () => {
if (!draftName.trim()) {
message.warning('请输入模板名称');
return;
}
const estimatedDuration = calcEstimatedDuration(segments);
const payload = {
name: draftName.trim(),
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(/[,,]/)
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: subtitleConfig,
bgm_config: bgmConfig,
estimated_duration: estimatedDuration,
segments: segments.map(({ id: _id, ...rest }) => rest),
};
if (loadedTemplateId) {
updateMutation.mutate({ id: loadedTemplateId, data: payload });
} else {
createMutation.mutate(payload);
}
};
const handleGenerate = () => {
if (!loadedTemplateId) {
message.warning('请先保存模板');
return;
}
setGenerateModalOpen(true);
};
const doGenerate = () => {
if (!voiceoverDuration || voiceoverDuration <= 0) {
message.warning('请输入配音时长');
return;
}
generateMutation.mutate({ templateId: loadedTemplateId!, duration: voiceoverDuration });
};
const estimatedDuration = calcEstimatedDuration(segments);
/* ──────────── 渲染 ──────────── */
return (
<div className="ep-editor">
{/* ═══ 顶部工具栏 ═══ */}
<div className="ep-toolbar">
<Space wrap>
{MODES.map((m) => (
<Button
key={m.key}
type={currentMode === m.key ? 'primary' : 'default'}
icon={m.icon}
onClick={() => handleModeChange(m.key)}
>
{MODE_LABELS[m.key]}
</Button>
))}
</Space>
<Space>
<Button icon={<SaveOutlined />} onClick={openSaveModal}>
保存模板
</Button>
<Button
type="primary"
icon={<VideoCameraOutlined />}
onClick={handleGenerate}
disabled={!loadedTemplateId}
>
使用此模板生成
</Button>
</Space>
</div>
{/* ═══ 三栏主体 ═══ */}
<div className="ep-body">
{/* 左侧:模板面板 */}
<TemplatePanel
templates={templates}
categories={categories}
isLoading={tplLoading}
searchText={searchText}
filterCategory={filterCategory}
loadedTemplateId={loadedTemplateId}
onSearchChange={setSearchText}
onCategoryChange={setFilterCategory}
onTemplateSelect={loadTemplate}
onNewTemplate={resetEditor}
/>
{/* 中间:预览 + 时间线 */}
<TimelinePanel
segments={segments}
currentMode={currentMode}
estimatedDuration={estimatedDuration}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
/>
{/* 右侧:设置面板 */}
<SettingsPanel
titleConfig={titleConfig}
subtitleConfig={subtitleConfig}
bgmConfig={bgmConfig}
onTitleChange={setTitleConfig}
onSubtitleChange={setSubtitleConfig}
onBgmChange={setBgmConfig}
/>
</div>
{/* 保存模板弹窗 */}
<SaveModal
open={saveModalOpen}
loading={saving}
isUpdate={!!loadedTemplateId}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
onNameChange={setDraftName}
onCategoryChange={setDraftCategory}
onTagsChange={setDraftTags}
onSave={handleSave}
onCancel={() => setSaveModalOpen(false)}
/>
{/* 使用模板生成弹窗 */}
<GenerateModal
open={generateModalOpen}
loading={generateMutation.isPending}
voiceoverDuration={voiceoverDuration}
estimatedDuration={estimatedDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={doGenerate}
onCancel={() => setGenerateModalOpen(false)}
/>
</div>
);
};
export default EditingPlanner;
@@ -0,0 +1,58 @@
/**
* 使用模板生成视频弹窗
* P1-4: voiceover_id → voiceover_duration (number)
*/
import React from 'react';
import { Modal, InputNumber, Space, Typography } from 'antd';
const { Text } = Typography;
interface GenerateModalProps {
open: boolean;
loading: boolean;
voiceoverDuration: number | null;
estimatedDuration: number;
onDurationChange: (v: number | null) => void;
onGenerate: () => void;
onCancel: () => void;
}
const GenerateModal: React.FC<GenerateModalProps> = ({
open,
loading,
voiceoverDuration,
estimatedDuration,
onDurationChange,
onGenerate,
onCancel,
}) => {
return (
<Modal
title="使用模板生成视频"
open={open}
onCancel={onCancel}
onOk={onGenerate}
confirmLoading={loading}
okText="开始生成"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>配音时长(秒)*</Text>
<InputNumber
placeholder="输入配音时长"
value={voiceoverDuration}
onChange={onDurationChange}
min={1}
max={600}
style={{ width: '100%' }}
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
预估时长:~{estimatedDuration}s,配音时长偏差超过 ±30% 时将收到警告
</Text>
</Space>
</Modal>
);
};
export default GenerateModal;
@@ -0,0 +1,88 @@
/**
* 保存/更新模板弹窗
* 分类使用 Select 关联后端分类 API(P1-5)
*/
import React from 'react';
import { Modal, Input, Select, Space, Typography } from 'antd';
import type { TemplateCategory } from '@/api/editingPlanner';
const { Text } = Typography;
interface SaveModalProps {
open: boolean;
loading: boolean;
isUpdate: boolean;
draftName: string;
draftCategory: string;
draftTags: string;
categories: TemplateCategory[];
estimatedDuration: number;
onNameChange: (v: string) => void;
onCategoryChange: (v: string) => void;
onTagsChange: (v: string) => void;
onSave: () => void;
onCancel: () => void;
}
const SaveModal: React.FC<SaveModalProps> = ({
open,
loading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancel,
}) => {
return (
<Modal
title={isUpdate ? '更新模板' : '保存模板'}
open={open}
onCancel={onCancel}
onOk={onSave}
confirmLoading={loading}
okText="保存"
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Text style={{ fontSize: 13 }}>模板名称 *</Text>
<Input
placeholder="输入模板名称"
value={draftName}
onChange={(e) => onNameChange(e.target.value)}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}>分类</Text>
<Select
placeholder="选择分类"
value={draftCategory || undefined}
onChange={(v) => onCategoryChange(v || '')}
allowClear
showSearch
style={{ width: '100%' }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div>
<Text style={{ fontSize: 13 }}>标签(逗号分隔)</Text>
<Input
placeholder="例如:vlog, 日常"
value={draftTags}
onChange={(e) => onTagsChange(e.target.value)}
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
预估时长:~{estimatedDuration}s
</Text>
</Space>
</Modal>
);
};
export default SaveModal;
@@ -0,0 +1,229 @@
/**
* 右侧设置面板
* 标题设置 / 字幕设置 / BGM 设置
*/
import React from 'react';
import { Typography, Input, Switch, Select, Slider, Tag } from 'antd';
import { SoundOutlined, FontSizeOutlined } from '@ant-design/icons';
import type { TitleConfig, SubtitleConfig, BgmConfig } from '@/api/editingPlanner';
const { Text } = Typography;
/* ── 常量 ── */
const FONT_PRESETS = ['思源黑体', '站酷快乐体', '方正兰亭', '汉仪旗黑'];
const POSITIONS = [
{ value: 'top', label: '顶部' },
{ value: 'center', label: '居中' },
{ value: 'bottom', label: '底部' },
];
const SUBTITLE_FONTS = ['思源黑体', '微软雅黑', '苹方'];
const SUBTITLE_ANIMATIONS = [
{ value: 'none', label: '无' },
{ value: 'fade', label: '淡入' },
{ value: 'typewriter', label: '打字机' },
{ value: 'slide', label: '滑动' },
];
interface SettingsPanelProps {
titleConfig: TitleConfig;
subtitleConfig: SubtitleConfig;
bgmConfig: BgmConfig;
onTitleChange: (config: TitleConfig) => void;
onSubtitleChange: (config: SubtitleConfig) => void;
onBgmChange: (config: BgmConfig) => void;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({
titleConfig,
subtitleConfig,
bgmConfig,
onTitleChange,
onSubtitleChange,
onBgmChange,
}) => {
return (
<div className="ep-right">
{/* 标题设置 */}
<div className="ep-settings-group">
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontSizeOutlined style={{ marginRight: 6 }} />
标题设置
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontSize: 13 }}>AI 自动选择</Text>
<Switch
size="small"
checked={titleConfig.ai_auto_select}
onChange={(checked) => onTitleChange({ ...titleConfig, ai_auto_select: checked })}
checkedChildren="ON"
unCheckedChildren="OFF"
/>
</div>
{!titleConfig.ai_auto_select && (
<Input.TextArea
placeholder="手动输入标题内容"
value={titleConfig.content}
onChange={(e) => onTitleChange({ ...titleConfig, content: e.target.value })}
rows={2}
size="small"
style={{ marginBottom: 12 }}
/>
)}
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>字体预设</Text>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{FONT_PRESETS.map((font) => (
<Tag
key={font}
color={titleConfig.font_preset === font ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => onTitleChange({ ...titleConfig, font_preset: font })}
>
{font}
</Tag>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>颜色</Text>
<Input
size="small"
value={titleConfig.font_color}
onChange={(e) => onTitleChange({ ...titleConfig, font_color: e.target.value })}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>位置</Text>
<Select
size="small"
value={titleConfig.position}
onChange={(v) => onTitleChange({ ...titleConfig, position: v })}
options={POSITIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>字号:{titleConfig.font_size}</Text>
<Slider
min={16}
max={72}
value={titleConfig.font_size}
onChange={(v) => onTitleChange({ ...titleConfig, font_size: v })}
/>
</div>
</div>
{/* 字幕设置 */}
<div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
<FontSizeOutlined style={{ marginRight: 6 }} />
字幕设置
</Text>
<Switch
size="small"
checked={subtitleConfig.enabled}
onChange={(checked) => onSubtitleChange({ ...subtitleConfig, enabled: checked })}
/>
</div>
{subtitleConfig.enabled && (
<>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>位置</Text>
<Select
size="small"
value={subtitleConfig.position}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, position: v })}
options={POSITIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>字体</Text>
<Select
size="small"
value={subtitleConfig.font}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, font: v })}
options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>颜色</Text>
<Input
size="small"
value={subtitleConfig.color}
onChange={(e) => onSubtitleChange({ ...subtitleConfig, color: e.target.value })}
style={{ marginTop: 4 }}
/>
</div>
<div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}>动画</Text>
<Select
size="small"
value={subtitleConfig.animation}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, animation: v })}
options={SUBTITLE_ANIMATIONS}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
<div>
<Text style={{ fontSize: 12 }}>字号:{subtitleConfig.size}</Text>
<Slider
min={12}
max={48}
value={subtitleConfig.size}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, size: v })}
/>
</div>
</>
)}
</div>
{/* BGM 设置 */}
<div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
<SoundOutlined style={{ marginRight: 6 }} />
BGM 设置
</Text>
<Switch
size="small"
checked={bgmConfig.enabled}
onChange={(checked) => onBgmChange({ ...bgmConfig, enabled: checked })}
/>
</div>
{bgmConfig.enabled && (
<div>
<Text style={{ fontSize: 12 }}>选择音乐</Text>
<Select
size="small"
placeholder="选择背景音乐"
value={bgmConfig.music_id || undefined}
onChange={(v) => onBgmChange({ ...bgmConfig, music_id: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: 'bgm-1', label: '轻快节奏' },
{ value: 'bgm-2', label: '舒缓氛围' },
{ value: 'bgm-3', label: '动感活力' },
]}
/>
</div>
)}
</div>
</div>
);
};
export default SettingsPanel;
@@ -0,0 +1,130 @@
/**
* 左侧模板面板
* 搜索、分类筛选、模板卡片列表
*/
import React from 'react';
import { Input, Select, Card, Tag, Empty, Spin, Button, Typography } from 'antd';
import {
SearchOutlined,
} from '@ant-design/icons';
import {
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateCategory,
type TemplateMode,
} from '@/api/editingPlanner';
const { Text } = Typography;
interface TemplatePanelProps {
templates: EditingTemplate[];
categories: TemplateCategory[];
isLoading: boolean;
searchText: string;
filterCategory: string;
loadedTemplateId: string | null;
onSearchChange: (v: string) => void;
onCategoryChange: (v: string) => void;
onTemplateSelect: (tpl: EditingTemplate) => void;
onNewTemplate: () => void;
}
const TemplatePanel: React.FC<TemplatePanelProps> = ({
templates,
categories,
isLoading,
searchText,
filterCategory,
loadedTemplateId,
onSearchChange,
onCategoryChange,
onTemplateSelect,
onNewTemplate,
}) => {
return (
<div className="ep-left">
<div style={{ padding: '0 12px', marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}>
我的模板
</Text>
<Input
prefix={<SearchOutlined />}
placeholder="搜索模板..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
size="small"
style={{ marginBottom: 8 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => onCategoryChange(v || '')}
allowClear
size="small"
style={{ width: '100%' }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
<div style={{ padding: '0 12px', flex: 1, overflowY: 'auto' }}>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : templates.length === 0 ? (
<Empty
description="暂无已保存的模板,请先编辑并保存模板"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: 20 }}
/>
) : (
templates.map((tpl) => (
<Card
key={tpl.id}
size="small"
hoverable
className={`ep-tpl-card ${loadedTemplateId === tpl.id ? 'ep-tpl-card-active' : ''}`}
onClick={() => onTemplateSelect(tpl)}
style={{ marginBottom: 8 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong ellipsis style={{ maxWidth: 140 }}>
{tpl.name}
</Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'blue'} style={{ marginRight: 0 }}>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} 片段 · ~{tpl.estimated_duration}s
</Text>
{tpl.tags.length > 0 && (
<div style={{ marginTop: 4 }}>
{tpl.tags.slice(0, 3).map((tag) => (
<Tag key={tag} style={{ fontSize: 11, marginRight: 4 }}>
{tag}
</Tag>
))}
</div>
)}
</div>
</Card>
))
)}
</div>
{loadedTemplateId && (
<div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}>
<Button size="small" block onClick={onNewTemplate}>
新建空白模板
</Button>
</div>
)}
</div>
);
};
export default TemplatePanel;
@@ -0,0 +1,195 @@
/**
* 中间预览 + 时间线面板
* 视频/封面预览区 + 片段卡片时间线
*/
import React from 'react';
import { Card, Button, Tag, Typography, Select, Slider } from 'antd';
import {
PlusOutlined,
DeleteOutlined,
DragOutlined,
VideoCameraOutlined,
PictureOutlined,
} from '@ant-design/icons';
import type { TemplateSegment, TemplateMode } from '@/api/editingPlanner';
const { Text } = Typography;
interface TimelinePanelProps {
segments: TemplateSegment[];
currentMode: TemplateMode;
estimatedDuration: number;
onAddSegment: () => void;
onRemoveSegment: (id: string) => void;
onUpdateSegment: (id: string, patch: Partial<TemplateSegment>) => void;
onDragStart: (idx: number) => void;
onDragOver: (e: React.DragEvent, idx: number) => void;
onDragEnd: () => void;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
segments,
currentMode,
estimatedDuration,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
onDragStart,
onDragOver,
onDragEnd,
}) => {
const isOneShot = currentMode === 'one_take';
const isMixedCut = currentMode === 'voice_pip';
const handleDragOver = (e: React.DragEvent, idx: number) => {
onDragOver(e, idx);
};
return (
<div className="ep-center">
{/* 预览区 */}
<div className="ep-preview-row">
{/* 视频预览 */}
<div className="ep-preview-box">
<div className="ep-preview-frame">
<VideoCameraOutlined style={{ fontSize: 40, color: '#bbb' }} />
<Text type="secondary" style={{ marginTop: 8 }}>
视频预览
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16 竖屏
</Text>
</div>
{/* 封面预览 + 方案按钮 */}
<div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}>
<div className="ep-preview-box">
<div className="ep-preview-frame">
<PictureOutlined style={{ fontSize: 40, color: '#bbb' }} />
<Text type="secondary" style={{ marginTop: 8 }}>
封面预览
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
9:16 竖屏
</Text>
</div>
<div className="ep-cover-btns">
<Button size="small" block>
AI 选帧
</Button>
<Button size="small" block>
手动选
</Button>
<Button size="small" block>
上传
</Button>
<Button size="small" block>
AI 重选
</Button>
</div>
</div>
</div>
{/* 时间线 */}
<div className="ep-timeline">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong>
时间线{' '}
<Text type="secondary" style={{ fontWeight: 'normal', fontSize: 12 }}>
(预估总时长:~{estimatedDuration}s)
</Text>
</Text>
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={onAddSegment}
disabled={isOneShot}
>
添加片段
</Button>
</div>
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 8 }}>
{segments.map((seg, idx) => (
<Card
key={seg.id}
size="small"
className="ep-seg-card"
draggable={!isOneShot}
onDragStart={() => onDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={onDragEnd}
style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span
style={{ cursor: isOneShot ? 'default' : 'grab', color: '#999' }}
>
<DragOutlined />
</span>
<Tag color="blue">#{seg.segment_order}</Tag>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => onRemoveSegment(seg.id!)}
disabled={isOneShot}
style={{ marginLeft: 'auto' }}
/>
</div>
{isOneShot ? (
<Text type="secondary" style={{ fontSize: 12 }}>
时长由配音自动决定
</Text>
) : (
<>
<div style={{ marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}>最短 (秒)</Text>
<Slider
min={1}
max={seg.duration_max}
value={seg.duration_min}
onChange={(v) => onUpdateSegment(seg.id!, { duration_min: v })}
/>
</div>
<div>
<Text style={{ fontSize: 12 }}>最长 (秒)</Text>
<Slider
min={seg.duration_min}
max={60}
value={seg.duration_max}
onChange={(v) => onUpdateSegment(seg.id!, { duration_max: v })}
/>
</div>
</>
)}
{isMixedCut && (
<div style={{ marginTop: 8 }}>
<Text style={{ fontSize: 12 }}>素材类型</Text>
<Select
size="small"
value={seg.material_type || '人物'}
onChange={(v) => onUpdateSegment(seg.id!, { material_type: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: '人物', label: '人物' },
{ value: '场景', label: '场景' },
]}
/>
</div>
)}
</Card>
))}
</div>
</div>
</div>
);
};
export default TimelinePanel;
+34 -9
View File
@@ -46,31 +46,31 @@ const GeneratePage: React.FC = () => {
const [generated, setGenerated] = useState(false);
// 获取模板列表
const { data: templates = [] } = useQuery({
const { data: templates = [], isLoading: tplLoading, isError: tplError } = useQuery({
queryKey: ['templates'],
queryFn: getTemplates,
});
// 获取素材库和素材
const { data: libraries = [] } = useQuery({
const { data: libraries = [], isLoading: libLoading, isError: libError } = useQuery({
queryKey: ['asset-libraries'],
queryFn: getAssetLibraries,
});
// 获取标题
const { data: titles = [] } = useQuery({
const { data: titles = [], isLoading: titleLoading, isError: titleError } = useQuery({
queryKey: ['titles'],
queryFn: getTitles,
});
// 获取配音
const { data: voices = [] } = useQuery({
const { data: voices = [], isLoading: voiceLoading, isError: voiceError } = useQuery({
queryKey: ['voices'],
queryFn: getVoices,
});
// 获取所有素材(跨库)
const { data: allAssets = [] } = useQuery({
const { data: allAssets = [], isLoading: assetsLoading, isError: assetsError } = useQuery({
queryKey: ['all-assets'],
queryFn: async () => {
const all: AssetItem[] = [];
@@ -83,6 +83,9 @@ const GeneratePage: React.FC = () => {
enabled: libraries.length > 0,
});
const pageLoading = tplLoading || libLoading || titleLoading || voiceLoading || assetsLoading;
const pageError = tplError || libError || titleError || voiceError || assetsError;
// 创建生成任务
const generateMutation = useMutation({
mutationFn: createGenerationTask,
@@ -91,8 +94,8 @@ const GeneratePage: React.FC = () => {
setGenerated(true);
setGenerating(false);
},
onError: () => {
message.error('生成失败');
onError: (err: any) => {
if (!err?.__msgShown) message.error('生成失败');
setGenerating(false);
},
});
@@ -275,6 +278,28 @@ const GeneratePage: React.FC = () => {
},
];
if (pageLoading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (pageError) {
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<Alert
type="error"
message="加载数据失败"
description="部分数据获取失败,请刷新页面重试。"
showIcon
action={<Button onClick={() => window.location.reload()}>刷新页面</Button>}
/>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<Title level={3} style={{ marginBottom: 24 }}>
@@ -301,14 +326,14 @@ const GeneratePage: React.FC = () => {
}}
>
<Button
disabled={currentStep === 0}
disabled={currentStep === 0 || generating}
onClick={() => setCurrentStep((s) => s - 1)}
>
上一步
</Button>
<Button
type="primary"
disabled={currentStep === steps.length - 1}
disabled={currentStep === steps.length - 1 || generating}
onClick={() => setCurrentStep((s) => s + 1)}
>
下一步
+1 -1
View File
@@ -61,7 +61,7 @@ const TaskHistory: React.FC = () => {
message.success('任务已重新提交');
queryClient.invalidateQueries({ queryKey: ['user-tasks'] });
},
onError: () => message.error('重试失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('重试失败') },
});
/** 过滤后的任务 */
@@ -0,0 +1,70 @@
/* ═══════════════════════════════════════════════════
* 我的模板页面样式
* ═══════════════════════════════════════════════════ */
.mt-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.mt-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.mt-filters {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.mt-content {
min-height: 400px;
}
/* 卡片样式 */
.mt-card {
height: 100%;
border-radius: 12px;
transition: box-shadow 0.2s, transform 0.2s;
}
.mt-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.mt-card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.mt-card-meta {
margin-bottom: 8px;
}
.mt-card-tags {
margin-bottom: 8px;
}
.mt-card-config {
margin-top: 4px;
}
/* 响应式 */
@media (max-width: 768px) {
.mt-head {
flex-direction: column;
gap: 12px;
}
.mt-filters {
flex-direction: column;
}
}
@@ -0,0 +1,249 @@
/**
* 我的模板页面
* 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Typography,
Card,
Input,
Select,
Tag,
Button,
Space,
Empty,
Spin,
Tooltip,
message,
Popconfirm,
Row,
Col,
} from 'antd';
import {
SearchOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
VideoCameraOutlined,
AppstoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
getEditingTemplates,
getTemplateCategories,
deleteEditingTemplate,
createEditingTemplate,
MODE_LABELS,
MODE_COLORS,
type EditingTemplate,
type TemplateMode,
} from '@/api/editingPlanner';
import './MyTemplates.css';
const { Title, Text } = Typography;
const MyTemplates: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState('');
const [filterCategory, setFilterCategory] = useState('');
/* ── 数据查询 ── */
const { data: templates = [], isLoading } = useQuery({
queryKey: ['editing-templates', filterCategory, searchText],
queryFn: () =>
getEditingTemplates({
category: filterCategory || undefined,
tag: searchText || undefined,
}),
});
const { data: categories = [] } = useQuery({
queryKey: ['template-categories'],
queryFn: getTemplateCategories,
});
/* ── Mutations ── */
const deleteMutation = useMutation({
mutationFn: deleteEditingTemplate,
onSuccess: () => {
message.success('模板已删除');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('删除失败');
},
});
const copyMutation = useMutation({
mutationFn: (tpl: EditingTemplate) =>
createEditingTemplate({
name: `${tpl.name}(副本)`,
mode: tpl.mode,
category: tpl.category,
tags: tpl.tags,
title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config,
estimated_duration: tpl.estimated_duration ?? Math.round(tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0)),
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}),
onSuccess: () => {
message.success('模板已复制');
queryClient.invalidateQueries({ queryKey: ['editing-templates'] });
},
onError: (err: any) => {
if (!err?.__msgShown) message.error('复制失败');
},
});
/* ── 操作 ── */
const handleEdit = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}`);
};
const handleGenerate = (tpl: EditingTemplate) => {
navigate(`/editing-planner?template=${tpl.id}&generate=1`);
};
const handleCopy = (tpl: EditingTemplate) => {
copyMutation.mutate(tpl);
};
const handleDelete = (id: string) => {
deleteMutation.mutate(id);
};
return (
<div className="mt-page">
{/* 页面头部 */}
<div className="mt-head">
<div>
<Title level={4} style={{ margin: 0 }}>
<AppstoreOutlined style={{ marginRight: 8 }} />
我的模板
</Title>
<Text type="secondary">管理你创建的剪辑模板,快速复用生成视频</Text>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/editing-planner')}
>
新建模板
</Button>
</div>
{/* 筛选栏 */}
<div className="mt-filters">
<Input
prefix={<SearchOutlined />}
placeholder="搜索模板名称或标签..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 280 }}
/>
<Select
placeholder="按分类筛选"
value={filterCategory || undefined}
onChange={(v) => setFilterCategory(v || '')}
allowClear
style={{ width: 160 }}
options={categories.map((c) => ({ value: c.name, label: c.name }))}
/>
</div>
{/* 模板卡片列表 */}
<div className="mt-content">
{isLoading ? (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
) : templates.length === 0 ? (
<Empty
description="还没有模板,点击右上角「新建模板」开始创建"
style={{ padding: 80 }}
>
<Button type="primary" onClick={() => navigate('/editing-planner')}>
新建模板
</Button>
</Empty>
) : (
<Row gutter={[16, 16]}>
{templates.map((tpl) => (
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
<Card
className="mt-card"
hoverable
actions={[
<Tooltip title="编辑" key="edit">
<EditOutlined onClick={() => handleEdit(tpl)} />
</Tooltip>,
<Tooltip title="复制" key="copy">
<CopyOutlined onClick={() => handleCopy(tpl)} />
</Tooltip>,
<Tooltip title="使用模板生成" key="generate">
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} />
</Tooltip>,
<Popconfirm
key="delete"
title="确定删除此模板?"
onConfirm={() => handleDelete(tpl.id)}
okText="删除"
cancelText="取消"
>
<Tooltip title="删除">
<DeleteOutlined style={{ color: '#ff4d4f' }} />
</Tooltip>
</Popconfirm>,
]}
>
<div className="mt-card-head">
<Text strong ellipsis style={{ fontSize: 15 }}>
{tpl.name}
</Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'default'}>{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}</Tag>
</div>
<div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
</Text>
{tpl.category && (
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>
)}
</div>
{tpl.tags.length > 0 && (
<div className="mt-card-tags">
{tpl.tags.map((tag) => (
<Tag key={tag} style={{ fontSize: 11 }}>
{tag}
</Tag>
))}
</div>
)}
<div className="mt-card-config">
<Space size={4} wrap>
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
</Space>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
</div>
);
};
export default MyTemplates;
@@ -64,6 +64,7 @@ const ProductLibrary: React.FC = () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['products'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
});
// 下载
@@ -71,8 +72,8 @@ const ProductLibrary: React.FC = () => {
try {
const { url } = await getProductDownloadUrl(productId);
window.open(url, '_blank');
} catch {
message.error('获取下载链接失败');
} catch (err: any) {
if (!err?.__msgShown) message.error('获取下载链接失败');
}
};
+9 -4
View File
@@ -18,6 +18,7 @@ const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [autoRenewChecked, setAutoRenewChecked] = useState(false);
const [autoRenewLoading, setAutoRenewLoading] = useState(false);
useEffect(() => {
loadData();
@@ -28,14 +29,15 @@ const Billing: React.FC = () => {
const data = await getCurrentSubscription();
setSubscription(data);
setAutoRenewChecked(data.auto_renew);
} catch {
message.error('加载订阅数据失败');
} catch (err: any) {
if (!err?.__msgShown) message.error('加载订阅数据失败');
} finally {
setLoading(false);
}
};
const handleToggleAutoRenew = async (checked: boolean) => {
setAutoRenewLoading(true);
try {
const res = await toggleAutoRenew(checked);
message.success(res.message);
@@ -43,8 +45,10 @@ const Billing: React.FC = () => {
if (subscription) {
setSubscription({ ...subscription, auto_renew: checked });
}
} catch {
message.error('操作失败');
} catch (err: any) {
if (!err?.__msgShown) message.error('操作失败');
} finally {
setAutoRenewLoading(false);
}
};
@@ -94,6 +98,7 @@ const Billing: React.FC = () => {
<Switch
checked={autoRenewChecked}
onChange={handleToggleAutoRenew}
loading={autoRenewLoading}
checkedChildren="开"
unCheckedChildren="关"
/>
@@ -32,8 +32,8 @@ const UpgradeSubscription: React.FC = () => {
const data = await getCurrentSubscription();
setSubscription(data);
setSelectedPlan(data.plan_id);
} catch {
message.error('获取订阅信息失败');
} catch (err: any) {
if (!err?.__msgShown) message.error('获取订阅信息失败');
} finally {
setLoading(false);
}
@@ -64,8 +64,8 @@ const UpgradeSubscription: React.FC = () => {
} else {
message.error(res.message);
}
} catch {
message.error('套餐变更失败,请重试');
} catch (err: any) {
if (!err?.__msgShown) message.error('套餐变更失败,请重试');
} finally {
setSubmitting(false);
}
@@ -80,8 +80,8 @@ const UpgradeSubscription: React.FC = () => {
if (subscription) {
setSubscription({ ...subscription, auto_renew: enabled });
}
} catch {
message.error('操作失败');
} catch (err: any) {
if (!err?.__msgShown) message.error('操作失败');
}
};
@@ -97,8 +97,8 @@ const UpgradeSubscription: React.FC = () => {
const res = await cancelSubscription();
message.success(res.message);
navigate('/subscription');
} catch {
message.error('取消失败');
} catch (err: any) {
if (!err?.__msgShown) message.error('取消失败');
}
},
});
@@ -18,6 +18,7 @@ import {
Select,
Modal,
Image,
message,
} from 'antd';
import {
PlayCircleOutlined,
@@ -50,9 +51,11 @@ const TemplateLibrary: React.FC = () => {
// 收藏/取消收藏
const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate,
onSuccess: () => {
onSuccess: (data) => {
message.success(data.is_favorite ? '已收藏' : '已取消收藏');
queryClient.invalidateQueries({ queryKey: ['templates'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('操作失败') },
});
/** 提取所有分类 */
@@ -155,6 +158,7 @@ const TemplateLibrary: React.FC = () => {
<StarOutlined />
)
}
disabled={favMutation.isPending}
onClick={() => favMutation.mutate(template.id)}
/>,
]}
+19 -4
View File
@@ -62,7 +62,7 @@ const TitleLibrary: React.FC = () => {
resetForm();
queryClient.invalidateQueries({ queryKey: ['titles'] });
},
onError: () => message.error('创建失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('创建失败') },
});
// 更新标题
@@ -75,7 +75,7 @@ const TitleLibrary: React.FC = () => {
resetForm();
queryClient.invalidateQueries({ queryKey: ['titles'] });
},
onError: () => message.error('更新失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('更新失败') },
});
// 删除标题
@@ -85,6 +85,7 @@ const TitleLibrary: React.FC = () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['titles'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
});
// 批量导入
@@ -97,7 +98,7 @@ const TitleLibrary: React.FC = () => {
setImportText('');
queryClient.invalidateQueries({ queryKey: ['titles'] });
},
onError: () => message.error('导入失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('导入失败') },
});
const resetForm = () => {
@@ -228,7 +229,21 @@ const TitleLibrary: React.FC = () => {
<Spin size="large" />
</div>
) : filteredTitles.length === 0 ? (
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'} />
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'}>
{!searchText && (
<Space>
<Button type="primary" onClick={openCreate}>
新建标题
</Button>
<Button
icon={<ImportOutlined />}
onClick={() => setImportModalOpen(true)}
>
批量导入
</Button>
</Space>
)}
</Empty>
) : (
<Table
columns={columns}
+4 -3
View File
@@ -80,7 +80,7 @@ const VoiceLibrary: React.FC = () => {
resetForm();
queryClient.invalidateQueries({ queryKey: ['voices'] });
},
onError: () => message.error('创建失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('创建失败') },
});
// 更新配音
@@ -93,7 +93,7 @@ const VoiceLibrary: React.FC = () => {
resetForm();
queryClient.invalidateQueries({ queryKey: ['voices'] });
},
onError: () => message.error('更新失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('更新失败') },
});
// 删除配音
@@ -103,6 +103,7 @@ const VoiceLibrary: React.FC = () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['voices'] });
},
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
});
// AI 生成配音
@@ -114,7 +115,7 @@ const VoiceLibrary: React.FC = () => {
setAiText('');
queryClient.invalidateQueries({ queryKey: ['voices'] });
},
onError: () => message.error('AI 生成失败'),
onError: (err: any) => { if (!err?.__msgShown) message.error('AI 生成失败') },
});
const resetForm = () => {
+8
View File
@@ -85,6 +85,14 @@ export const router = createBrowserRouter([
path: 'products',
lazy: () => import('@/pages/products/ProductLibrary').then(m => ({ Component: m.default })),
},
{
path: 'editing-planner',
lazy: () => import('@/pages/editing-planner/EditingPlanner').then(m => ({ Component: m.default })),
},
{
path: 'my-templates',
lazy: () => import('@/pages/my-templates/MyTemplates').then(m => ({ Component: m.default })),
},
{
path: 'duplication',
lazy: () => import('@/pages/duplication/DuplicationUpload').then(m => ({ Component: m.default })),
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ server {
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
client_max_body_size 800m;
client_max_body_size 2g;
location /api/ {
proxy_pass http://xiaoxia-api-production:8000/api/;
+3 -3
View File
@@ -28,7 +28,7 @@ server {
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 800m;
client_max_body_size 2g;
location / {
proxy_pass http://127.0.0.1:3002/;
@@ -51,7 +51,7 @@ server {
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 800m;
client_max_body_size 2g;
location /api/ {
proxy_pass http://127.0.0.1:8001;
@@ -112,7 +112,7 @@ server {
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 800m;
client_max_body_size 2g;
location / {
proxy_pass http://127.0.0.1:3000/;
@@ -16,7 +16,7 @@ class InMemoryAssetLibraryRepository:
def get(self, library_id: str) -> AssetLibrary | None:
return self._libraries.get(library_id)
def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
def find_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
items = [library for library in self._libraries.values() if library.project_id == project_id]
if kind is not None:
items = [library for library in items if library.kind == kind]
@@ -11,7 +11,7 @@ class SQLAlchemyAssetRepository:
def __init__(self, session: Session):
self.session = session
async def find_by_library(
def find_by_library(
self,
library_id: str,
skip: int = 0,
@@ -22,7 +22,7 @@ class SQLAlchemyAssetRepository:
).offset(skip).limit(limit).all()
return [self._to_domain(model) for model in models]
async def find_by_project(
def find_by_project(
self,
project_id: str,
skip: int = 0,
@@ -33,7 +33,7 @@ class SQLAlchemyAssetRepository:
).offset(skip).limit(limit).all()
return [self._to_domain(model) for model in models]
async def find_by_id(self, asset_id: str) -> Asset | None:
def find_by_id(self, asset_id: str) -> Asset | None:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model is None:
return None
@@ -42,7 +42,7 @@ class SQLAlchemyAssetRepository:
def get(self, asset_id: str) -> Asset | None:
return self.find_by_id(asset_id)
async def create(self, asset: Asset) -> Asset:
def create(self, asset: Asset) -> Asset:
now = datetime.now(timezone.utc)
model = AssetModel(
id=asset.id,
@@ -70,7 +70,7 @@ class SQLAlchemyAssetRepository:
self.session.commit()
return asset
async def update(self, asset: Asset) -> Asset:
def update(self, asset: Asset) -> Asset:
model = self.session.query(AssetModel).filter(AssetModel.id == asset.id).first()
if model is None:
raise ValueError(f"Asset {asset.id} not found")
@@ -92,7 +92,7 @@ class SQLAlchemyAssetRepository:
self.session.commit()
return asset
async def delete(self, asset_id: str) -> bool:
def delete(self, asset_id: str) -> bool:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model:
self.session.delete(model)
@@ -100,11 +100,27 @@ class SQLAlchemyAssetRepository:
return True
return False
async def count_by_project(self, project_id: str) -> int:
def count_by_project(self, project_id: str) -> int:
return self.session.query(AssetModel).filter(
AssetModel.project_id == project_id
).count()
def count_by_project_ids(self, project_ids: list[str]) -> int:
if not project_ids:
return 0
return self.session.query(AssetModel).filter(
AssetModel.project_id.in_(project_ids)
).count()
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
if not project_ids:
return 0
from sqlalchemy import func
result = self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0)).filter(
AssetModel.project_id.in_(project_ids)
).scalar()
return int(result or 0)
def _to_domain(self, model: AssetModel) -> Asset:
metadata = {}
if model.classification_result:
@@ -4,6 +4,29 @@ from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
from packages.domain import GenerationTask
def _to_domain(model: GenerationTaskModel) -> GenerationTask:
"""Convert ORM model to domain entity."""
return GenerationTask(
id=model.id,
project_id=model.project_id,
strategy_id=model.strategy_id,
asset_library_id=model.asset_library_id,
voice_library_id=model.voice_library_id,
template_id=model.template_id,
asset_ids=list(model.asset_ids or []),
title_ids=list(model.title_ids or []),
voice_ids=list(model.voice_ids or []),
status=model.status,
progress=model.progress,
result_count=int(model.result_count or 0),
error_message=model.error_message,
started_at=model.started_at,
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
created_at=model.created_at,
)
class SQLAlchemyGenerationTaskRepository:
def __init__(self, session: Session):
self.session = session
@@ -15,6 +38,10 @@ class SQLAlchemyGenerationTaskRepository:
strategy_id=task.strategy_id,
asset_library_id=task.asset_library_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -32,31 +59,55 @@ class SQLAlchemyGenerationTaskRepository:
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
if model is None:
return None
return GenerationTask(
id=model.id,
project_id=model.project_id,
strategy_id=model.strategy_id,
asset_library_id=model.asset_library_id,
voice_library_id=model.voice_library_id,
status=model.status,
progress=model.progress,
result_count=int(model.result_count or 0),
error_message=model.error_message,
started_at=model.started_at,
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
created_at=model.created_at,
)
return _to_domain(model)
def list_by_project(self, project_id: str) -> list[GenerationTask]:
models = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.project_id == project_id).all()
return [self.get(model.id) for model in models if self.get(model.id) is not None]
models = (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.project_id == project_id)
.order_by(GenerationTaskModel.created_at.desc())
.all()
)
return [_to_domain(m) for m in models]
def list_by_user(self, user_id: str) -> list[GenerationTask]:
models = (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.created_by_user_id == user_id)
.order_by(GenerationTaskModel.created_at.desc())
.all()
)
return [_to_domain(m) for m in models]
def count_by_user(self, user_id: str) -> int:
return (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.created_by_user_id == user_id)
.count()
)
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
models = (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.created_by_user_id == user_id)
.order_by(GenerationTaskModel.created_at.desc())
.limit(limit)
.all()
)
return [_to_domain(m) for m in models]
def update(self, task: GenerationTask) -> GenerationTask:
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first()
if model is None:
raise ValueError(f"GenerationTask {task.id} not found")
model.project_id = task.project_id
model.asset_library_id = task.asset_library_id
model.strategy_id = task.strategy_id
model.voice_library_id = task.voice_library_id
model.template_id = task.template_id
model.asset_ids = task.asset_ids
model.title_ids = task.title_ids
model.voice_ids = task.voice_ids
model.status = task.status
model.progress = task.progress
model.result_count = task.result_count
+73 -5
View File
@@ -65,8 +65,6 @@ class AssetModel(Base):
asset_library_id = Column(String(36), nullable=False, index=True)
name = Column(String(500), nullable=False)
file_type = Column(String(20), nullable=False, index=True)
# storage_key: OSS 对象键(相对路径),用于内部存储和操作
storage_key = Column(String(255), nullable=False)
# file_size: 文件大小(字节),使用 Integer 类型以确保精确性
file_size = Column(Integer, nullable=False)
# file_url: 完整可访问的 URL,用于客户端直接访问文件
@@ -139,10 +137,14 @@ class GenerationTaskModel(Base):
__tablename__ = "generation_tasks"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), nullable=False, default="", index=True)
strategy_id = Column(String(32), nullable=False, default="")
asset_library_id = Column(String(32), nullable=False, index=True)
asset_library_id = Column(String(32), nullable=False, default="", index=True)
voice_library_id = Column(String(32), nullable=False, default="")
template_id = Column(String(36), nullable=False, default="", index=True)
asset_ids = Column(JSON, nullable=False, default=list)
title_ids = Column(JSON, nullable=False, default=list)
voice_ids = Column(JSON, nullable=False, default=list)
editing_mode = Column(String(20), nullable=False, default="one_take", index=True) # 剪辑模式: one_take, pip, voice_over, voice_pip
status = Column(String(20), nullable=False, default="pending", index=True)
progress = Column(Float, nullable=False, default=0.0)
@@ -150,7 +152,7 @@ class GenerationTaskModel(Base):
error_message = Column(Text, nullable=False, default="")
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="")
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -254,3 +256,69 @@ class DuplicationSegmentModel(Base):
matched_end = Column(Float, nullable=False)
similarity = Column(Float, nullable=False)
class RecipeModel(Base):
__tablename__ = "recipes"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=False, default="")
template_id = Column(String(36), nullable=False, default="")
generation_params = Column(JSON, nullable=False, default=dict)
is_active = Column(Boolean, nullable=False, default=True)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class RecipeItemModel(Base):
__tablename__ = "recipe_items"
id = Column(String(36), primary_key=True)
recipe_id = Column(String(36), nullable=False, index=True)
item_type = Column(String(20), nullable=False)
item_id = Column(String(36), nullable=False)
position = Column(Integer, nullable=False, default=0)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
class TemplateModel(Base):
__tablename__ = "templates"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(200), nullable=False)
mode = Column(String(30), nullable=False, index=True) # EditingMode 枚举值: pip / voice_pip / one_take / voice_over
category = Column(String(100), nullable=False, default="")
tags = Column(JSON, nullable=False, default=list)
title_config = Column(JSON, nullable=False, default=dict)
subtitle_config = Column(JSON, nullable=False, default=dict)
bgm_config = Column(JSON, nullable=False, default=dict)
estimated_duration = Column(Float, nullable=False, default=0.0)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class TemplateSegmentModel(Base):
__tablename__ = "template_segments"
id = Column(String(36), primary_key=True)
template_id = Column(String(36), nullable=False, index=True)
segment_order = Column(Integer, nullable=False)
duration_min = Column(Float, nullable=False)
duration_max = Column(Float, nullable=False)
material_type = Column(String(20), nullable=True) # 仅 voice_over 模式: 人物/场景
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class TemplateCategoryModel(Base):
__tablename__ = "template_categories"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -54,12 +54,13 @@ class SQLAlchemyProjectRepository:
def find_accessible_projects(self, user_id: str) -> list[Project]:
"""查找用户可访问的所有项目(自己拥有的 + 被共享的)"""
from sqlalchemy import or_
from sqlalchemy import or_, cast
from sqlalchemy.dialects.postgresql import JSONB
models = self.session.query(ProjectModel).filter(
or_(
ProjectModel.owner_user_id == user_id,
ProjectModel.shared_users.contains([user_id])
cast(ProjectModel.shared_users, JSONB).contains([user_id])
)
).all()
return [self._to_entity(model) for model in models]
@@ -0,0 +1,179 @@
"""SQLAlchemy implementation of RecipeRepository."""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import RecipeModel, RecipeItemModel
from packages.domain.recipe import Recipe, RecipeItem
class SQLAlchemyRecipeRepository:
"""SQLAlchemy 配方仓储"""
def __init__(self, session: Session) -> None:
self.session = session
def list_by_user(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Recipe]:
models = (
self.session.query(RecipeModel)
.filter(
RecipeModel.user_id == user_id,
RecipeModel.is_active == True,
)
.order_by(RecipeModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
recipes = [self._model_to_entity(m) for m in models]
# Load items for each recipe
for recipe in recipes:
recipe.items = self.list_items(recipe.id)
return recipes
def get(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
model = (
self.session.query(RecipeModel)
.filter(
RecipeModel.id == recipe_id,
RecipeModel.user_id == user_id,
)
.first()
)
if model is None:
return None
recipe = self._model_to_entity(model)
recipe.items = self.list_items(recipe.id)
return recipe
def create(self, recipe: Recipe) -> Recipe:
model = RecipeModel(
id=recipe.id,
user_id=recipe.user_id,
name=recipe.name,
description=recipe.description,
template_id=recipe.template_id,
generation_params=recipe.generation_params,
is_active=recipe.is_active,
extra_meta=recipe.metadata_,
)
self.session.add(model)
self.session.commit()
self.session.refresh(model)
result = self._model_to_entity(model)
result.items = recipe.items
return result
def update(self, recipe: Recipe) -> Recipe:
model = (
self.session.query(RecipeModel)
.filter(
RecipeModel.id == recipe.id,
RecipeModel.user_id == recipe.user_id,
)
.first()
)
if model is None:
raise ValueError(f"Recipe {recipe.id} not found")
model.name = recipe.name
model.description = recipe.description
model.template_id = recipe.template_id
model.generation_params = recipe.generation_params
model.is_active = recipe.is_active
model.extra_meta = recipe.metadata_
self.session.commit()
self.session.refresh(model)
result = self._model_to_entity(model)
result.items = recipe.items
return result
def delete(self, recipe_id: str, user_id: str) -> bool:
model = (
self.session.query(RecipeModel)
.filter(
RecipeModel.id == recipe_id,
RecipeModel.user_id == user_id,
)
.first()
)
if model is None:
return False
model.is_active = False
self.session.commit()
return True
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
return (
self.session.query(RecipeModel)
.filter(
RecipeModel.user_id == user_id,
RecipeModel.is_active == is_active,
)
.count()
)
def list_items(self, recipe_id: str) -> List[RecipeItem]:
models = (
self.session.query(RecipeItemModel)
.filter(RecipeItemModel.recipe_id == recipe_id)
.order_by(RecipeItemModel.position)
.all()
)
return [self._item_model_to_entity(m) for m in models]
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]:
for item in items:
model = RecipeItemModel(
id=item.id,
recipe_id=item.recipe_id,
item_type=item.item_type,
item_id=item.item_id,
position=item.position,
extra_meta=item.metadata_,
)
self.session.add(model)
self.session.commit()
return items
def delete_items_by_recipe(self, recipe_id: str) -> int:
count = (
self.session.query(RecipeItemModel)
.filter(RecipeItemModel.recipe_id == recipe_id)
.delete()
)
self.session.commit()
return count
@staticmethod
def _model_to_entity(model: RecipeModel) -> Recipe:
return Recipe(
id=model.id,
user_id=model.user_id,
name=model.name,
description=model.description or "",
template_id=model.template_id or "",
generation_params=model.generation_params or {},
is_active=model.is_active,
metadata_=model.extra_meta or {},
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _item_model_to_entity(model: RecipeItemModel) -> RecipeItem:
return RecipeItem(
id=model.id,
recipe_id=model.recipe_id,
item_type=model.item_type,
item_id=model.item_id,
position=model.position or 0,
metadata_=model.extra_meta or {},
)
@@ -0,0 +1,278 @@
"""SQLAlchemy implementation of TemplateRepository."""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
TemplateCategoryModel,
TemplateModel,
TemplateSegmentModel,
)
from packages.domain.template import Template, TemplateCategory, TemplateSegment
class SQLAlchemyTemplateRepository:
"""SQLAlchemy 剪辑计划模板仓储."""
def __init__(self, session: Session) -> None:
self.session = session
# ── Template CRUD ──
def list_by_user(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Template]:
models = (
self.session.query(TemplateModel)
.filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active == True,
)
.order_by(TemplateModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
templates = [self._model_to_entity(m) for m in models]
# 批量加载所有 segments,避免 N+1 查询
if templates:
template_ids = [t.id for t in templates]
seg_models = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id.in_(template_ids))
.order_by(TemplateSegmentModel.segment_order)
.all()
)
# 按 template_id 分组
seg_map: dict[str, list] = {}
for sm in seg_models:
seg_map.setdefault(sm.template_id, []).append(
self._segment_model_to_entity(sm),
)
for t in templates:
t.segments = seg_map.get(t.id, [])
return templates
def get(self, template_id: str, user_id: str) -> Optional[Template]:
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
)
.first()
)
if model is None:
return None
template = self._model_to_entity(model)
template.segments = self.list_segments(template.id)
return template
def create(self, template: Template) -> Template:
model = TemplateModel(
id=template.id,
user_id=template.user_id,
name=template.name,
mode=template.mode,
category=template.category,
tags=template.tags,
title_config=template.title_config,
subtitle_config=template.subtitle_config,
bgm_config=template.bgm_config,
estimated_duration=template.estimated_duration,
is_active=template.is_active,
)
self.session.add(model)
# flush 而非 commit,让 create + create_segments 在同一事务中提交
self.session.flush()
self.session.refresh(model)
result = self._model_to_entity(model)
result.segments = template.segments
return result
def update(self, template: Template) -> Template:
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template.id,
TemplateModel.user_id == template.user_id,
)
.first()
)
if model is None:
raise ValueError(f"Template {template.id} not found")
model.name = template.name
model.mode = template.mode
model.category = template.category
model.tags = template.tags
model.title_config = template.title_config
model.subtitle_config = template.subtitle_config
model.bgm_config = template.bgm_config
model.estimated_duration = template.estimated_duration
model.is_active = template.is_active
self.session.commit()
self.session.refresh(model)
result = self._model_to_entity(model)
result.segments = template.segments
return result
def delete(self, template_id: str, user_id: str) -> bool:
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
)
.first()
)
if model is None:
return False
model.is_active = False
# 级联清理关联的 segments,避免孤儿数据
self.session.query(TemplateSegmentModel).filter(
TemplateSegmentModel.template_id == template_id,
).delete(synchronize_session=False)
self.session.commit()
return True
def count_by_user(self, user_id: str) -> int:
return (
self.session.query(TemplateModel)
.filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active == True,
)
.count()
)
# ── Segments ──
def list_segments(self, template_id: str) -> List[TemplateSegment]:
models = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id == template_id)
.order_by(TemplateSegmentModel.segment_order)
.all()
)
return [self._segment_model_to_entity(m) for m in models]
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
for seg in segments:
model = TemplateSegmentModel(
id=seg.id,
template_id=seg.template_id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
self.session.add(model)
self.session.commit()
return segments
def delete_segments_by_template(self, template_id: str) -> int:
count = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id == template_id)
.delete()
)
self.session.commit()
return count
# ── Categories ──
def list_categories(self, user_id: str) -> List[TemplateCategory]:
models = (
self.session.query(TemplateCategoryModel)
.filter(TemplateCategoryModel.user_id == user_id)
.order_by(TemplateCategoryModel.created_at)
.all()
)
return [self._category_model_to_entity(m) for m in models]
def create_category(self, category: TemplateCategory) -> TemplateCategory:
model = TemplateCategoryModel(
id=category.id,
user_id=category.user_id,
name=category.name,
)
self.session.add(model)
self.session.commit()
self.session.refresh(model)
return self._category_model_to_entity(model)
def get_category(self, category_id: str, user_id: str) -> Optional[TemplateCategory]:
model = (
self.session.query(TemplateCategoryModel)
.filter(
TemplateCategoryModel.id == category_id,
TemplateCategoryModel.user_id == user_id,
)
.first()
)
if model is None:
return None
return self._category_model_to_entity(model)
def delete_category(self, category_id: str, user_id: str) -> bool:
model = (
self.session.query(TemplateCategoryModel)
.filter(
TemplateCategoryModel.id == category_id,
TemplateCategoryModel.user_id == user_id,
)
.first()
)
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
# ── Mapping helpers ──
@staticmethod
def _model_to_entity(model: TemplateModel) -> Template:
return Template(
id=model.id,
user_id=model.user_id,
name=model.name,
mode=model.mode,
category=model.category or "",
tags=model.tags or [],
title_config=model.title_config or {},
subtitle_config=model.subtitle_config or {},
bgm_config=model.bgm_config or {},
estimated_duration=model.estimated_duration or 0.0,
is_active=model.is_active,
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _segment_model_to_entity(model: TemplateSegmentModel) -> TemplateSegment:
return TemplateSegment(
id=model.id,
template_id=model.template_id,
segment_order=model.segment_order,
duration_min=model.duration_min,
duration_max=model.duration_max,
material_type=model.material_type,
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
return TemplateCategory(
id=model.id,
user_id=model.user_id,
name=model.name,
created_at=model.created_at,
)
+11 -5
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from uuid import uuid4
from packages.domain import GenerationTask
@@ -9,11 +9,14 @@ from packages.ports.generation_task_repository import GenerationTaskRepository
@dataclass(slots=True)
class CreateGenerationTaskCommand:
project_id: str
asset_library_id: str
project_id: str = ""
asset_library_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
edit_plan_id: str = ""
template_id: str = ""
asset_ids: list[str] = field(default_factory=list)
title_ids: list[str] = field(default_factory=list)
voice_ids: list[str] = field(default_factory=list)
created_by_user_id: str = ""
@@ -28,7 +31,10 @@ class CreateGenerationTaskUseCase:
asset_library_id=command.asset_library_id,
strategy_id=command.strategy_id,
voice_library_id=command.voice_library_id,
edit_plan_id=command.edit_plan_id,
template_id=command.template_id,
asset_ids=command.asset_ids,
title_ids=command.title_ids,
voice_ids=command.voice_ids,
status="pending",
progress=0.0,
result_count=0,
+36
View File
@@ -0,0 +1,36 @@
"""Recipe commands."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class RecipeItemCommand:
item_type: str
item_id: str
position: int = 0
metadata_: dict = field(default_factory=dict)
@dataclass
class CreateRecipeCommand:
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: dict = field(default_factory=dict)
items: List[RecipeItemCommand] = field(default_factory=list)
metadata_: dict = field(default_factory=dict)
@dataclass
class UpdateRecipeCommand:
recipe_id: str
user_id: str
name: Optional[str] = None
description: Optional[str] = None
template_id: Optional[str] = None
generation_params: Optional[dict] = None
items: Optional[List[RecipeItemCommand]] = None
metadata_: Optional[dict] = None
+184
View File
@@ -0,0 +1,184 @@
"""Recipe use cases."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
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
from packages.infrastructure.feature_flags import FeatureScope, feature_flags
class NotFoundError(Exception):
pass
class FeatureDisabledError(Exception):
pass
@dataclass
class MissingAssetWarning:
"""使用配方时缺失的素材警告"""
item_type: str
item_id: str
position: int
class CreateRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, command: CreateRecipeCommand) -> Recipe:
recipe_id = uuid.uuid4().hex
recipe = Recipe(
id=recipe_id,
user_id=command.user_id,
name=command.name,
description=command.description,
template_id=command.template_id,
generation_params=command.generation_params,
metadata_=command.metadata_,
)
recipe = self.repository.create(recipe)
# Create items
if command.items:
items = [
RecipeItem(
id=uuid.uuid4().hex,
recipe_id=recipe.id,
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in command.items
]
self.repository.create_items(items)
recipe.items = items
return recipe
class ListRecipesUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Recipe]:
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
class GetRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
return self.repository.get(recipe_id, user_id)
class UpdateRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, command: UpdateRecipeCommand) -> Recipe:
existing = self.repository.get(command.recipe_id, command.user_id)
if existing is None:
raise NotFoundError(f"Recipe {command.recipe_id} not found")
if command.name is not None:
existing.name = command.name
if command.description is not None:
existing.description = command.description
if command.template_id is not None:
existing.template_id = command.template_id
if command.generation_params is not None:
existing.generation_params = command.generation_params
if command.metadata_ is not None:
existing.metadata_ = command.metadata_
self.repository.update(existing)
# Replace items if provided
if command.items is not None:
self.repository.delete_items_by_recipe(existing.id)
items = [
RecipeItem(
id=uuid.uuid4().hex,
recipe_id=existing.id,
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in command.items
]
self.repository.create_items(items)
existing.items = items
else:
existing.items = self.repository.list_items(existing.id)
return existing
class DeleteRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, recipe_id: str, user_id: str) -> bool:
return self.repository.delete(recipe_id, user_id)
@dataclass
class UseRecipeResult:
"""使用配方的结果"""
recipe: Recipe
warnings: List[MissingAssetWarning]
class UseRecipeUseCase:
"""使用配方 — 校验 Feature Flag + 检查素材可用性"""
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(
self,
recipe_id: str,
user_id: str,
*,
user_plan: str = "free",
) -> UseRecipeResult:
# 1. 校验 Feature Flag(仅 basic/premium 可用)
if not feature_flags.is_enabled(
FeatureScope.RECIPE_REUSE,
user_plan=user_plan,
):
raise FeatureDisabledError(
"配方复用功能仅对基础版和高级版用户开放"
)
# 2. 获取配方
recipe = self.repository.get(recipe_id, user_id)
if recipe is None:
raise NotFoundError(f"Recipe {recipe_id} not found")
# 3. 校验引用的素材/标题/配音是否仍存在
warnings: List[MissingAssetWarning] = []
# Note: 实际项目中这里需要注入 asset/title/voice repository
# 来校验每个 item 是否仍然存在。当前版本返回空警告列表,
# 由调用方(路由层)决定是否传入额外的校验逻辑。
return UseRecipeResult(recipe=recipe, warnings=warnings)
+55
View File
@@ -0,0 +1,55 @@
"""Template commands."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class SegmentCommand:
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None
@dataclass
class CreateTemplateCommand:
user_id: str
name: str
mode: str
category: str = ""
tags: List[str] = field(default_factory=list)
title_config: dict = field(default_factory=dict)
subtitle_config: dict = field(default_factory=dict)
bgm_config: dict = field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentCommand] = field(default_factory=list)
@dataclass
class UpdateTemplateCommand:
template_id: str
user_id: str
name: Optional[str] = None
mode: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
title_config: Optional[dict] = None
subtitle_config: Optional[dict] = None
bgm_config: Optional[dict] = None
estimated_duration: Optional[float] = None
segments: Optional[List[SegmentCommand]] = None
@dataclass
class CreateCategoryCommand:
user_id: str
name: str
@dataclass
class ValidateTemplateCommand:
template_id: str
user_id: str
voiceover_duration: Optional[float] = None # 配音实际时长(用于偏差校验)
+256
View File
@@ -0,0 +1,256 @@
"""Template use cases."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import List, Optional
from packages.application.template.commands import (
CreateCategoryCommand,
CreateTemplateCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.domain.editing_mode import EditingMode
from packages.domain.template import Template, TemplateCategory, TemplateSegment
from packages.ports.template_repository import TemplateRepositoryPort
class NotFoundError(Exception):
pass
class ValidationError(Exception):
"""业务规则校验失败."""
pass
VALID_MODES = {m.value for m in EditingMode}
VALID_MATERIAL_TYPES = {"人物", "场景"}
@dataclass
class GenerateWarning:
"""生成时的警告信息."""
code: str # voiceover_duration_mismatch / missing_material_type / ...
message: str
details: dict = field(default_factory=dict)
@dataclass
class ValidateResult:
"""模板校验结果."""
template: Template
warnings: List[GenerateWarning] = field(default_factory=list)
# ── Template CRUD ──
class CreateTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: CreateTemplateCommand) -> Template:
if command.mode not in VALID_MODES:
raise ValidationError(f"无效的剪辑模式: {command.mode},可选值: {VALID_MODES}")
template_id = uuid.uuid4().hex
template = Template(
id=template_id,
user_id=command.user_id,
name=command.name,
mode=command.mode,
category=command.category,
tags=command.tags,
title_config=command.title_config,
subtitle_config=command.subtitle_config,
bgm_config=command.bgm_config,
estimated_duration=command.estimated_duration,
)
template = self.repository.create(template)
# 始终调用 create_segments 以确保在同一事务中提交
segments = [
TemplateSegment(
id=uuid.uuid4().hex,
template_id=template.id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
for seg in command.segments
]
self.repository.create_segments(segments)
template.segments = segments
return template
class ListTemplatesUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Template]:
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
class GetTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, template_id: str, user_id: str) -> Optional[Template]:
return self.repository.get(template_id, user_id)
class UpdateTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: UpdateTemplateCommand) -> Template:
existing = self.repository.get(command.template_id, command.user_id)
if existing is None:
raise NotFoundError(f"Template {command.template_id} not found")
if command.mode is not None and command.mode not in VALID_MODES:
raise ValidationError(f"无效的剪辑模式: {command.mode}")
if command.name is not None:
existing.name = command.name
if command.mode is not None:
existing.mode = command.mode
if command.category is not None:
existing.category = command.category
if command.tags is not None:
existing.tags = command.tags
if command.title_config is not None:
existing.title_config = command.title_config
if command.subtitle_config is not None:
existing.subtitle_config = command.subtitle_config
if command.bgm_config is not None:
existing.bgm_config = command.bgm_config
if command.estimated_duration is not None:
existing.estimated_duration = command.estimated_duration
self.repository.update(existing)
# Replace segments if provided
if command.segments is not None:
self.repository.delete_segments_by_template(existing.id)
segments = [
TemplateSegment(
id=uuid.uuid4().hex,
template_id=existing.id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
for seg in command.segments
]
self.repository.create_segments(segments)
existing.segments = segments
else:
existing.segments = self.repository.list_segments(existing.id)
return existing
class DeleteTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, template_id: str, user_id: str) -> bool:
return self.repository.delete(template_id, user_id)
# ── Validate template ──
class ValidateTemplateUseCase:
"""校验模板业务规则."""
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: ValidateTemplateCommand) -> ValidateResult:
template = self.repository.get(command.template_id, command.user_id)
if template is None:
raise NotFoundError(f"Template {command.template_id} not found")
warnings: List[GenerateWarning] = []
# 业务规则 1: one_take 必须恰好 1 个片段
if template.mode == EditingMode.ONE_TAKE.value:
if len(template.segments) != 1:
raise ValidationError(
f"一镜到底模式必须恰好有 1 个片段,当前有 {len(template.segments)} 个"
)
# 业务规则 2: voice_over 每个片段必须有 material_type
if template.mode == EditingMode.VOICE_OVER.value:
for seg in template.segments:
if not seg.material_type or seg.material_type not in VALID_MATERIAL_TYPES:
raise ValidationError(
f"口播+B-roll模式下每个片段必须指定 material_type(人物/场景),"
f"片段 {seg.segment_order} 的 material_type 无效: {seg.material_type}"
)
# 业务规则 3: 配音时长偏差 ±30% 警告
if command.voiceover_duration is not None and template.estimated_duration > 0:
ratio = command.voiceover_duration / template.estimated_duration
if ratio < 0.7 or ratio > 1.3:
warnings.append(GenerateWarning(
code="voiceover_duration_mismatch",
message=(
f"配音时长 ({command.voiceover_duration:.1f}s) "
f"与预估时长 ({template.estimated_duration:.1f}s) "
f"偏差超过 ±30%,可能影响剪辑效果"
),
details={
"voiceover_duration": command.voiceover_duration,
"estimated_duration": template.estimated_duration,
"ratio": round(ratio, 3),
},
))
return ValidateResult(template=template, warnings=warnings)
# ── Category CRUD ──
class CreateCategoryUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: CreateCategoryCommand) -> TemplateCategory:
category = TemplateCategory(
id=uuid.uuid4().hex,
user_id=command.user_id,
name=command.name,
)
return self.repository.create_category(category)
class ListCategoriesUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, user_id: str) -> List[TemplateCategory]:
return self.repository.list_categories(user_id)
class DeleteCategoryUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, category_id: str, user_id: str) -> bool:
return self.repository.delete_category(category_id, user_id)
-65
View File
@@ -250,71 +250,6 @@ class IngestJob:
)
# 继续读取其他实体定义 - 生成任务、生成视频等
@dataclass(slots=True)
class ClassificationJob:
id: str
project_id: str
asset_id: str
status: str = "pending"
classification: str = ""
confidence: float = 0.0
error_message: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass(slots=True)
class GenerationTask:
id: str
project_id: str
asset_library_id: str
strategy_id: str = ""
voice_library_id: str = ""
edit_plan_id: str = ""
status: str = "pending"
progress: float = 0.0
result_count: int = 0
error_message: str = ""
started_at: datetime | None = None
completed_at: datetime | None = None
created_by_user_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass(slots=True)
class GeneratedVideo:
id: str
project_id: str
generation_task_id: str
name: str
file_url: str
file_size: float = 0.0
duration: float = 0.0
thumbnail_url: str | None = None
width: int = 0
height: int = 0
fps: float = 0.0
status: str = "completed"
review_status: str = "pending_review"
generation_params: dict[str, Any] = field(default_factory=dict)
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime | None = None
@dataclass(slots=True)
class EditTemplate:
id: str
project_id: str
name: str
description: str = ""
target_duration: float = 30.0
clip_count: int = 3
is_active: bool = True
created_by_user_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+16 -4
View File
@@ -21,6 +21,10 @@ class GenerationTask:
asset_library_id: str
strategy_id: str = ""
voice_library_id: str = ""
template_id: str = ""
asset_ids: list[str] = field(default_factory=list)
title_ids: list[str] = field(default_factory=list)
voice_ids: list[str] = field(default_factory=list)
status: GenerationTaskStatus = GenerationTaskStatus.PENDING
progress: float = 0.0
result_count: int = 0
@@ -38,17 +42,25 @@ class GenerationTask:
*,
strategy_id: str = "",
voice_library_id: str = "",
template_id: str = "",
asset_ids: list[str] | None = None,
title_ids: list[str] | None = None,
voice_ids: list[str] | None = None,
created_by_user_id: str = "",
) -> "GenerationTask":
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not asset_library_id.strip():
raise ValueError("asset_library_id 不能为空")
if not project_id.strip() and not template_id.strip():
raise ValueError("project_id 或 template_id 至少需要提供一个")
if not asset_library_id.strip() and not (asset_ids or title_ids or voice_ids):
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
asset_library_id=asset_library_id.strip(),
strategy_id=strategy_id.strip(),
voice_library_id=voice_library_id.strip(),
template_id=template_id.strip(),
asset_ids=list(asset_ids) if asset_ids else [],
title_ids=list(title_ids) if title_ids else [],
voice_ids=list(voice_ids) if voice_ids else [],
created_by_user_id=created_by_user_id.strip(),
)
+33
View File
@@ -0,0 +1,33 @@
"""Recipe domain entities."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List
@dataclass
class RecipeItem:
"""配方中的单个素材/标题/配音项"""
id: str
recipe_id: str
item_type: str # asset / title / voice
item_id: str
position: int = 0
metadata_: dict = field(default_factory=dict)
@dataclass
class Recipe:
"""配方 — 一次「一键生成」的完整参数组合"""
id: str
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: dict = field(default_factory=dict)
items: List[RecipeItem] = field(default_factory=list)
is_active: bool = True
metadata_: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+47
View File
@@ -0,0 +1,47 @@
"""Template domain entities — 剪辑计划模板."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Optional
@dataclass
class TemplateSegment:
"""模板中的单个片段."""
id: str
template_id: str
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None # 仅 voice_over_mix: 人物/场景; 其他模式 null
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class Template:
"""剪辑计划模板."""
id: str
user_id: str
name: str
mode: str # EditingMode 枚举值: pip / voice_pip / one_take / voice_over
category: str = ""
tags: List[str] = field(default_factory=list)
title_config: dict = field(default_factory=dict)
subtitle_config: dict = field(default_factory=dict)
bgm_config: dict = field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[TemplateSegment] = field(default_factory=list)
is_active: bool = True
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class TemplateCategory:
"""模板分类."""
id: str
user_id: str
name: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+1
View File
@@ -112,6 +112,7 @@ class FeatureFlags:
name=FeatureScope.RECIPE_REUSE,
description="配方复用功能",
global_enabled=True,
plan_overrides={"free": False}, # 仅基础版和高级版可用
),
]
for flag in defaults:
+16 -8
View File
@@ -1,4 +1,4 @@
"""兼容层:旧素材仓储接口定义,保留给遗留异步适配器使用。"""
"""素材仓储接口定义。"""
from abc import ABC, abstractmethod
@@ -7,15 +7,15 @@ from packages.domain import Asset
class AssetRepository(ABC):
@abstractmethod
async def create(self, asset: Asset) -> Asset:
def create(self, asset: Asset) -> Asset:
pass
@abstractmethod
async def find_by_id(self, asset_id: str) -> Asset | None:
def find_by_id(self, asset_id: str) -> Asset | None:
pass
@abstractmethod
async def find_by_project(
def find_by_project(
self,
project_id: str,
skip: int = 0,
@@ -24,7 +24,7 @@ class AssetRepository(ABC):
pass
@abstractmethod
async def find_by_library(
def find_by_library(
self,
library_id: str,
skip: int = 0,
@@ -33,13 +33,21 @@ class AssetRepository(ABC):
pass
@abstractmethod
async def update(self, asset: Asset) -> Asset:
def update(self, asset: Asset) -> Asset:
pass
@abstractmethod
async def delete(self, asset_id: str) -> bool:
def delete(self, asset_id: str) -> bool:
pass
@abstractmethod
async def count_by_project(self, project_id: str) -> int:
def count_by_project(self, project_id: str) -> int:
pass
@abstractmethod
def count_by_project_ids(self, project_ids: list[str]) -> int:
pass
@abstractmethod
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
pass
@@ -12,4 +12,10 @@ class GenerationTaskRepository(Protocol):
def list_by_project(self, project_id: str) -> list[GenerationTask]: ...
def list_by_user(self, user_id: str) -> list[GenerationTask]: ...
def count_by_user(self, user_id: str) -> int: ...
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
def update(self, task: GenerationTask) -> GenerationTask: ...
+43
View File
@@ -0,0 +1,43 @@
"""Recipe repository port."""
from __future__ import annotations
from typing import List, Optional, Protocol
from packages.domain.recipe import Recipe, RecipeItem
class RecipeRepository(Protocol):
"""配方仓储接口"""
def list_by_user(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Recipe]:
...
def get(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
...
def create(self, recipe: Recipe) -> Recipe:
...
def update(self, recipe: Recipe) -> Recipe:
...
def delete(self, recipe_id: str, user_id: str) -> bool:
...
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
...
def list_items(self, recipe_id: str) -> List[RecipeItem]:
...
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]:
...
def delete_items_by_recipe(self, recipe_id: str) -> int:
...
+22
View File
@@ -0,0 +1,22 @@
"""Template repository port (Protocol)."""
from __future__ import annotations
from typing import List, Optional, Protocol
from packages.domain.template import Template, TemplateCategory, TemplateSegment
class TemplateRepositoryPort(Protocol):
def list_by_user(self, user_id: str, *, skip: int = 0, limit: int = 50) -> List[Template]: ...
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
def create(self, template: Template) -> Template: ...
def update(self, template: Template) -> Template: ...
def delete(self, template_id: str, user_id: str) -> bool: ...
def count_by_user(self, user_id: str) -> int: ...
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]: ...
def delete_segments_by_template(self, template_id: str) -> int: ...
def list_categories(self, user_id: str) -> List[TemplateCategory]: ...
def create_category(self, category: TemplateCategory) -> TemplateCategory: ...
def get_category(self, category_id: str, user_id: str) -> Optional[TemplateCategory]: ...
def delete_category(self, category_id: str, user_id: str) -> bool: ...
View File
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
# cleanup_old_images.sh
# 清理构建服务器上的旧 Docker 镜像和 Registry 旧版本
# 保留最近 KEEP_VERSIONS 个版本(默认 2)
# 在 CI 构建完成后调用,防止磁盘空间耗尽
set -eu
KEEP_VERSIONS="${KEEP_VERSIONS:-2}"
REGISTRY_HOST="${REGISTRY_HOST:-172.30.18.198:5000}"
SERVICES="xiaoxia-saas-api xiaoxia-saas-worker xiaoxia-saas-web"
echo "=== Docker Image Cleanup ==="
echo "Keeping last ${KEEP_VERSIONS} versions per service"
echo ""
for svc in $SERVICES; do
# 收集所有 v0.N.N 格式的版本号(去重,按版本号排序)
versions=$(docker images --format "{{.Repository}}:{{.Tag}}" | \
grep "${svc}" | \
grep -E "v[0-9]+\.[0-9]+\.[0-9]+" | \
sed -E "s/.*:${svc}:(v[0-9]+\.[0-9]+\.[0-9]+).*/\1/" | \
sed -E "s/.*:v([0-9]+\.[0-9]+\.[0-9]+).*/v\1/" | \
sort -t. -k1,1V -k2,2n -k3,3n | \
uniq)
total=$(echo "$versions" | grep -c "^v" || true)
if [ "$total" -gt "$KEEP_VERSIONS" ]; then
remove_count=$((total - KEEP_VERSIONS))
to_remove=$(echo "$versions" | head -n "$remove_count")
echo "[Registry] Cleaning old blobs for ${svc}..."
for ver in $to_remove; do
repo_name=$(echo "$svc" | sed "s/xiaoxia-saas-//")
manifest_url="http://admin:Xiaoxia2026@localhost:5000/v2/${repo_name}/manifests/${ver}"
digest=$(curl -sI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "$manifest_url" | grep -i "^docker-content-digest:" | tr -d "\r" | awk "{print \$2}")
if [ -n "$digest" ]; then
curl -s -X DELETE -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "http://admin:Xiaoxia2026@localhost:5000/v2/${repo_name}/manifests/${digest}" > /dev/null 2>&1 || true
echo " Deleted registry tag: ${svc}:${ver}"
fi
done
echo "[Local] Removing old local images for ${svc}..."
for ver in $to_remove; do
docker rmi "${svc}:${ver}" 2>/dev/null && echo " Removed local: ${svc}:${ver}" || true
docker rmi "${REGISTRY_HOST}/${svc}:${ver}" 2>/dev/null && echo " Removed registry-ref: ${REGISTRY_HOST}/${svc}:${ver}" || true
done
else
echo "[${svc}] ${total} version(s) found, within keep limit (${KEEP_VERSIONS})"
fi
echo ""
done
# 清理悬空镜像(构建中间层)
echo "=== Pruning dangling images ==="
pruned=$(docker image prune -f 2>&1)
echo "$pruned" | tail -1
# 清理 dev 标签的旧层(dev 标签会被下次构建覆盖)
echo ""
echo "=== Cleanup complete ==="
echo "Current images:"
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | grep -E "(xiaoxia|REPOSITORY)" || true
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
冒烟测试脚本 - 部署后自动验证核心端点可用性
用法: python3 smoke_test.py <API_BASE_URL> [--email EMAIL] [--password PASSWORD] [--json]
示例: python3 smoke_test.py https://saas-api.xiaoxiajianji.com --email test@example.com --password test123 --json
"""
import argparse
import json
import sys
import time
import urllib.request
import urllib.error
import ssl
CORE_ENDPOINTS = [
{"name": "upload/direct/prepare", "method": "POST", "path": "/api/v1/upload/direct/prepare",
"body": {"project_id": "smoke-test", "file_name": "test.mp4", "file_size": 1024, "content_type": "video/mp4"},
"expect": [200, 401, 422]},
{"name": "upload/chunk/init", "method": "POST", "path": "/api/v1/upload/chunk/init",
"body": {"project_id": "smoke-test", "file_name": "test.mp4", "file_size": 1024000, "total_chunks": 2},
"expect": [200, 401, 422]},
{"name": "dashboard/overview", "method": "GET", "path": "/api/v1/dashboard/overview",
"expect": [200, 401]},
{"name": "assets", "method": "GET", "path": "/api/v1/assets?library_id=smoke-test",
"expect": [200, 401]},
{"name": "generation/tasks", "method": "POST", "path": "/api/v1/generation/tasks",
"body": {},
"expect": [200, 401, 422]},
]
def make_request(base_url, endpoint, token=None):
url = f"{base_url}{endpoint['path']}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(endpoint.get("body", {})).encode() if endpoint.get("body") is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=endpoint["method"])
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
start = time.time()
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
elapsed = round((time.time() - start) * 1000)
body = resp.read().decode()
return {"status": resp.status, "elapsed_ms": elapsed, "body": body[:200], "error": None}
except urllib.error.HTTPError as e:
elapsed = round((time.time() - start) * 1000)
body = ""
try:
body = e.read().decode()[:200]
except:
pass
return {"status": e.code, "elapsed_ms": elapsed, "body": body, "error": None}
except Exception as e:
return {"status": 0, "elapsed_ms": 0, "body": "", "error": str(e)}
def login(base_url, email, password):
url = f"{base_url}/api/v1/auth/login"
data = json.dumps({"email": email, "password": password}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=10, context=ctx)
body = json.loads(resp.read().decode())
return body.get("token") or body.get("data", {}).get("token") or body.get("access_token")
except:
return None
def run_smoke_test(base_url, email=None, password=None, output_json=False):
base_url = base_url.rstrip("/")
token = None
if email and password:
token = login(base_url, email, password)
if not output_json:
print(f"{'✅ 登录成功' if token else '⚠️ 登录失败,将以未认证模式测试'}")
results = []
all_passed = True
for ep in CORE_ENDPOINTS:
result = make_request(base_url, ep, token)
passed = result["status"] in ep["expect"] and result["error"] is None
is_5xx = 500 <= result["status"] < 600
if is_5xx:
passed = False
all_passed = False
results.append({
"name": ep["name"],
"path": ep["path"],
"status": result["status"],
"elapsed_ms": result["elapsed_ms"],
"passed": passed,
"error": result["error"],
"is_5xx": is_5xx
})
if not output_json:
icon = "✅" if passed else "❌"
print(f" {icon} {ep['name']}: {result['status']} ({result['elapsed_ms']}ms)")
if output_json:
print(json.dumps({"success": all_passed, "results": results, "base_url": base_url}, indent=2))
return 0 if all_passed else 1
def main():
parser = argparse.ArgumentParser(description="冒烟测试 - 部署后核心端点验证")
parser.add_argument("base_url", help="API 基础地址,如 https://saas-api.xiaoxiajianji.com")
parser.add_argument("--email", help="登录邮箱")
parser.add_argument("--password", help="登录密码")
parser.add_argument("--json", action="store_true", help="JSON 格式输出")
args = parser.parse_args()
if not args.json:
print(f"\n🔍 冒烟测试: {args.base_url}")
print("-" * 50)
exit_code = run_smoke_test(args.base_url, args.email, args.password, args.json)
if not args.json:
print("-" * 50)
print(f"{'✅ 全部通过' if exit_code == 0 else '❌ 存在失败端点'}\n")
sys.exit(exit_code)
if __name__ == "__main__":
main()
+142 -4
View File
@@ -1,11 +1,28 @@
import os
import sys
from pathlib import Path
from uuid import uuid4
# 设置必要环境变量(必须在导入 app 模块之前)
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes.asset_diagnosis import _build_diagnosis
from packages.domain import Asset, AssetStatus, ClassificationStatus
from packages.domain import (
Asset,
AssetLibrary,
AssetLibraryKind,
AssetStatus,
ClassificationStatus,
Project,
)
def _asset(name: str, mime_type: str, *, status=AssetStatus.READY, duration=None, quality_score=None):
@@ -25,7 +42,6 @@ def _asset(name: str, mime_type: str, *, status=AssetStatus.READY, duration=None
def test_asset_diagnosis_reports_missing_video_gap():
diagnosis = _build_diagnosis(
"workspace-1",
"project-1",
[_asset("voice.mp3", "audio/mpeg"), _asset("image.jpg", "image/jpeg")],
)
@@ -39,7 +55,6 @@ def test_asset_diagnosis_scores_ready_video_assets():
used_asset = _asset("video-1.mp4", "video/mp4", duration=8)
used_asset.metadata = {"generation_use_count": 1, "review_status": "pending_review"}
diagnosis = _build_diagnosis(
"workspace-1",
"project-1",
[
used_asset,
@@ -64,7 +79,6 @@ def test_asset_diagnosis_scores_ready_video_assets():
def test_asset_diagnosis_flags_unready_and_low_quality_assets():
diagnosis = _build_diagnosis(
"workspace-1",
"project-1",
[
_asset("video.mp4", "video/mp4", duration=10, quality_score=40),
@@ -78,3 +92,127 @@ def test_asset_diagnosis_flags_unready_and_low_quality_assets():
smart_view_counts = {item.key: item.count for item in diagnosis.smart_views}
assert smart_view_counts["needs_attention"] == 2
assert smart_view_counts["high_risk"] == 1
# ---------------------------------------------------------------------------
# 路由层测试 — 验证 find_by_project 调用正确性
# ---------------------------------------------------------------------------
class _StubProjectRepository:
def __init__(self, projects: dict[str, Project] | None = None):
self._projects = projects or {}
def get(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
class _StubAssetLibraryRepository:
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
self._libraries = libraries or {}
self.find_by_project_called_with: list[str] = []
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
self.find_by_project_called_with.append(project_id)
return [lib for lib in self._libraries.values() if lib.project_id == project_id]
def list_by_project(self, project_id: str) -> list[AssetLibrary]:
raise AssertionError("路由不应调用 list_by_project,应调用 find_by_project")
class _StubAssetRepository:
def __init__(self, assets: dict[str, Asset] | None = None):
self._assets = assets or {}
self.list_by_library_called_with: list[str] = []
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50) -> list[Asset]:
self.list_by_library_called_with.append(library_id)
return [a for a in self._assets.values() if a.library_id == library_id]
def count_by_library(self, library_id: str) -> int:
return len([a for a in self._assets.values() if a.library_id == library_id])
def _dep(name: str):
from app import dependencies
return getattr(dependencies, name)
def _build_route_test_app(project_repo, library_repo, asset_repo):
from unittest.mock import MagicMock
from app.api.routes.asset_diagnosis import router
from app.auth import AuthenticatedUser, get_current_user
app = FastAPI()
app.include_router(router, prefix="/api/v1")
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
mock_user.email = "test@example.com"
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[_dep("get_project_repository")] = lambda: project_repo
app.dependency_overrides[_dep("get_asset_library_repository")] = lambda: library_repo
app.dependency_overrides[_dep("get_asset_repository")] = lambda: asset_repo
return app
class TestAssetDiagnosisRoute:
"""路由层测试 — 验证 find_by_project 调用正确性。"""
def test_returns_404_when_project_not_found(self):
project_repo = _StubProjectRepository()
library_repo = _StubAssetLibraryRepository()
asset_repo = _StubAssetRepository()
app = _build_route_test_app(project_repo, library_repo, asset_repo)
client = TestClient(app)
resp = client.get("/api/v1/projects/nonexistent/asset-diagnosis")
assert resp.status_code == 404
def test_find_by_project_called_with_correct_project_id(self):
project = Project(id="proj-123", name="Test", owner_user_id="user-1")
library = AssetLibrary(
id="lib-1", name="Lib", project_id="proj-123", kind=AssetLibraryKind.VIDEO
)
project_repo = _StubProjectRepository({"proj-123": project})
library_repo = _StubAssetLibraryRepository({"lib-1": library})
asset_repo = _StubAssetRepository()
app = _build_route_test_app(project_repo, library_repo, asset_repo)
client = TestClient(app)
resp = client.get("/api/v1/projects/proj-123/asset-diagnosis")
assert resp.status_code == 200
assert library_repo.find_by_project_called_with == ["proj-123"]
def test_list_by_library_called_for_each_library(self):
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
lib1 = AssetLibrary(id="lib-1", name="Lib1", project_id="proj-1", kind=AssetLibraryKind.VIDEO)
lib2 = AssetLibrary(id="lib-2", name="Lib2", project_id="proj-1", kind=AssetLibraryKind.VOICE)
project_repo = _StubProjectRepository({"proj-1": project})
library_repo = _StubAssetLibraryRepository({"lib-1": lib1, "lib-2": lib2})
asset_repo = _StubAssetRepository()
app = _build_route_test_app(project_repo, library_repo, asset_repo)
client = TestClient(app)
resp = client.get("/api/v1/projects/proj-1/asset-diagnosis")
assert resp.status_code == 200
assert set(asset_repo.list_by_library_called_with) == {"lib-1", "lib-2"}
def test_find_by_project_not_list_by_project(self):
"""路由调用 find_by_project 而非 list_by_project(否则会触发 AssertionError)。"""
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
library = AssetLibrary(id="lib-1", name="Lib", project_id="proj-1", kind=AssetLibraryKind.VIDEO)
project_repo = _StubProjectRepository({"proj-1": project})
library_repo = _StubAssetLibraryRepository({"lib-1": library})
asset_repo = _StubAssetRepository()
app = _build_route_test_app(project_repo, library_repo, asset_repo)
client = TestClient(app)
resp = client.get("/api/v1/projects/proj-1/asset-diagnosis")
# 如果调用了 list_by_project,会抛 AssertionError → 500
assert resp.status_code == 200
+316
View File
@@ -0,0 +1,316 @@
"""
chunked_upload.py 路由单元测试
覆盖:
- init_chunked_upload 端点正常路径
- init_chunked_upload 项目/素材库不存在时返回 404
- find_by_project 调用正确性
- 文件大小校验
- OSS 凭证校验
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
# 设置必要环境变量(必须在导入 app 模块之前)
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain import AssetLibrary, AssetLibraryKind, Project
# ---------------------------------------------------------------------------
# Stub 实现(不继承 Port ABC)
# ---------------------------------------------------------------------------
class StubProjectRepository:
def __init__(self, projects: dict[str, Project] | None = None):
self._projects = projects or {}
def get(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
class StubAssetLibraryRepository:
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
self._libraries = libraries or {}
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
if kind is not None:
items = [lib for lib in items if lib.kind == kind]
return items
def list_by_project(self, project_id: str) -> list[AssetLibrary]:
raise AssertionError("路由不应调用 list_by_project,应调用 find_by_project")
class StubChunkedUploadRepository:
def __init__(self):
self._uploads = {}
def add(self, upload) -> None:
self._uploads[upload.upload_id] = upload
def get(self, upload_id: str):
return self._uploads.get(upload_id)
def update(self, upload) -> None:
self._uploads[upload.upload_id] = upload
def find_by_project(self, project_id: str, skip: int = 0, limit: int = 50):
return [u for u in self._uploads.values() if u.project_id == project_id]
def count_by_project(self, project_id: str) -> int:
return len([u for u in self._uploads.values() if u.project_id == project_id])
class StubIngestJobRepository:
def add(self, job) -> None:
pass
def get(self, job_id: str):
return None
def update_status(self, job_id, status, **kwargs):
pass
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50):
return []
def count_by_library(self, library_id: str) -> int:
return 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_project(id: str = "proj-1", owner_user_id: str = "user-1") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=AssetLibraryKind.VIDEO)
def _dep(name: str):
from app import dependencies
return getattr(dependencies, name)
def _build_app(
project_repo=None,
library_repo=None,
storage=None,
chunked_repo=None,
ingest_repo=None,
) -> FastAPI:
from app.api.routes.chunked_upload import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
app = FastAPI()
app.include_router(router, prefix="/api/v1")
project_repo = project_repo or StubProjectRepository()
library_repo = library_repo or StubAssetLibraryRepository()
storage = storage or MagicMock()
storage.is_configured = True
chunked_repo = chunked_repo or StubChunkedUploadRepository()
ingest_repo = ingest_repo or StubIngestJobRepository()
# Mock auth
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
mock_user.email = "test@example.com"
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[_dep("get_project_repository")] = lambda: project_repo
app.dependency_overrides[_dep("get_asset_library_repository")] = lambda: library_repo
app.dependency_overrides[get_storage_service] = lambda: storage
app.dependency_overrides[_dep("get_ingest_job_repository")] = lambda: ingest_repo
return app
def _client(**kwargs) -> TestClient:
return TestClient(_build_app(**kwargs))
# ---------------------------------------------------------------------------
# 测试用例
# ---------------------------------------------------------------------------
class TestInitChunkedUpload:
"""init_chunked_upload 端点测试。"""
def test_returns_upload_record_on_success(self):
"""正常初始化分片上传。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(project_repo=project_repo, library_repo=library_repo)
file_size = 100 * 1024 * 1024 # 100MB
chunk_size = 5 * 1024 * 1024 # 5MB
total_chunks = (file_size + chunk_size - 1) // chunk_size
resp = client.post(
"/api/v1/init",
json={
"project_id": project.id,
"library_id": library.id,
"filename": "large-video.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["filename"] == "large-video.mp4"
assert "upload_id" in data
def test_returns_404_when_project_not_found(self):
"""项目不存在时返回 404。"""
library = _make_library()
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(
project_repo=StubProjectRepository(),
library_repo=library_repo,
)
file_size = 100 * 1024 * 1024
total_chunks = (file_size + 5 * 1024 * 1024 - 1) // (5 * 1024 * 1024)
resp = client.post(
"/api/v1/init",
json={
"project_id": "nonexistent",
"library_id": library.id,
"filename": "test.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
assert resp.status_code == 404
assert "Project not found" in resp.json()["detail"]
def test_returns_404_when_library_not_in_project(self):
"""素材库不属于该项目时返回 404。"""
project = _make_project()
project_repo = StubProjectRepository({project.id: project})
# 素材库属于另一个项目
other_library = _make_library(project_id="other-project")
library_repo = StubAssetLibraryRepository({other_library.id: other_library})
client = _client(project_repo=project_repo, library_repo=library_repo)
file_size = 100 * 1024 * 1024
total_chunks = (file_size + 5 * 1024 * 1024 - 1) // (5 * 1024 * 1024)
resp = client.post(
"/api/v1/init",
json={
"project_id": project.id,
"library_id": "nonexistent-lib",
"filename": "test.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
assert resp.status_code == 404
assert "Asset library not found" in resp.json()["detail"]
def test_rejects_file_exceeding_max_size(self):
"""超过 2GB 限制的文件被拒绝(schema 层 le=2GB 会返回 422)。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(project_repo=project_repo, library_repo=library_repo)
resp = client.post(
"/api/v1/init",
json={
"project_id": project.id,
"library_id": library.id,
"filename": "huge.mp4",
"content_type": "video/mp4",
"file_size": 3 * 1024 * 1024 * 1024, # 3GB,超过 2GB 限制
"total_chunks": 600,
},
)
# schema le=2147483648 → 422; route-level check → 413
assert resp.status_code in (400, 413, 422)
def test_find_by_project_is_called_not_list_by_project(self):
"""验证路由调用 find_by_project 而非 list_by_project。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(project_repo=project_repo, library_repo=library_repo)
file_size = 100 * 1024 * 1024
total_chunks = (file_size + 5 * 1024 * 1024 - 1) // (5 * 1024 * 1024)
resp = client.post(
"/api/v1/init",
json={
"project_id": project.id,
"library_id": library.id,
"filename": "test.mp4",
"content_type": "video/mp4",
"file_size": file_size,
"total_chunks": total_chunks,
},
)
# 如果调用了 list_by_project,StubAssetLibraryRepository 会抛 AssertionError
assert resp.status_code == 200
class TestChunkedUploadConstants:
"""分片上传常量测试。"""
def test_max_file_size_is_2gb(self):
from app.api.routes.chunked_upload import MAX_FILE_SIZE
assert MAX_FILE_SIZE == 2 * 1024 * 1024 * 1024
def test_default_chunk_size_is_5mb(self):
from app.api.routes.chunked_upload import DEFAULT_CHUNK_SIZE
assert DEFAULT_CHUNK_SIZE == 5 * 1024 * 1024
def test_chunk_expiry_hours_is_24(self):
from app.api.routes.chunked_upload import CHUNK_EXPIRY_HOURS
assert CHUNK_EXPIRY_HOURS == 24
+198
View File
@@ -0,0 +1,198 @@
"""
config.py OSS 配置字段单元测试
覆盖:
- OSS 相关字段默认值
- 环境变量覆盖
- 字段名与代码引用一致
- pydantic_settings 加载行为
"""
from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
def _load_settings_class():
"""
直接加载 config.py 模块,绕过 apps/api/__init__.py 的副作用。
apps/api/__init__.py 会导入 main.py,而 main.py 依赖 app 模块。
"""
config_path = Path(__file__).resolve().parents[2] / "apps" / "api" / "app" / "config.py"
spec = importlib.util.spec_from_file_location("config_module", config_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.Settings
def _fresh_settings(**env_overrides: dict[str, str]):
"""
每次创建一个全新的 Settings 实例。
env_overrides 会注入到 os.environ。
"""
env = {
"JWT_SECRET_KEY": "unit-test-secret-key-12345",
**env_overrides,
}
with patch.dict(os.environ, env, clear=False):
Settings = _load_settings_class()
return Settings()
# ---------------------------------------------------------------------------
# 默认值测试
# ---------------------------------------------------------------------------
class TestOSSConfigDefaults:
"""OSS 配置字段默认值必须与代码引用一致。"""
def test_oss_endpoint_default(self):
settings = _fresh_settings()
assert settings.OSS_ENDPOINT == "oss-cn-hangzhou.aliiyuncs.com"
def test_oss_access_key_id_default_empty(self):
settings = _fresh_settings()
assert settings.OSS_ACCESS_KEY_ID == ""
def test_oss_access_key_secret_default_empty(self):
settings = _fresh_settings()
assert settings.OSS_ACCESS_KEY_SECRET == ""
def test_oss_bucket_name_default(self):
settings = _fresh_settings()
assert settings.OSS_BUCKET_NAME == "xiaoxia-autocut"
def test_oss_direct_upload_max_mb_default_is_2000(self):
"""PR #117 修复:默认值从 800 改为 2000。"""
settings = _fresh_settings()
assert settings.OSS_DIRECT_UPLOAD_MAX_MB == 2000
def test_oss_direct_upload_expire_seconds_default(self):
settings = _fresh_settings()
assert settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS == 900
# ---------------------------------------------------------------------------
# 环境变量覆盖测试
# ---------------------------------------------------------------------------
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"
def test_oss_access_key_id_override(self):
settings = _fresh_settings(OSS_ACCESS_KEY_ID="test-key-id")
assert settings.OSS_ACCESS_KEY_ID == "test-key-id"
def test_oss_access_key_secret_override(self):
settings = _fresh_settings(OSS_ACCESS_KEY_SECRET="test-key-secret")
assert settings.OSS_ACCESS_KEY_SECRET == "test-key-secret"
def test_oss_bucket_name_override(self):
settings = _fresh_settings(OSS_BUCKET_NAME="test-bucket")
assert settings.OSS_BUCKET_NAME == "test-bucket"
def test_oss_direct_upload_max_mb_override(self):
settings = _fresh_settings(OSS_DIRECT_UPLOAD_MAX_MB="4096")
assert settings.OSS_DIRECT_UPLOAD_MAX_MB == 4096
def test_oss_direct_upload_expire_seconds_override(self):
settings = _fresh_settings(OSS_DIRECT_UPLOAD_EXPIRE_SECONDS="1800")
assert settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS == 1800
# ---------------------------------------------------------------------------
# 字段名一致性测试(防止再次出现字段名拼写错误导致 500)
# ---------------------------------------------------------------------------
class TestOSSConfigFieldNameConsistency:
"""
确保 Settings 类包含代码中实际引用的所有字段。
防止类似 OSS_DIRECT_UPLOAD_EXPRESS_SECRET 的拼写错误再次发生。
"""
def test_settings_has_oss_endpoint_field(self):
settings = _fresh_settings()
assert hasattr(settings, "OSS_ENDPOINT")
def test_settings_has_oss_access_key_id_field(self):
settings = _fresh_settings()
assert hasattr(settings, "OSS_ACCESS_KEY_ID")
def test_settings_has_oss_access_key_secret_field(self):
settings = _fresh_settings()
assert hasattr(settings, "OSS_ACCESS_KEY_SECRET")
def test_settings_has_oss_bucket_name_field(self):
settings = _fresh_settings()
assert hasattr(settings, "OSS_BUCKET_NAME")
def test_settings_has_oss_direct_upload_max_mb_field(self):
settings = _fresh_settings()
assert hasattr(settings, "OSS_DIRECT_UPLOAD_MAX_MB")
def test_settings_has_oss_direct_upload_expire_seconds_field(self):
settings = _fresh_settings()
assert hasattr(settings, "OSS_DIRECT_UPLOAD_EXPIRE_SECONDS")
def test_property_aliases_match_field_names(self):
"""确保 property 访问器与字段值一致。"""
settings = _fresh_settings(
OSS_ENDPOINT="ep",
OSS_ACCESS_KEY_ID="kid",
OSS_ACCESS_KEY_SECRET="ksec",
OSS_BUCKET_NAME="bkt",
)
assert settings.oss_endpoint == "ep"
assert settings.oss_access_key_id == "kid"
assert settings.oss_access_key_secret == "ksec"
assert settings.oss_bucket_name == "bkt"
# ---------------------------------------------------------------------------
# extra="ignore" 行为测试
# ---------------------------------------------------------------------------
class TestSettingsExtraIgnore:
"""extra="ignore" 确保未知环境变量不会导致启动失败。"""
def test_unknown_env_var_is_ignored(self):
settings = _fresh_settings(UNKNOWN_RANDOM_VAR="whatever")
assert not hasattr(settings, "UNKNOWN_RANDOM_VAR")
def test_settings_loads_without_error(self):
settings = _fresh_settings()
assert settings.APP_NAME == "xiaoxia-saas"
# ---------------------------------------------------------------------------
# 别名测试(MAX_UPLOAD_SIZE_MB 兼容旧配置)
# ---------------------------------------------------------------------------
class TestOSSConfigAliases:
"""OSS_DIRECT_UPLOAD_MAX_MB 支持 MAX_UPLOAD_SIZE_MB 别名。"""
def test_max_upload_size_mb_alias_works(self):
"""旧环境变量 MAX_UPLOAD_SIZE_MB 仍能生效。"""
env = {
"JWT_SECRET_KEY": "unit-test-secret-key-12345",
"MAX_UPLOAD_SIZE_MB": "3000",
}
with patch.dict(os.environ, env, clear=False):
os.environ.pop("OSS_DIRECT_UPLOAD_MAX_MB", None)
Settings = _load_settings_class()
settings = Settings()
assert settings.OSS_DIRECT_UPLOAD_MAX_MB == 3000
+650
View File
@@ -0,0 +1,650 @@
"""
upload.py 表单上传端点单元测试
覆盖(24个测试用例):
- 正常路径(5):视频/音频/图片上传成功、创建导入任务、响应包含URL
- 文件名校验(5):路径遍历防护、反斜杠处理、空文件名、特殊字符、无扩展名
- 异常路径(8):不支持文件类型、项目/素材库不存在、存储服务错误、缺少参数
- 多格式支持(3):多种视频(4种)/音频(5种)/图片(6种)格式
- MIME验证(6):有效类型、空类型(400)、不支持类型(415)
"""
from __future__ import annotations
import io
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
# 设置必要环境变量(必须在导入 app 模块之前)
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain import AssetLibrary, AssetLibraryKind, Project
# ---------------------------------------------------------------------------
# 测试用 Stub(不继承 Port ABC,因为 Port 定义 async 方法,路由实际使用同步 duck-type)
# ---------------------------------------------------------------------------
class StubProjectRepository:
def __init__(self, projects: dict[str, Project] | None = None):
self._projects = projects or {}
def get(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
def find_by_id(self, project_id: str) -> Project | None:
return self._projects.get(project_id)
class StubAssetLibraryRepository:
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
self._libraries = libraries or {}
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
if kind is not None:
items = [lib for lib in items if lib.kind == kind]
return items
def list_by_project(self, project_id: str) -> list[AssetLibrary]:
raise AssertionError("路由不应调用 list_by_project,应调用 find_by_project")
class StubIngestJobRepository:
def __init__(self):
self._jobs = {}
self._counter = 0
def add(self, job) -> None:
self._jobs[job.id] = job
def get(self, job_id: str):
return self._jobs.get(job_id)
def update_status(self, job_id, status, **kwargs):
if job_id in self._jobs:
self._jobs[job_id].status = status
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50):
return []
def count_by_library(self, library_id: str) -> int:
return 0
def create(self, job):
"""创建一个模拟的 ingest job"""
self._jobs[job.id] = job
return job
# ---------------------------------------------------------------------------
# 测试 Fixtures
# ---------------------------------------------------------------------------
def _make_project(id: str = "proj-1", owner_user_id: str = "user-1") -> Project:
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
def _make_library(
id: str = "lib-1",
project_id: str = "proj-1",
kind: AssetLibraryKind = AssetLibraryKind.VIDEO,
) -> AssetLibrary:
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=kind)
def _build_app(
project_repo: StubProjectRepository | None = None,
library_repo: StubAssetLibraryRepository | None = None,
storage: MagicMock | None = None,
ingest_repo: StubIngestJobRepository | None = None,
) -> FastAPI:
"""构建一个最小化的 FastAPI app,只注册 upload 路由。"""
from app.api.routes.upload import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_ingest_job_repository,
get_project_repository,
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
project_repo = project_repo or StubProjectRepository()
library_repo = library_repo or StubAssetLibraryRepository()
storage = storage or MagicMock()
storage.is_configured = True
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
ingest_repo = ingest_repo or StubIngestJobRepository()
# Mock auth
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
mock_user.email = "test@example.com"
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_project_repository] = lambda: project_repo
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
app.dependency_overrides[get_storage_service] = lambda: storage
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
return app
def _client(**kwargs) -> TestClient:
app = _build_app(**kwargs)
return TestClient(app)
# ---------------------------------------------------------------------------
# 测试用例
# ---------------------------------------------------------------------------
class TestFormUploadSuccess:
"""表单上传正常成功路径测试。"""
def test_upload_video_file_successfully(self):
"""上传视频文件成功。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
ingest_repo = StubIngestJobRepository()
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/video.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
ingest_repo=ingest_repo,
)
# 模拟一个视频文件
file_content = b"fake video content" * 100
files = {"file": ("test-video.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
result = resp.json()
assert "storage_key" in result
assert "ingest_job_id" in result
assert "url" in result
assert storage.upload_file.called
def test_upload_image_file_successfully(self):
"""上传图片文件成功。"""
project = _make_project()
library = _make_library(kind=AssetLibraryKind.IMAGE)
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/image.jpg"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
# 模拟一个图片文件
file_content = b"fake image content" * 50
files = {"file": ("test-image.jpg", io.BytesIO(file_content), "image/jpeg")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
assert storage.upload_file.called
def test_upload_audio_file_successfully(self):
"""上传音频文件成功。"""
project = _make_project()
library = _make_library(kind=AssetLibraryKind.VOICE)
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/audio.mp3"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake audio content" * 50
files = {"file": ("test-audio.mp3", io.BytesIO(file_content), "audio/mpeg")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
assert storage.upload_file.called
def test_ingest_job_is_created(self):
"""验证上传成功后创建导入任务。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
ingest_repo = StubIngestJobRepository()
client = _client(
project_repo=project_repo,
library_repo=library_repo,
ingest_repo=ingest_repo,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
result = resp.json()
assert "ingest_job_id" in result
# 验证 ingest job 被创建
job = ingest_repo.get(result["ingest_job_id"])
assert job is not None
def test_response_contains_url(self):
"""验证响应包含文件 URL。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
expected_url = "https://bucket.oss.example.com/uploads/my-video.mp4"
storage.upload_file.return_value = expected_url
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
result = resp.json()
assert "url" in result
assert result["url"].startswith("https://")
class TestFormUploadFilenameSanitization:
"""文件名校验测试。"""
def _upload_with_filename(self, filename: str):
"""辅助方法:使用指定文件名上传文件。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": (filename, io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
return resp, storage
def test_path_traversal_prevented(self):
"""路径遍历防护:../ 被替换为 __。"""
resp, storage = self._upload_with_filename("../../../etc/passwd")
assert resp.status_code == 200
# 验证文件名被清理
call_args = storage.upload_file.call_args
storage_key = call_args[0][1] # 第二个位置参数是 storage_key
# 提取文件名部分(去掉 uploads/{file_id}/ 前缀)
filename_part = storage_key.split("/", 2)[-1]
# 文件名部分不应包含 / 或 \
assert "/" not in filename_part
assert "\\" not in filename_part
def test_backslash_replaced(self):
"""反斜杠被替换为下划线。"""
resp, storage = self._upload_with_filename(r"folder\subfolder\video.mp4")
assert resp.status_code == 200
call_args = storage.upload_file.call_args
storage_key = call_args[0][1]
assert "\\" not in storage_key
def test_empty_filename_becomes_unknown(self):
"""空文件名被 FastAPI 拒绝(422)。"""
resp, storage = self._upload_with_filename("")
# FastAPI 验证文件名不能为空
assert resp.status_code == 422
def test_special_characters_in_filename(self):
"""特殊字符文件名正常处理。"""
resp, storage = self._upload_with_filename("my-video (2024) [HD].mp4")
assert resp.status_code == 200
call_args = storage.upload_file.call_args
storage_key = call_args[0][1]
assert "my-video (2024) [HD].mp4" in storage_key
def test_filename_without_extension(self):
"""无扩展名文件名正常处理。"""
resp, storage = self._upload_with_filename("no-extension-file")
assert resp.status_code == 200
call_args = storage.upload_file.call_args
storage_key = call_args[0][1]
assert "no-extension-file" in storage_key
class TestFormUploadMissingFile:
"""缺少文件字段测试。"""
def test_missing_file_field_returns_422(self):
"""缺少文件字段时返回 422。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(project_repo=project_repo, library_repo=library_repo)
# 只提交表单数据,不上传文件
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", data=data)
assert resp.status_code == 422
class TestFormUploadMissingParameters:
"""缺少必填参数测试。"""
def test_missing_project_id_returns_422(self):
"""缺少 project_id 时返回 422。"""
library = _make_library()
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(library_repo=library_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"library_id": library.id} # 缺少 project_id
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 422
def test_missing_library_id_returns_422(self):
"""缺少 library_id 时返回 422。"""
project = _make_project()
project_repo = StubProjectRepository({project.id: project})
client = _client(project_repo=project_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id} # 缺少 library_id
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 422
def test_empty_project_id_returns_422(self):
"""project_id 为空字符串时返回 422(min_length=1)。"""
library = _make_library()
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(library_repo=library_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": "", "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 422
class TestFormUploadProjectNotFound:
"""项目/素材库不存在测试。"""
def test_returns_404_when_project_not_found(self):
"""项目不存在时返回 404。"""
library = _make_library()
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(
project_repo=StubProjectRepository(),
library_repo=library_repo,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": "nonexistent", "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 404
assert "Project not found" in resp.json()["detail"]
def test_returns_404_when_library_not_in_project(self):
"""素材库不属于该项目时返回 404。"""
project = _make_project()
project_repo = StubProjectRepository({project.id: project})
# 素材库属于另一个项目
other_library = _make_library(project_id="other-project")
library_repo = StubAssetLibraryRepository({other_library.id: other_library})
client = _client(project_repo=project_repo, library_repo=library_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": "nonexistent-lib"}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 404
assert "Asset library not found" in resp.json()["detail"]
class TestFormUploadOSSNotConfigured:
"""OSS 未配置测试。"""
def test_returns_503_when_oss_not_configured(self):
"""OSS 未配置时返回 503(RuntimeError)。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.side_effect = RuntimeError("OSS 未配置")
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 503
class TestFormUploadStorageErrors:
"""存储服务错误测试。"""
def test_returns_500_on_generic_storage_error(self):
"""存储服务通用错误返回 500。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.side_effect = Exception("Network error")
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 500
assert "Failed to upload file" in resp.json()["detail"]
class TestFormUploadMultipleFormats:
"""多格式支持测试。"""
def _upload_with_mime(self, mime_type: str, filename: str = "test.mp4"):
"""辅助方法:使用指定 MIME 类型上传文件。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake content"
files = {"file": (filename, io.BytesIO(file_content), mime_type)}
data = {"project_id": project.id, "library_id": library.id}
return client.post("/api/v1", files=files, data=data)
def test_multiple_video_formats(self):
"""支持多种视频格式(4种)。"""
video_formats = [
("video/mp4", "test.mp4"),
("video/mpeg", "test.mpeg"),
("video/quicktime", "test.mov"),
("video/x-msvideo", "test.avi"),
]
for mime_type, filename in video_formats:
resp = self._upload_with_mime(mime_type, filename)
assert resp.status_code == 200, f"Failed for {mime_type}"
def test_multiple_audio_formats(self):
"""支持多种音频格式(5种)。"""
audio_formats = [
("audio/mpeg", "test.mp3"),
("audio/wav", "test.wav"),
("audio/ogg", "test.ogg"),
("audio/flac", "test.flac"),
("audio/aac", "test.aac"),
]
for mime_type, filename in audio_formats:
resp = self._upload_with_mime(mime_type, filename)
assert resp.status_code == 200, f"Failed for {mime_type}"
def test_multiple_image_formats(self):
"""支持多种图片格式(6种)。"""
image_formats = [
("image/jpeg", "test.jpg"),
("image/png", "test.png"),
("image/gif", "test.gif"),
("image/webp", "test.webp"),
("image/bmp", "test.bmp"),
("image/svg+xml", "test.svg"),
]
for mime_type, filename in image_formats:
resp = self._upload_with_mime(mime_type, filename)
assert resp.status_code == 200, f"Failed for {mime_type}"
class TestFormUploadMIMEValidation:
"""MIME 类型验证测试。"""
def _upload_with_content_type(self, content_type: str | None):
"""辅助方法:使用指定 Content-Type 上传文件。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake content"
# 使用元组形式明确指定 content_type
files = {"file": ("test.mp4", io.BytesIO(file_content), content_type)}
data = {"project_id": project.id, "library_id": library.id}
return client.post("/api/v1", files=files, data=data)
def test_valid_mime_type_accepted(self):
"""有效 MIME 类型被接受。"""
resp = self._upload_with_content_type("video/mp4")
assert resp.status_code == 200
def test_empty_content_type_returns_400(self):
"""空 Content-Type 返回 400。"""
# 使用空字符串作为 content type
resp = self._upload_with_content_type("")
assert resp.status_code == 400
assert "Content-Type header is required" in resp.json()["detail"]
def test_unsupported_content_type_returns_415(self):
"""不支持的 Content-Type 返回 415。"""
resp = self._upload_with_content_type("text/plain")
assert resp.status_code == 415
assert "not supported" in resp.json()["detail"]

Some files were not shown because too many files have changed in this diff Show More