Compare commits

..

41 Commits

Author SHA1 Message Date
xiaoxia 5bf572b225 test(web): 修复WechatCallback测试 - mock localStorage的wechat_state
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m8s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m35s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 15s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 15s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m13s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8m42s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m2s
2026-07-23 19:21:52 +08:00
xiaoxia 375692e838 test(web): 修复WechatCallback测试 - mock正确的code/state参数
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 10s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m51s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m56s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 10s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m33s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m8s
2026-07-23 19:14:44 +08:00
xiaoxia 2f237c80cf test(web): 添加WechatCallback页面基础单元测试
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 53s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m40s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 53s
AI Code Review / AI Code Review (pull_request) Successful in 3m56s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m47s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49m12s
2026-07-23 18:56:24 +08:00
xiaoxia ee916acb1b fix(web): 修复微信回调页token时序bug - 先存token再取用户信息
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m15s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m33s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 26s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 53s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 10m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m10s
WechatCallback页面存在与#734相同的时序问题:
getCurrentUser()在setAuth()之前执行,导致请求拦截器
从localStorage取不到token,返回401后触发登出逻辑跳回登录页。

修复方式与#734一致:先手动存token到localStorage,
再调用getCurrentUser(),最后setAuth同步store状态。

关联: #558, #734
2026-07-23 18:29:31 +08:00
xiaoxia f9f7eae37d Merge pull request 'fix(#584): 修复智能匹配模式向后兼容 - _select_assets_from_library恢复简单排序' (#762) from fix/smart-asset-selector-backward-compat into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m45s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m51s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m27s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m13s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m25s
CI/CD Pipeline / Integration Tests (push) Successful in 4m56s
CI/CD Pipeline / Unit Tests (push) Successful in 7m37s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 11s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 16s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
2026-07-23 18:16:03 +08:00
CI Bot 5a9b6d9890 fix(#584): 修复智能匹配模式向后兼容问题 - _select_assets_from_library的smart模式恢复简单排序
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 25s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m50s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m51s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m16s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
AI Code Review / AI Code Review (pull_request) Successful in 2m40s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m27s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14m26s
#751引入SmartAssetSelector后破坏了_select_assets_from_library的smart模式原有行为:
- 新增最低质量分过滤(旧逻辑只排序不过滤)
- null quality_score处理逻辑变化(旧逻辑当0分,新逻辑当0.5分)
- 时长tiebreaker变化(旧逻辑时长越长越靠前,新逻辑有最优区间)

修复:_select_assets_from_library恢复quality_score+时长的简单排序,
保持API层smart模式的向后兼容。SmartAssetSelector作为独立服务
继续存在,供AI推荐、自动剪辑等需要4维评分的高级场景使用。

修复tests/unit/test_asset_select_mode.py中5个失败的单测。
2026-07-23 18:11:31 +08:00
xiaoxia 27cb7381ad style: 修复2个文件ruff告警+格式化对齐(smart_asset_selector + test_ai_service) (#759)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 35s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m11s
CI/CD Pipeline / Integration Tests (push) Successful in 5m44s
CI/CD Pipeline / Unit Tests (push) Failing after 9m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 17:57:33 +08:00
xiaoxia 8cec4069fa fix(ci): Unit Tests添加worker依赖+修复PYTHONPATH+pip国内源 (#757)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m52s
CI/CD Pipeline / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m26s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Unit Tests (push) Failing after 7m15s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 17:44:11 +08:00
xiaoxia 2ee710ca16 fix(ci): 去掉前端脚本DooD嵌套,直接在CI容器内运行Node命令 (#756)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 17:38:09 +08:00
xiaoxia 5c5aabd311 Merge pull request 'feat(#674): 豆包大模型 Phase 3 - AI推荐片段编排 + 客户端抽共享层' (#754) from feat/doubao-ai-integration-phase3 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 17:33:19 +08:00
xiaoxia 5c260c1c87 Merge pull request 'feat(#674): 豆包大模型 Phase 2 - 智能素材语义匹配' (#753) from feat/doubao-ai-integration-phase2 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 17:33:07 +08:00
xiaoxia b5ad67830e Merge pull request 'fix(api): 修复验证码repository datetime时区问题 - naive转aware' (#755) from fix/verification-code-datetime-timezone into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 17:32:51 +08:00
CI Bot 545293fe5c feat(#674): 豆包大模型 Phase 3 - AI推荐片段编排 + 客户端抽共享层
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
AI Code Review / AI Code Review (pull_request) Successful in 4m36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 35s
- 豆包客户端抽 packages/shared/ai_client.py,API和Worker共用
- 配置移到 SharedSettings,两边统一读取
- Worker AI推荐接入豆包大模型,替换原stub
- 智能编排:intro/showcase/outro 三段式结构 + 转场分配
- 降级机制:无Key/调用失败/解析失败均回退本地规则
- 响应解析:格式校验+字段兜底+非法asset过滤+order排序重编号
- 新增22个worker AI单测 + 调整41个API单测mock
- 累计63个AI单测全部通过
2026-07-23 14:05:15 +08:00
CI Bot df7dafb8c4 feat(#674): 豆包大模型 Phase 2 - 智能素材语义匹配
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m14s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m7s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m23s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m47s
AI Code Review / AI Code Review (pull_request) Successful in 9m13s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 27s
- 新增 semantic_match_assets 语义匹配服务
- 豆包评估素材名称/标签/描述与目标描述的匹配度(0-1分)
- 支持3种返回格式解析:dict / matches数组 / 数组
- 降级方案:基于关键词的本地匹配(中英文支持+名称加权)
- 新增 POST /ai/assets/match 接口,支持 top_k 限制
- 新增 18 个单测,覆盖降级匹配/解析/集成场景
- 累计42个AI单测全部通过
2026-07-23 14:04:23 +08:00
xiaoxia b701182f6b fix(api): 修复验证码repository datetime时区问题 - 数据库naive转aware
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 29s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 13s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m3s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m44s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 9m23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 35s
SQLAlchemy 从数据库读出的 DateTime 是 naive(不带时区),
但领域模型 VerificationCode 用的是 aware datetime(带 timezone.utc),
直接比较会报 "can't compare offset-naive and offset-aware datetimes"。

修复:_to_entity 时给所有 datetime 字段补 timezone.utc。

影响:send-verification-code 限流检查、bind-contact 验证码校验(is_expired)
2026-07-23 13:20:59 +08:00
xiaoxia 5aa8a96a43 feat(#584): 智能匹配视频素材增强 — 多维度评分+多样性保证+项目级支持 (#751)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m49s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m59s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m1s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m27s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 40s
CI/CD Pipeline / Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Unit Tests (push) Failing after 4m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 18m36s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 18m36s
2026-07-23 13:17:54 +08:00
xiaoxia ad3e2f4834 feat(#674): 接入豆包大模型 Phase 1 - 智能标题生成 + 统一AI服务层 (#752)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 13:17:39 +08:00
xiaoxia 72b62a60c4 feat(#632): 配音素材库音频架构统一,迁移到audio图层混音 (#750)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 27s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 26s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 32s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m47s
CI/CD Pipeline / Unit Tests (push) Failing after 13m52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 25m38s
CI/CD Pipeline / Integration Tests (push) Successful in 29m31s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 44m4s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 10:43:48 +08:00
xiaoxia 26cc6a88b7 fix(ci): 修复auto-merge语法bug - 逻辑抽取到独立脚本 (#748)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 23s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 13s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 58s
CI/CD Pipeline / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 47s
CI/CD Pipeline / Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Failing after 7m54s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 10:31:11 +08:00
xiaoxia 9d3a8852e5 feat(#632): 一键生成输出分辨率可配置 (#749)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 10:27:37 +08:00
xiaoxia f3ca061055 test: P3-1 第二十二波 TTS音频合并+流式服务单元测试 14个 (#728)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 34s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 38s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 1m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 33s
CI/CD Pipeline / Integration Tests (push) Successful in 1m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m25s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 40s
CI/CD Pipeline / Unit Tests (push) Successful in 2m36s
CI/CD Pipeline / Build Staging API Image (push) Successful in 9m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-23 10:04:18 +08:00
xiaoxia c14fd21eaa test: P3-1 第二十一波 JWT+Password委托层单元测试 24个 (#727)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 09:56:48 +08:00
xiaoxia ac724a07a0 test: P3-1 第二十波 wechat_oauth_service单元测试 28个 (#726)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 09:56:25 +08:00
xiaoxia 833c604fd7 feat(video-share): 视频分享后端 - 密码保护/有效期/浏览下载计数 (#737)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 38s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 58s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m4s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m51s
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m6s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 2m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m39s
2026-07-23 09:38:21 +08:00
xiaoxia b42c81e690 fix: 修复AI审查严重问题检测正则,正确匹配需要修改的问题 (#747)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 09:38:11 +08:00
xiaoxia 550fecdd47 test(p3-1): 第27波 voice_clone_workflow 单测 (22个) (#733)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 0s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-23 06:39:11 +08:00
xiaoxia 6a873e057b ci: Agent提交前自动格式化 + AI审查与auto-approve联动 (#746)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 14m59s
CI/CD Pipeline / Integration Tests (push) Failing after 15m1s
CI/CD Pipeline / Unit Tests (push) Failing after 15m1s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 15m2s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 15m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 15m2s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 15m2s
CI/CD Pipeline / Build Staging API Image (push) Failing after 15m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 15m2s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 15m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-23 01:24:36 +08:00
xiaoxia 99227bd9eb test(p3-1): 第二十五波 - 绑定联系方式+微信同步 use case 35个单测 (#731)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m48s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 2m51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m24s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m10s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m49s
CI/CD Pipeline / Unit Tests (push) Successful in 3m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 25m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 35m36s
2026-07-23 00:25:25 +08:00
xiaoxia a78e8c3480 test: P3-1 第十九波 generation_tasks应用层用例单元测试 17个 (#725)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-23 00:25:23 +08:00
xiaoxia 62ebac4d21 test: P3-1 第十七波 verification_code_service 单元测试 32个 (#723)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (push) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (push) Blocked by required conditions
CI/CD Pipeline / PR Build API Image (push) Waiting to run
CI/CD Pipeline / PR Build Web Image (push) Waiting to run
CI/CD Pipeline / PR Build Worker Image (push) Waiting to run
CI/CD Pipeline / Build Staging API Image (push) Waiting to run
CI/CD Pipeline / Build Staging Web Image (push) Waiting to run
CI/CD Pipeline / Build Staging Worker Image (push) Waiting to run
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Blocked by required conditions
CI/CD Pipeline / Staging E2E Tests (push) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (push) Blocked by required conditions
CI/CD Pipeline / Build Production API Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (push) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (push) Blocked by required conditions
CI/CD Pipeline / Deploy Production (push) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (push) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (push) Blocked by required conditions
2026-07-23 00:25:22 +08:00
xiaoxia 09869ce69c test: P3-1 第十八波 jobs应用层用例单元测试 37个 (#724)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m36s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m39s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m28s
CI/CD Pipeline / Unit Tests (push) Successful in 4m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m43s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 10m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 13m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-22 23:57:27 +08:00
xiaoxia 18a1f4e43d fix(#462): 修复直通模式下水印静默失效 - 配置格式不兼容 (#739)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 57s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m29s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 2m31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m41s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m59s
CI/CD Pipeline / Unit Tests (push) Successful in 3m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 15m51s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 15m51s
CI/CD Pipeline / Frontend Lint (push) Successful in 42m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 39m39s
2026-07-22 22:42:15 +08:00
xiaoxia cd7e75a845 fix(#633): 修复一键生成转场时长不生效 - _VirtualClip缺transition_duration字段 (#738)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-22 22:42:14 +08:00
xiaoxia 4016f0eca8 test(p3-1): 第29波 template use cases 单测 (39个) (#736)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-22 22:42:12 +08:00
xiaoxia adb3a2e269 test(p3-1): 第28波 CosyVoiceService 单测 (51个) (#735)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-22 22:42:10 +08:00
xiaoxia fcdf693707 fix(web): 修复登录时序bug - token先存localStorage再取用户信息 (#734)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-22 22:42:09 +08:00
xiaoxia 3e48248c83 test(p3-1): 第二十六波 - TTS workflow 工作流编排 25个单测 (#732)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-22 22:42:08 +08:00
xiaoxia 0f6496a945 test(p3-1): 第二十四波 - 注册+密码重置 use case 39个单测 (#730)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
2026-07-22 22:42:05 +08:00
xiaoxia 7421a76e5a test(p3-1): 第二十三波 - auth登录/刷新/登出use case 38个单测 (#729)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
test(p3-1): 第二十三波 - auth登录/刷新/登出use case 38个单测
2026-07-22 22:41:24 +08:00
xiaoxia 8aaef6e964 ci: 简化PR门禁,加速合并(减法重构)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 35s
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m6s
CI/CD Pipeline / Unit Tests (push) Successful in 2m45s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 18m10s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 18m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
减法重构:3个文件,净删52行。

1. auto-merge从7个门禁减到4个(去掉3个PR构建,不卡合并)
2. PR构建去掉--load和本地缓存,纯构建验证
3. 修复Frontend Unit Tests环境:vitest改在Docker容器中运行
4. Gitea分支保护同步更新为4个required门禁
2026-07-22 22:22:04 +08:00
xiaoxia 614a8e95be ci: CI全链路加固6项 - 从基础设施到门禁策略的完整补漏 (#742)
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 40s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 45s
CI/CD Pipeline / Frontend Lint (push) Successful in 55s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 57s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 16s
CI/CD Pipeline / Integration Tests (push) Successful in 59s
CI/CD Pipeline / Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m22s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 5m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
2026-07-22 21:40:10 +08:00
63 changed files with 11806 additions and 2109 deletions
+2
View File
@@ -10,6 +10,8 @@ on:
branches:
- main
- develop
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00,每日全量CI回归
workflow_dispatch:
inputs:
reason:
+38 -291
View File
@@ -13,7 +13,7 @@ jobs:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps:
- name: Checkout code
shell: sh
@@ -22,6 +22,15 @@ jobs:
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: "🔍 脚本语法自检"
shell: bash
run: |
ERROR=0
for f in scripts/ci/*.sh; do [ -f "$f" ] && bash -n "$f" 2>&1 || ERROR=$((ERROR+1)); done
for f in scripts/ci/*.py; do [ -f "$f" ] && python3 -m py_compile "$f" 2>&1 || ERROR=$((ERROR+1)); done
if [ "$ERROR" -ne 0 ]; then echo "❌ 语法自检失败 ($ERROR个)"; exit 1; fi
echo "✅ 脚本语法自检通过"
- name: Auto approve when CI passes
shell: bash
env:
@@ -30,153 +39,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑 → 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
exit 0
bash scripts/ci/auto_approve.sh
- name: Report CI trace
if: always()
shell: sh
@@ -193,7 +56,7 @@ jobs:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps:
- name: Checkout code
shell: sh
@@ -202,6 +65,31 @@ jobs:
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: "🔍 脚本语法自检(防止脚本bug导致所有PR挂掉)"
shell: bash
run: |
echo "=== CI脚本语法自检 ==="
ERROR=0
for f in scripts/ci/*.sh; do
[ -f "$f" ] || continue
if ! bash -n "$f" 2>&1; then
echo "FAIL: $f"
ERROR=1
fi
done
for f in scripts/ci/*.py; do
[ -f "$f" ] || continue
if ! python3 -m py_compile "$f" 2>&1; then
echo "FAIL: $f"
ERROR=1
fi
done
if [ "$ERROR" -ne 0 ]; then
echo "❌ 脚本语法自检失败"
exit 1
fi
echo "✅ 所有CI脚本语法自检通过"
- name: Auto merge when CI passes and approved
shell: bash
env:
@@ -211,148 +99,7 @@ jobs:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / PR Build API Image (pull_request)"
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
"CI/CD Pipeline / PR Build Web Image (pull_request)"
)
echo "检查required门禁(与分支保护一致)"
fi
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 18); do # 短作业模式:最多等3分钟(18次x10秒),不满足就退出等下次触发
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# CI全绿 → 合并
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "CI全绿,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
exit 0
bash scripts/ci/auto_merge.sh
- name: Report CI trace
if: always()
shell: sh
+48
View File
@@ -0,0 +1,48 @@
"""#P3-2 - 视频分享表 video_shares
Revision ID: 050
Revises: 049
Create Date: 2026-07-22
Changes:
1. 新建 video_shares 表,支持视频匿名分享链接
2. share_token 唯一索引,用于公开分享URL
3. 支持密码保护、有效期、浏览/下载计数
"""
import sqlalchemy as sa
from alembic import context, op
revision = "050_video_shares"
down_revision = "049_wechat_login_phone"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查表是否已存在(幂等)
result = conn.execute(sa.text("SELECT to_regclass('public.video_shares')"))
if result.scalar() is not None:
return
op.create_table(
"video_shares",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("video_id", sa.String(32), nullable=False, index=True),
sa.Column("user_id", sa.String(32), nullable=False, index=True),
sa.Column("share_token", sa.String(16), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("view_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("download_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("video_shares")
+53
View File
@@ -0,0 +1,53 @@
"""#632 - 一键生成输出分辨率可配置
Revision ID: 051
Revises: 050
Create Date: 2026-07-23
Changes:
1. generation_tasks 表新增 resolution 字段,存储用户指定的输出分辨率(如 "1280x720"
2. 为空时使用默认值(1280x720)
"""
import sqlalchemy as sa
from alembic import context, op
revision = "051_generation_task_resolution"
down_revision = "050_video_shares"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column("resolution", sa.String(20), nullable=False, server_default=""),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "resolution")
+11
View File
@@ -9,8 +9,10 @@ from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.ai import router as ai_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.projects import router as projects_router
from app.api.routes.share import router as share_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
@@ -104,6 +106,10 @@ api_router.include_router(
videos_router,
tags=["VideoCenter"],
)
api_router.include_router(
share_router,
tags=["Share"],
)
api_router.include_router(
duplication_router,
prefix="/duplication",
@@ -129,6 +135,11 @@ api_router.include_router(
prefix="/tts",
tags=["TTS"],
)
api_router.include_router(
ai_router,
prefix="/ai",
tags=["AI"],
)
api_router.include_router(
feature_flags_router,
tags=["Internal"],
+136
View File
@@ -0,0 +1,136 @@
"""AI 相关接口 — 智能标题、智能素材匹配等.
基于豆包大模型的 AI 能力接口,未配置 API Key 时自动降级为本地模拟。
"""
from __future__ import annotations
from typing import List, Literal, Optional
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
from fastapi import APIRouter
from pydantic import BaseModel, Field
router = APIRouter()
# ── 请求/响应模型 ────────────────────────────────────────────────────────────
class GenerateTitlesRequest(BaseModel):
"""智能标题生成请求."""
description: str = Field(..., min_length=1, max_length=500, description="视频内容描述")
style: Literal["viral", "emotional", "informative"] = Field(
default="viral",
description="标题风格:viral爆款 / emotional情感 / informative信息",
)
count: int = Field(default=5, ge=3, le=10, description="生成数量,3-10个")
class GenerateTitlesResponse(BaseModel):
"""智能标题生成响应."""
titles: List[str] = Field(..., description="生成的标题列表")
style: str = Field(..., description="实际使用的风格")
source: str = Field(..., description="来源:doubao 或 fallback")
description: str = Field(..., description="原始描述")
class TitleStyleInfo(BaseModel):
"""标题风格信息."""
key: str
name: str
description: str
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
class AssetMatchItem(BaseModel):
"""待匹配素材项."""
id: str = Field(..., description="素材ID")
name: str = Field(default="", description="素材名称")
tags: List[str] = Field(default_factory=list, description="标签列表")
description: str = Field(default="", description="素材描述")
class SemanticMatchRequest(BaseModel):
"""语义匹配请求."""
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
class SemanticMatchResultItem(AssetMatchItem):
"""匹配结果项."""
match_score: float = Field(..., description="匹配度评分 0-1")
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
class SemanticMatchResponse(BaseModel):
"""语义匹配响应."""
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
source: str = Field(..., description="来源:doubao / fallback")
description: str = Field(..., description="原始描述")
total: int = Field(..., description="输入素材总数")
# ── 路由 ────────────────────────────────────────────────────────────────────
@router.post("/titles/generate", response_model=GenerateTitlesResponse)
def generate_titles(request: GenerateTitlesRequest):
"""生成智能标题.
根据视频描述生成指定风格的标题,支持爆款、情感、信息三种风格。
未配置豆包 API Key 时自动降级为本地规则生成。
"""
result = generate_smart_titles(
description=request.description,
style=request.style,
count=request.count,
)
return GenerateTitlesResponse(**result)
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
def list_title_styles():
"""获取支持的标题风格列表."""
return [
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
for key, info in TITLE_STYLES.items()
]
@router.post("/assets/match", response_model=SemanticMatchResponse)
def match_assets(request: SemanticMatchRequest):
"""智能素材语义匹配.
根据用户描述,对素材列表做语义匹配并按匹配度排序。
未配置豆包 API Key 时自动降级为关键词匹配。
- 支持最多 100 个素材同时匹配
- 返回 match_score (0-1),按降序排列
- top_k 可限制返回数量
"""
# 转为 dict 传给服务层
assets_dict = [asset.model_dump() for asset in request.assets]
result = semantic_match_assets(
description=request.description,
assets=assets_dict,
top_k=request.top_k,
)
return SemanticMatchResponse(
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
source=result["source"],
description=result["description"],
total=result["total"],
)
+28 -8
View File
@@ -30,6 +30,7 @@ from app.schemas.generation_task import (
GenerationTaskResponse,
ListGenerationTasksResponse,
)
from app.services.smart_asset_selector import SmartAssetSelector
from fastapi import APIRouter, Depends, HTTPException
from packages.application import (
@@ -59,6 +60,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
@@ -104,7 +106,7 @@ def _select_assets_from_library(
Args:
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns:
@@ -122,17 +124,19 @@ def _select_assets_from_library(
return [a.id for a in selected]
if mode == "smart":
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
sorted_assets = sorted(
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
# 注意:这里使用简单的 quality_score 排序保持向后兼容
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
scored_assets = sorted(
ready_video_assets,
key=lambda a: (
a.quality_score if a.quality_score is not None else 0.0,
a.duration if a.duration is not None else 0.0,
-(a.quality_score if a.quality_score is not None else 0.0),
-(getattr(a, "duration", 0.0) or 0.0),
),
reverse=True,
)
selected = sorted_assets if count <= 0 else sorted_assets[:count]
return [a.id for a in selected]
if count > 0:
scored_assets = scored_assets[:count]
return [a.id for a in scored_assets]
# 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets]
@@ -223,6 +227,20 @@ def create_generation_task(
mode=request.asset_select_mode,
count=request.asset_select_count,
)
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id)
if assets:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
if not resolved_asset_ids:
raise HTTPException(
status_code=422,
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
@@ -270,6 +288,7 @@ def create_generation_task(
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
resolution=request.resolution,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
)
@@ -408,6 +427,7 @@ def retry_generation_task(
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
)
)
try:
+298
View File
@@ -0,0 +1,298 @@
"""视频分享 API 路由."""
from __future__ import annotations
import logging
from typing import Optional
from app.api.routes._helpers import format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_db_session, get_generated_video_repository
from app.schemas.video_share import (
CreateShareRequest,
ShareAccessResponse,
ShareListResponse,
ShareMetaResponse,
ShareResponse,
UpdateShareRequest,
VerifySharePasswordRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.video_share_repository import (
SQLAlchemyVideoShareRepository,
)
from packages.application.video_share.commands import (
CreateShareCommand,
UpdateShareCommand,
)
from packages.application.video_share.use_cases import (
AccessShareUseCase,
CreateShareUseCase,
GetShareByTokenUseCase,
InvalidPasswordError,
ListSharesByUserUseCase,
ListSharesByVideoUseCase,
NotFoundError,
PasswordRequiredError,
RecordShareDownloadUseCase,
RevokeShareUseCase,
ShareExpiredError,
UpdateShareUseCase,
VideoNotFoundError,
)
from packages.ports.generated_video_repository import GeneratedVideoRepository
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_share_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyVideoShareRepository:
return SQLAlchemyVideoShareRepository(session)
def _to_share_response(share) -> ShareResponse:
return ShareResponse(
id=share.id,
video_id=share.video_id,
share_token=share.share_token,
has_password=share.has_password,
expires_at=share.expires_at,
view_count=share.view_count,
download_count=share.download_count,
is_active=share.is_active,
created_at=format_utc_datetime(share.created_at),
updated_at=format_utc_datetime(share.updated_at),
)
# ── 用户侧:创建/管理分享 ──────────────────────────────────
@router.post("/videos/{video_id}/share", response_model=ShareResponse)
def create_share(
video_id: str,
request: CreateShareRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
) -> ShareResponse:
"""为视频创建分享链接."""
use_case = CreateShareUseCase(share_repo, video_repo)
try:
share = use_case.execute(
CreateShareCommand(
video_id=video_id,
user_id=authenticated_user.user.id,
password=request.password,
expires_at=request.expires_at,
)
)
except VideoNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
logger.info(
"Share created: video_id=%s share_id=%s token=%s user=%s",
video_id,
share.id,
share.share_token,
authenticated_user.user.id,
)
return _to_share_response(share)
@router.get("/videos/{video_id}/shares", response_model=ShareListResponse)
def list_video_shares(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> ShareListResponse:
"""获取某个视频的所有分享记录."""
use_case = ListSharesByVideoUseCase(share_repo)
items = use_case.execute(video_id, authenticated_user.user.id)
return ShareListResponse(
items=[_to_share_response(s) for s in items],
total=len(items),
skip=0,
limit=len(items),
)
@router.get("/shares", response_model=ShareListResponse)
def list_user_shares(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> ShareListResponse:
"""获取用户创建的所有分享记录."""
use_case = ListSharesByUserUseCase(share_repo)
items, total = use_case.execute(authenticated_user.user.id, skip=skip, limit=limit)
return ShareListResponse(
items=[_to_share_response(s) for s in items],
total=total,
skip=skip,
limit=limit,
)
@router.patch("/shares/{share_id}", response_model=ShareResponse)
def update_share(
share_id: str,
request: UpdateShareRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> ShareResponse:
"""更新分享配置(密码、有效期等)."""
use_case = UpdateShareUseCase(share_repo)
try:
share = use_case.execute(
UpdateShareCommand(
share_id=share_id,
user_id=authenticated_user.user.id,
password=request.password,
expires_at=request.expires_at,
)
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_share_response(share)
@router.delete(
"/shares/{share_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
)
def revoke_share(
share_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> Response:
"""撤销/删除分享链接."""
use_case = RevokeShareUseCase(share_repo)
try:
use_case.execute(share_id, authenticated_user.user.id)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ── 公开侧:访问分享内容(无需登录) ──────────────────────
@router.get("/share/{token}/meta", response_model=ShareMetaResponse)
def get_share_meta(
token: str,
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
) -> ShareMetaResponse:
"""获取分享元信息(不需要密码,用于分享页加载前判断)。"""
use_case = GetShareByTokenUseCase(share_repo)
try:
share = use_case.execute(token)
except (NotFoundError, ShareExpiredError) as e:
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
video = video_repo.get(share.video_id)
video_name = video.name if video else ""
video_duration = video.duration if video else 0.0
thumbnail_url = None
if video and video.thumbnail_url:
try:
thumbnail_url = storage.get_download_url(video.thumbnail_url)
except Exception:
thumbnail_url = video.thumbnail_url
return ShareMetaResponse(
share_token=share.share_token,
has_password=share.has_password,
is_expired=share.is_expired,
is_active=share.is_active,
video_name=video_name,
video_duration=video_duration,
thumbnail_url=thumbnail_url,
created_at=format_utc_datetime(share.created_at),
)
@router.post("/share/{token}/access", response_model=ShareAccessResponse)
def access_share(
token: str,
request: Optional[VerifySharePasswordRequest] = None,
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
) -> ShareAccessResponse:
"""访问分享内容(验证密码后返回视频信息+播放/下载地址)。"""
use_case = AccessShareUseCase(share_repo, video_repo)
password = request.password if request else None
try:
result = use_case.execute(token, password=password)
except NotFoundError as e:
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
except ShareExpiredError as e:
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
except PasswordRequiredError as e:
raise HTTPException(status_code=403, detail="需要访问密码") from e
except InvalidPasswordError as e:
raise HTTPException(status_code=403, detail="密码错误") from e
except VideoNotFoundError as e:
raise HTTPException(status_code=404, detail="视频不存在") from e
# 生成下载URL
download_url = None
if result.video.file_url:
try:
download_url = storage.get_download_url(result.video.file_url)
except Exception:
download_url = result.video.file_url
# 缩略图URL
thumbnail_url = None
if result.video.thumbnail_url:
try:
thumbnail_url = storage.get_download_url(result.video.thumbnail_url)
except Exception:
thumbnail_url = result.video.thumbnail_url
return ShareAccessResponse(
share=_to_share_response(result.share),
video_name=result.video.name,
video_duration=result.video.duration,
video_size=result.video.file_size,
thumbnail_url=thumbnail_url,
download_url=download_url,
password_verified=result.password_verified,
)
@router.post("/share/{token}/download")
def record_share_download(
token: str,
request: Optional[VerifySharePasswordRequest] = None,
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> dict:
"""记录分享下载(下载计数+1)。"""
use_case = RecordShareDownloadUseCase(share_repo)
password = request.password if request else None
try:
use_case.execute(token, password=password)
except NotFoundError as e:
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
except ShareExpiredError as e:
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
except InvalidPasswordError as e:
raise HTTPException(status_code=403, detail="密码错误") from e
return {"success": True}
+6
View File
@@ -46,6 +46,11 @@ class CreateGenerationTaskRequest(BaseModel):
le=5,
description="最大自动重试次数,0表示不自动重试,最大5次",
)
# ── 输出分辨率 ──
resolution: str = Field(
default="",
description="输出分辨率,格式为 WIDTHxHEIGHT,如 1280x720、1080x1920。为空使用默认 1280x720",
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -74,6 +79,7 @@ class GenerationTaskResponse(BaseModel):
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
resolution: str = ""
status: str
progress: float
result_count: int
+92
View File
@@ -0,0 +1,92 @@
"""视频分享相关 schemas."""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class CreateShareRequest(BaseModel):
"""创建分享请求."""
password: Optional[str] = Field(
None,
description="访问密码(可选,不设置则无需密码)",
min_length=0,
max_length=50,
)
expires_at: Optional[datetime] = Field(
None,
description="过期时间(可选,不设置则永久有效)",
)
class UpdateShareRequest(BaseModel):
"""更新分享配置请求."""
password: Optional[str] = Field(
None,
description="新密码(传空字符串清除密码,不传则不修改)",
max_length=50,
)
expires_at: Optional[datetime] = Field(
None,
description="新的过期时间(不传则不修改)",
)
class VerifySharePasswordRequest(BaseModel):
"""验证分享密码请求."""
password: str = Field(..., description="访问密码")
class ShareResponse(BaseModel):
"""分享记录响应."""
id: str
video_id: str
share_token: str
has_password: bool = False
expires_at: Optional[datetime] = None
view_count: int = 0
download_count: int = 0
is_active: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class ShareListResponse(BaseModel):
"""分享列表响应."""
items: List[ShareResponse]
total: int = 0
skip: int = 0
limit: int = 20
class ShareAccessResponse(BaseModel):
"""分享访问成功响应(含视频信息)."""
share: ShareResponse
video_name: str
video_duration: float = 0.0
video_size: int = 0
thumbnail_url: Optional[str] = None
download_url: Optional[str] = None
password_verified: bool = True
class ShareMetaResponse(BaseModel):
"""分享元信息响应(访问前获取,用于判断是否需要密码)。"""
share_token: str
has_password: bool = False
is_expired: bool = False
is_active: bool = True
video_name: str = ""
video_duration: float = 0.0
thumbnail_url: Optional[str] = None
created_at: Optional[datetime] = None
+535
View File
@@ -0,0 +1,535 @@
"""统一 AI 服务层 — 豆包大模型接入.
提供基于字节跳动豆包大模型的 AI 能力:
- 智能标题生成(爆款/情感/信息三种风格)
- 后续扩展:智能素材匹配、AI 推荐片段编排等
设计原则:
1. 无 API Key 或调用失败时自动降级为本地模拟,不阻塞主流程
2. 统一的客户端封装,新增能力只需加方法
3. 所有模型相关配置集中在 Settings
"""
from __future__ import annotations
import json
import logging
import math
import random
from typing import Any, Dict, List, Optional
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
# ── 智能标题风格定义 ─────────────────────────────────────────────────────────
TITLE_STYLES = {
"viral": {
"name": "爆款",
"description": "吸引点击、引发好奇的爆款标题,带有数字、疑问或反差感",
"examples": [
"3个方法让你效率翻倍,第2个最绝",
"为什么越努力越穷?真相扎心了",
"看完这个,我删掉了手机里一半的APP",
],
},
"emotional": {
"name": "情感",
"description": "触动人心、引发共鸣的情感向标题",
"examples": [
"那些年我们一起追过的梦想",
"生活不易,但请相信光",
"致每一个在城市里打拼的你",
],
},
"informative": {
"name": "信息",
"description": "清晰直白、传递核心信息的干货标题",
"examples": [
"2026年最新个税政策解读,一文讲透",
"新手剪辑入门:从0到1完整指南",
"产品对比:10款热门手机深度评测",
],
},
}
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
def _generate_titles_fallback(
description: str,
style: str = "viral",
count: int = 5,
) -> List[str]:
"""本地降级:基于模板规则生成标题.
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
"""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
examples = style_info["examples"]
# 从描述中提取关键词(取前几个词)
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
keyword = keywords[0] if keywords else "精彩内容"
# 基于模板生成
templates = [
f"{keyword}{examples[0][:10]}...",
f"{keyword}{examples[1]}",
f"关于{keyword},你不知道的3件事",
f"{keyword}入门指南,新手必看",
f"深度解析:{keyword}背后的秘密",
f"{keyword}怎么做?手把手教你",
f"干货分享 | {keyword}全攻略",
f"建议收藏:{keyword}实用技巧",
f"{keyword}避坑指南,别再踩雷了",
f"一分钟搞懂{keyword}",
]
random.shuffle(templates)
return templates[: min(count, len(templates))]
def _parse_titles_from_response(content: str) -> List[str]:
"""从模型返回中解析标题列表.
支持多种返回格式:
- JSON 数组: ["标题1", "标题2"]
- 编号列表: 1. 标题1 / 2. 标题2
- 换行分隔: 标题1\n标题2
- 带破折号: - 标题1
"""
if not content:
return []
# 尝试解析 JSON
try:
# 清理可能的 markdown 代码块标记
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if isinstance(data, list):
return [str(item).strip() for item in data if str(item).strip()]
if isinstance(data, dict) and "titles" in data:
titles = data["titles"]
if isinstance(titles, list):
return [str(t).strip() for t in titles if str(t).strip()]
except (json.JSONDecodeError, ValueError):
pass
# 尝试按行解析
titles: List[str] = []
for line in content.strip().split("\n"):
line = line.strip()
if not line:
continue
# 去掉编号前缀 "1. " "1、" "1"
import re
line = re.sub(r"^[\d]+[\.、\)]\s*", "", line)
# 去掉破折号前缀 "- " "• "
line = re.sub(r"^[-•·]\s*", "", line)
# 去掉引号
line = line.strip('"').strip("'").strip("「」")
if line and len(line) < 100: # 过滤过长的行
titles.append(line)
return titles
def generate_smart_titles(
description: str,
style: str = "viral",
count: int = 5,
) -> Dict[str, Any]:
"""生成智能标题.
Args:
description: 视频内容描述
style: 标题风格 viral/emotional/informative
count: 生成数量(5-10
Returns:
{
"titles": [...],
"style": "viral",
"source": "doubao" | "fallback", # 实际来源
"description": "...",
}
"""
# 参数校验与边界处理
if style not in TITLE_STYLES:
style = "viral"
count = max(3, min(10, count)) # 3-10 个
description = (description or "").strip()
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级生成标题")
titles = _generate_titles_fallback(description, style, count)
return {
"titles": titles,
"style": style,
"source": "fallback",
"description": description,
}
style_info = TITLE_STYLES[style]
system_prompt = (
f"你是一个专业的短视频标题创作专家,擅长根据视频内容生成吸引人的标题。\n"
f"请根据以下视频描述,生成{count}{style_info['name']}风格的标题。\n"
f"风格说明:{style_info['description']}\n"
f"要求:\n"
f"1. 每个标题控制在8-25字之间\n"
f"2. 直接返回JSON数组格式,不要其他文字\n"
f"3. 标题要贴合内容,有吸引力"
)
user_prompt = f"视频描述:{description}\n\n请生成标题:"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.8,
max_tokens=512,
)
if result:
titles = _parse_titles_from_response(result)
if len(titles) >= 2: # 至少解析出2个才算成功
titles = titles[:count]
logger.info(
"豆包智能标题生成成功: style=%s count=%d description=%s...",
style,
len(titles),
description[:20],
)
return {
"titles": titles,
"style": style,
"source": "doubao",
"description": description,
}
logger.warning("豆包返回内容解析失败,降级到本地生成: %s", result[:100])
# 降级到本地生成
titles = _generate_titles_fallback(description, style, count)
return {
"titles": titles,
"style": style,
"source": "fallback",
"description": description,
}
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
def _semantic_match_fallback(
description: str,
assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""本地降级:基于关键词的简单匹配.
计算描述中的关键词与素材名称/标签/描述的重叠度,
作为匹配度评分。0-1分。
"""
import re
# 提取关键词(中文按2字以上片段,英文按单词)
desc = description.lower()
# 简单分词:提取2字以上的中文字符串和英文单词
keywords = set()
# 英文单词
for word in re.findall(r"[a-zA-Z]{3,}", desc):
keywords.add(word)
# 中文2-4字片段
for i in range(len(desc)):
for j in range(i + 2, min(i + 5, len(desc) + 1)):
fragment = desc[i:j]
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
keywords.add(fragment)
if not keywords:
# 没有关键词时给所有素材中等分数
for asset in assets:
asset["match_score"] = 0.5
asset["match_reason"] = "fallback_default"
return assets
results = []
for asset in assets:
# 组合素材的文本信息:名称 + 标签 + 描述
asset_text_parts = [
str(asset.get("name", "")).lower(),
" ".join(str(t) for t in asset.get("tags", [])).lower(),
str(asset.get("description", "")).lower(),
]
asset_text = " | ".join(asset_text_parts)
# 计算匹配度:命中关键词占比 + 稀有关键词加权
hit_count = 0
hit_keywords = []
for kw in keywords:
if kw in asset_text:
hit_count += 1
hit_keywords.append(kw)
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
# 名称命中加分(名称匹配更重要)
name = str(asset.get("name", "")).lower()
name_hits = sum(1 for kw in hit_keywords if kw in name)
name_bonus = min(0.2, name_hits * 0.05)
score = min(1.0, base_score * 0.8 + name_bonus)
score = round(score, 3)
results.append({
**asset,
"match_score": score,
"match_reason": "fallback_keyword",
})
# 按匹配度降序
results.sort(key=lambda x: x["match_score"], reverse=True)
return results
def _parse_semantic_match_response(
content: str,
asset_ids: List[str],
) -> Optional[Dict[str, float]]:
"""从模型返回中解析素材匹配度.
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
score 范围 0-1。
"""
if not content:
return None
# 尝试解析 JSON
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
result: Dict[str, float] = {}
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
if isinstance(data, dict):
if "matches" in data and isinstance(data["matches"], list):
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
for item in data["matches"]:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
else:
for key, value in data.items():
if isinstance(value, (int, float)):
result[str(key)] = max(0.0, min(1.0, float(value)))
# 格式3: [{"asset_id": "...", "score": 0.8}]
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
return result
except (json.JSONDecodeError, ValueError):
pass
return None
def semantic_match_assets(
description: str,
assets: List[Dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
"""智能素材语义匹配.
根据用户描述,评估每个素材的语义匹配度并排序。
Args:
description: 用户描述的目标视频内容
assets: 素材列表,每个素材需含 id/name/tags/description 等字段
top_k: 返回前K个,0表示返回全部
Returns:
{
"matches": [{"asset_id": ..., "match_score": ..., ...}],
"source": "doubao" | "fallback",
"description": "...",
"total": 总数,
}
"""
description = (description or "").strip()
if not assets:
return {"matches": [], "source": "fallback", "description": description, "total": 0}
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级做素材语义匹配")
matched = _semantic_match_fallback(description, assets)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "fallback",
"description": description,
"total": len(assets),
}
# 构建素材信息(控制 token 数量)
asset_summaries = []
for asset in assets[:50]: # 最多传50个素材给模型
aid = asset.get("id", "")
name = asset.get("name", "")[:50]
tags = asset.get("tags", [])
tags_str = ",".join(str(t) for t in tags[:5])
desc = str(asset.get("description", ""))[:80]
asset_summaries.append(
f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}"
)
asset_ids = [str(a.get("id", "")) for a in assets[:50]]
system_prompt = (
"你是一个专业的视频素材匹配助手。"
"根据用户的视频目标描述,评估每个素材的匹配程度。\n"
"评分规则:\n"
"- 0.0-0.3: 完全不相关\n"
"- 0.3-0.6: 有一定关联但不够匹配\n"
"- 0.6-0.8: 比较匹配,适合使用\n"
"- 0.8-1.0: 高度匹配,非常适合\n"
"只返回JSON对象,key为素材ID,value为匹配分数(0-1之间的小数)。"
"不要其他文字说明。"
)
user_prompt = (
f"目标视频描述:{description}\n\n"
f"素材列表:\n" + "\n".join(asset_summaries) +
f"\n\n请返回每个素材的匹配分数JSON"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.3,
max_tokens=1024,
)
if result:
scores = _parse_semantic_match_response(result, asset_ids)
if scores:
# 把评分填回素材
matched = []
for asset in assets:
aid = str(asset.get("id", ""))
score = scores.get(aid, 0.3) # 没评分的给默认偏低分
matched.append({
**asset,
"match_score": round(score, 3),
"match_reason": "doubao_semantic",
})
matched.sort(key=lambda x: x["match_score"], reverse=True)
logger.info(
"豆包语义匹配完成: assets=%d top_score=%.2f description=%s...",
len(matched),
matched[0]["match_score"] if matched else 0,
description[:20],
)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "doubao",
"description": description,
"total": len(assets),
}
logger.warning("豆包语义匹配返回解析失败,降级到本地: %s", result[:100])
# 降级
matched = _semantic_match_fallback(description, assets)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "fallback",
"description": description,
"total": len(assets),
}
# ── 单例入口 ─────────────────────────────────────────────────────────────────
def get_ai_service() -> "AIService":
"""获取 AI 服务单例."""
global _ai_service
if _ai_service is None:
_ai_service = AIService()
return _ai_service
_ai_service: Optional["AIService"] = None
class AIService:
"""AI 服务统一入口,便于后续扩展更多能力."""
def __init__(self) -> None:
self._client = get_doubao_client()
@property
def is_available(self) -> bool:
return self._client.is_available
def generate_titles(
self,
description: str,
style: str = "viral",
count: int = 5,
) -> Dict[str, Any]:
return generate_smart_titles(description, style, count)
def semantic_match(
self,
description: str,
assets: List[Dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
return semantic_match_assets(description, assets, top_k)
+326
View File
@@ -0,0 +1,326 @@
"""SmartAssetSelector — 智能素材选择服务.
根据多维度评分从素材库中自动选择最优视频素材,
用于一键生成等需要自动选取素材的场景。
评分维度(加权求和,总分 0-1):
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
特性:
- 最低质量分门槛:自动过滤低质量素材
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
- 兼容全部模式:素材库模式和项目模式都可用
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# ── 评分权重 ──────────────────────────────────────────────────────────────────
_WEIGHT_QUALITY = 0.5
_WEIGHT_RESOLUTION = 0.2
_WEIGHT_DURATION = 0.2
_WEIGHT_BITRATE = 0.1
# ── 评分参数 ──────────────────────────────────────────────────────────────────
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
_OPTIMAL_DURATION_MAX = 30.0
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
# 长素材:> 15s
@dataclass
class SmartSelectResult:
"""智能选择结果."""
selected_ids: list[str]
total_candidates: int
filtered_out: int # 被质量门槛过滤的数量
avg_score: float
details: list[AssetScoreDetail]
@dataclass
class AssetScoreDetail:
"""单个素材的评分详情."""
asset_id: str
total_score: float
quality_score: float
resolution_score: float
duration_score: float
bitrate_score: float
duration: float | None
class SmartAssetSelector:
"""智能素材选择器.
从一组素材中按综合评分选择最优的 N 个,
同时保证时长分布的多样性。
"""
def __init__(
self,
min_quality_score: float = _MIN_QUALITY_SCORE,
target_width: int = _TARGET_WIDTH,
target_height: int = _TARGET_HEIGHT,
):
self.min_quality_score = min_quality_score
self.target_width = target_width
self.target_height = target_height
# ── 公开方法 ──────────────────────────────────────────────────────────────
def select(
self,
assets: list,
count: int = 0,
*,
ensure_diversity: bool = True,
) -> SmartSelectResult:
"""从素材列表中智能选择最优素材.
Args:
assets: Asset 实体列表(需要有 id/quality_score/width/height/duration/file_size 属性)
count: 选取数量,0 表示全部符合条件的
ensure_diversity: 是否保证时长多样性(默认开启)
Returns:
SmartSelectResult 选择结果
"""
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
candidates = []
filtered_out = 0
for asset in assets:
status = getattr(asset, "status", None)
status_val = status.value if hasattr(status, "value") else str(status)
if status_val != "ready":
continue
mime_type = getattr(asset, "mime_type", "") or ""
if not mime_type.startswith("video"):
continue
quality = getattr(asset, "quality_score", None)
if quality is not None and quality < self.min_quality_score:
filtered_out += 1
continue
candidates.append(asset)
if not candidates:
return SmartSelectResult(
selected_ids=[],
total_candidates=0,
filtered_out=filtered_out,
avg_score=0.0,
details=[],
)
# 2. 对每个候选素材评分
scored: list[AssetScoreDetail] = []
for asset in candidates:
detail = self._score_asset(asset)
scored.append(detail)
# 3. 按总分降序排列
scored.sort(key=lambda d: d.total_score, reverse=True)
# 4. 多样性选择(如果需要且数量有限制)
if ensure_diversity and count > 0 and len(scored) > count:
selected = self._diverse_selection(scored, count)
else:
# 无数量限制或不要求多样性,直接按排名取
selected = scored if count <= 0 else scored[:count]
avg_score = sum(d.total_score for d in selected) / len(selected) if selected else 0.0
result = SmartSelectResult(
selected_ids=[d.asset_id for d in selected],
total_candidates=len(candidates),
filtered_out=filtered_out,
avg_score=avg_score,
details=selected,
)
logger.info(
"智能素材选择完成: 候选=%d, 过滤=%d, 选中=%d, 平均分=%.3f",
result.total_candidates,
result.filtered_out,
len(result.selected_ids),
result.avg_score,
)
return result
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _score_asset(self, asset) -> AssetScoreDetail:
"""对单个素材进行多维度评分."""
# 质量分
quality = getattr(asset, "quality_score", None)
quality_score = (quality / 100.0) if quality is not None else 0.5
# 分辨率评分:越接近目标分辨率得分越高
width = getattr(asset, "width", None)
height = getattr(asset, "height", None)
resolution_score = self._score_resolution(width, height)
# 时长评分:在最佳区间内得分高,过短过长扣分
duration = getattr(asset, "duration", None)
duration_score = self._score_duration(duration)
# 码率评分:用 file_size/duration 估算,适中得分高
file_size = getattr(asset, "file_size", 0) or 0
bitrate_score = self._score_bitrate(file_size, duration)
# 加权总分
total = (
_WEIGHT_QUALITY * quality_score
+ _WEIGHT_RESOLUTION * resolution_score
+ _WEIGHT_DURATION * duration_score
+ _WEIGHT_BITRATE * bitrate_score
)
return AssetScoreDetail(
asset_id=asset.id,
total_score=round(total, 4),
quality_score=round(quality_score, 4),
resolution_score=round(resolution_score, 4),
duration_score=round(duration_score, 4),
bitrate_score=round(bitrate_score, 4),
duration=duration,
)
def _score_resolution(self, width: int | None, height: int | None) -> float:
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
if width is None or height is None or width <= 0 or height <= 0:
return 0.5 # 未知分辨率给中评分
target_pixels = self.target_width * self.target_height
actual_pixels = width * height
# 计算像素数比例
ratio = actual_pixels / target_pixels
if ratio >= 1.0:
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
return 1.0
else:
# 低于目标分辨率:线性衰减,但最低不低于 0.1
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
score = 0.3 + 0.7 * ratio
return max(0.1, min(1.0, score))
def _score_duration(self, duration: float | None) -> float:
"""时长评分:3-30秒最佳,过短或过长都扣分."""
if duration is None or duration <= 0:
return 0.5 # 未知时长给中评分
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
# 最佳区间:满分
return 1.0
if duration < _OPTIMAL_DURATION_MIN:
# 太短:线性衰减,1秒以下给 0.3
ratio = duration / _OPTIMAL_DURATION_MIN
return 0.3 + 0.7 * ratio
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
excess = duration - _OPTIMAL_DURATION_MAX
penalty = min(0.8, excess / 10.0 * 0.1)
return max(0.2, 1.0 - penalty)
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
if not file_size or not duration or duration <= 0:
return 0.5 # 未知给中评分
# 估算码率(bps
bitrate = (file_size * 8) / duration
# 最佳码率范围:2-8 Mbps
optimal_low = 2_000_000 # 2 Mbps
optimal_high = 8_000_000 # 8 Mbps
if optimal_low <= bitrate <= optimal_high:
return 1.0
if bitrate < optimal_low:
# 码率太低:线性衰减
ratio = bitrate / optimal_low
return 0.3 + 0.7 * ratio
# 码率太高(文件太大):适度扣分
excess = bitrate / optimal_high - 1.0
penalty = min(0.5, excess * 0.2)
return max(0.5, 1.0 - penalty)
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
"""多样性选择:按时长分桶,保证每个桶都有素材.
策略:
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
2. 每个桶配额 = max(1, count / 3)
3. 先从每桶按配额取最高分的
4. 剩余名额从全局最高分中取(不重复)
"""
# 分桶
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
medium_bucket = [
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
]
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
unknown_bucket = [d for d in scored if d.duration is None]
buckets = [short_bucket, medium_bucket, long_bucket]
bucket_names = ["short", "medium", "long"]
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
base_quota = max(1, count // 3)
selected: list[AssetScoreDetail] = []
selected_ids: set[str] = set()
# 先按配额从每个桶取
for bucket, _name in zip(buckets, bucket_names, strict=False):
quota = min(base_quota, len(bucket))
if quota <= 0:
continue
# 桶内已经按分数排好序了,直接取前 quota 个
for item in bucket[:quota]:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
return selected
# 剩余名额:从全局(未被选中的)中按分数高低取
remaining_needed = count - len(selected)
if remaining_needed > 0:
for item in scored:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
# 如果还不够(不应该发生),加上未知时长的
if len(selected) < count and unknown_bucket:
for item in unknown_bucket:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
return selected[:count]
Regular → Executable
+17 -1
View File
@@ -20,7 +20,14 @@ export const useLogin = () => {
const data = await mutation.mutateAsync(credentials)
const refreshToken = data.refresh_token ?? null
// 获取用户信息
// 先存 token 到 localStorage,确保后续请求拦截器能取到
// apiClient 拦截器从 localStorage 读 access_token
localStorage.setItem("access_token", data.access_token)
if (refreshToken) {
localStorage.setItem("refresh_token", refreshToken)
}
// 再获取用户信息(这时候请求带 Authorization header
const user = await authApi.getCurrentUser()
setAuth(user, data.access_token, refreshToken)
@@ -54,6 +61,15 @@ export const useWechatCallback = () => {
localStorage.removeItem("wechat_state")
const result = await mutation.mutateAsync({ code, state })
// 先存 token 到 localStorage,确保后续请求拦截器能取到
// apiClient 拦截器从 localStorage 读 access_token
localStorage.setItem("access_token", result.access_token)
if (result.refresh_token) {
localStorage.setItem("refresh_token", result.refresh_token)
}
// 再获取用户信息(这时候请求带 Authorization header
const user = await authApi.getCurrentUser()
setAuth(user, result.access_token, result.refresh_token)
+7 -1
View File
@@ -39,7 +39,13 @@ const WechatCallback: React.FC = () => {
const result = await wechatCallback(code, state)
// 获取用户信息
// 先把 token 存到 localStorage,让请求拦截器能拿到(getCurrentUser 需要带 token
localStorage.setItem("access_token", result.access_token)
if (result.refresh_token) {
localStorage.setItem("refresh_token", result.refresh_token)
}
// 获取用户信息(这时候请求拦截器能拿到 token 了)
const userData = await getCurrentUser()
const user: User = normalizeUser(userData)
setAuth(user, result.access_token, result.refresh_token)
@@ -0,0 +1,79 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import WechatCallback from "@/pages/auth/WechatCallback"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
}
})
vi.mock("@/api/auth", () => ({
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
getCurrentUser: vi.fn(),
normalizeUser: (u: unknown) => u,
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: () => ({
setAuth: vi.fn(),
}),
}))
vi.mock("@/components/auth/BindContactModal", () => ({
default: ({ open }: { open: boolean }) => (
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
BindContactModal
</div>
),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return {
...actual,
message: {
success: vi.fn(),
error: vi.fn(),
},
}
})
describe("WechatCallback Page", () => {
beforeEach(() => {
// mock localStorage,设置wechat_state匹配,让校验通过
const store: Record<string, string> = {
wechat_state: "test_state",
}
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
store[key] = val
})
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
delete store[key]
})
})
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
it("should show loading state while processing", () => {
render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
// wechatCallback 返回 pending promise,所以应该显示 loading
expect(screen.getByText("正在登录...")).toBeTruthy()
})
})
@@ -483,6 +483,7 @@ class RenderAdapter:
progress_cb: ProgressCallback | None = None,
rendered_clip_ids: list[str] | None = None,
failed_clip_ids: list[str] | None = None,
voiceover_audio_path: str | None = None,
) -> RenderAdapterResult:
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
@@ -491,6 +492,7 @@ class RenderAdapter:
Args:
rendered_clip_ids: 成功下载/准备的 clip id 列表(render_plan 从下载阶段传入)
failed_clip_ids: 失败的 clip id 列表
voiceover_audio_path: 配音素材库音频本地路径(一键生成场景使用)
Returns:
RenderAdapterResult
@@ -525,6 +527,7 @@ class RenderAdapter:
output_height=output_height,
bgm_path=bgm_path,
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
)
result = render_svc.render()
@@ -593,6 +596,7 @@ class RenderAdapter:
job_id: str = "",
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
voiceover_audio_path: str | None = None,
) -> RenderAdapterResult:
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
@@ -606,6 +610,7 @@ class RenderAdapter:
job_id: 关联的 Job ID
work_dir: 工作目录,不传则用临时目录
progress_cb: 进度回调
voiceover_audio_path: 配音素材库音频本地路径
Returns:
RenderAdapterResult
@@ -649,6 +654,7 @@ class RenderAdapter:
plan_id=actual_plan_id,
job_id=job_id,
progress_cb=progress_cb,
voiceover_audio_path=voiceover_audio_path,
)
except subprocess.CalledProcessError as exc:
@@ -175,6 +175,7 @@ class UnifiedRenderService:
transition_duration: float = DEFAULT_TRANSITION_DURATION,
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
bgm_path: str | None = None, # BGM 本地文件路径
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
):
self.plan = plan
self.clips = clips
@@ -186,6 +187,7 @@ class UnifiedRenderService:
self.transition_duration = transition_duration
self.asr_service = asr_service
self.bgm_path = bgm_path
self.voiceover_audio_path = voiceover_audio_path
self._transition_engine = TransitionEngine(default_duration=transition_duration)
self._speed_engine = SpeedEngine()
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
@@ -226,6 +228,9 @@ class UnifiedRenderService:
# 3.5 TTS 配音生成(如果配置了)
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
# 3.6 配音素材库音频(如果传入了本地路径)
self._maybe_add_voice_library_layer(layers, video_duration=video_duration)
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
ass_path = self._maybe_generate_ass(video_duration)
@@ -843,6 +848,107 @@ class UnifiedRenderService:
logger.warning("TTS 配音异常,跳过: %s", e)
return False
def _maybe_add_voice_library_layer(
self,
layers: list[RenderLayer],
*,
video_duration: float,
) -> bool:
"""将配音素材库音频作为整段配音加到 audio 图层.
与 TTS 配音共享同一套 audio 图层混音架构,
支持与 BGM、TTS 的音量平衡,不再走独立的后处理 mux 链路。
Returns:
是否成功添加了配音音轨
"""
if not self.voiceover_audio_path:
return False
audio_path = Path(self.voiceover_audio_path)
if not audio_path.exists() or audio_path.stat().st_size == 0:
logger.warning("配音素材库音频文件不存在或为空,跳过: %s", self.voiceover_audio_path)
return False
try:
# 找到或创建 audio 图层
audio_layer = None
for layer in layers:
if layer.role == "audio":
audio_layer = layer
break
if audio_layer is None:
from video_processing.unified_render_service import _LAYER_Z_INDEX # type: ignore
z_index = _LAYER_Z_INDEX.get("audio", 2)
audio_layer = RenderLayer(role="audio", z_index=z_index)
layers.append(audio_layer)
# 配音素材作为整段配音:从 0 开始,覆盖整个视频时长
# 音频不足视频时长时,混音层会按实际长度处理(amix 不自动循环)
vo_clip = ResolvedClip(
clip_id="voice_library_main",
asset_id="voice_library",
local_path=audio_path,
clip_type="audio",
order=len(audio_layer.clips),
start_time=0.0,
duration=video_duration,
config={"volume": 1.0, "voice_library": True},
actual_duration=video_duration,
)
audio_layer.clips.append(vo_clip)
logger.info(
"配音素材库音频已添加到 audio 图层: plan_id=%s duration=%.2fs",
self.plan.id,
video_duration,
)
return True
except Exception as e:
logger.warning("配音素材库音频添加失败,跳过: %s", e)
return False
@staticmethod
def _resolve_watermark_config(plan_config: dict[str, Any] | None) -> WatermarkConfig | None:
"""从 plan config 中解析水印配置,兼容两种存储格式.
支持格式:
1. 嵌套格式:config.watermark = {enabled, mode, text, image_path, ...}
2. 扁平格式(导出配置):config.export.watermark_enabled + config.export.watermark_text
Returns:
WatermarkConfig 或 None(未启用水印时)
"""
if not plan_config or not isinstance(plan_config, dict):
return None
# 格式1: 嵌套 watermark 对象(优先)
wm_data = plan_config.get("watermark")
if isinstance(wm_data, dict) and wm_data:
config = WatermarkConfig.from_dict(wm_data)
if config is not None:
return config
# 格式2: 扁平 export.watermark_enabled + export.watermark_text
export_cfg = plan_config.get("export")
if isinstance(export_cfg, dict) and export_cfg:
enabled = export_cfg.get("watermark_enabled", False)
text = export_cfg.get("watermark_text", "") or ""
if enabled and text:
return WatermarkConfig(
mode="text",
text=str(text),
position=export_cfg.get("watermark_position", "bottom_right"),
opacity=float(export_cfg.get("watermark_opacity", 0.6)),
font_size=int(export_cfg.get("watermark_font_size", 24)),
font_color=str(export_cfg.get("watermark_font_color", "white")),
)
return None
def _can_use_pass_through(self, layers: list[RenderLayer]) -> bool:
"""判断是否可以走直通优化路径。
@@ -864,15 +970,11 @@ class UnifiedRenderService:
if isinstance(plan_config, dict) and plan_config.get("stickers"):
return False
# 有水印时禁用直通(图片水印需要额外输入,统一走 filter_complex
try:
from video_processing.watermark_engine import WatermarkConfig
wm_config = WatermarkConfig.from_dict(plan_config.get("watermark"))
if wm_config is not None and wm_config.validate()[0]:
return False
except Exception:
pass
# 有水印时禁用直通(图片水印需要额外输入,统一走 filter_complex
# 文字水印虽然可以 -vf 叠加,但为了保持路径统一也走 filter_complex
wm_config = UnifiedRenderService._resolve_watermark_config(plan_config)
if wm_config is not None and wm_config.validate()[0]:
return False
# 有调速时仍然可以走直通(视频调速通过 setpts 实现,单输入即可)
@@ -1117,11 +1219,11 @@ class UnifiedRenderService:
filters.append(f"scale={pip_w}:{pip_h}")
elif role == "background":
# background: 铺满裁剪(作为底图,覆盖全屏)
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase")
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=increase")
filters.append(f"crop={self.output_width}:{self.output_height}")
else:
# main / broll: 等比缩放 + 居中留黑边(保持原始比例,不裁剪内容)
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=decrease")
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 调色滤镜
@@ -1304,6 +1406,8 @@ class UnifiedRenderService:
start_time=seg_start,
duration=seg_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
config={**clip_config, "_segment_id": seg.segment_id},
actual_duration=actual_duration,
trim_config=seg.trim,
@@ -1464,16 +1568,12 @@ class UnifiedRenderService:
pip_h = int(self.output_height * _PIP_SCALE)
filters.append(f"scale={pip_w}:{pip_h}")
elif role == "background":
filters.append(
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
)
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=increase")
filters.append(f"crop={self.output_width}:{self.output_height}")
else:
# main / broll: 等比缩放 + 居中留黑边(保持原始比例,不裁剪内容)
# concat 要求所有输入分辨率完全一致,pad 模式确保不同宽高比的素材都能正常拼接
filters.append(
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=decrease"
)
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 调色滤镜(每个 clip 独立的 color grade 配置)
@@ -1561,9 +1661,7 @@ class UnifiedRenderService:
if role in layer_output_labels:
base_label = layer_output_labels[role]
combined_label = f"combined_{role}"
filter_parts.append(
f"[{final_video_label}][{base_label}]" f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
)
filter_parts.append(f"[{final_video_label}][{base_label}]overlay=(W-w)/2:(H-h)/2[{combined_label}]")
final_video_label = combined_label
else:
# 无 background 时,取 broll 或 main 作为基础
@@ -1587,11 +1685,11 @@ class UnifiedRenderService:
20,
)
combined_label = f"combined_{layer.role}"
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
filter_parts.append(f"[{final_video_label}][{overlay_label}]overlay={x}:{y}[{combined_label}]")
final_video_label = combined_label
# 叠加水印(在字幕之前)
watermark_config = WatermarkConfig.from_dict((self.plan.config or {}).get("watermark"))
watermark_config = UnifiedRenderService._resolve_watermark_config(self.plan.config)
if watermark_config is not None:
wm_valid, wm_err = watermark_config.validate()
if wm_valid:
+164 -5
View File
@@ -10,12 +10,14 @@
from __future__ import annotations
import json
import logging
import random
import time
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
@@ -23,17 +25,16 @@ logger = logging.getLogger(__name__)
# ── AI 推荐片段方案 ──────────────────────────────────────────────────────────
def _call_ai_recommend_service(
def _fallback_recommend_clips(
plan_id: str,
template_id: str,
asset_ids: List[str],
editing_mode: str,
target_duration: float,
) -> Dict[str, Any]:
"""调用 AI 推荐服务(stub
"""本地降级推荐方案(原 stub 逻辑).
TODO: 接入真实 AI 服务,分析素材内容并生成推荐方案
当前返回基于模板规则的模拟推荐数据。
当豆包 API 不可用或调用失败时使用,基于模板规则生成模拟推荐数据
"""
# 模拟 AI 分析耗时
time.sleep(0.5)
@@ -87,6 +88,7 @@ def _call_ai_recommend_service(
"config": {},
}
)
order += 1
# 生成推荐 config
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
@@ -101,6 +103,163 @@ def _call_ai_recommend_service(
}
def _parse_recommend_response(
content: str,
asset_ids: List[str],
target_duration: float,
) -> Optional[Dict[str, Any]]:
"""解析豆包返回的推荐方案.
期望返回结构:
{
"clips": [
{"clip_type": "intro/showcase/outro", "order": 0,
"text_content": "...", "duration": 3.0,
"transition_effect": "fade/cut", "asset_id": "...",
"start_time": 0.0, "config": {}}
],
"title": "视频标题",
"confidence": 0.85
}
"""
if not content:
return None
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if not isinstance(data, dict):
return None
clips_data = data.get("clips", [])
if not isinstance(clips_data, list) or len(clips_data) == 0:
return None
clips: List[Dict[str, Any]] = []
for i, clip in enumerate(clips_data):
if not isinstance(clip, dict):
continue
asset_id = str(clip.get("asset_id", ""))
# 校验 asset_id 是否在输入列表中
if asset_id and asset_id not in asset_ids:
asset_id = ""
clips.append({
"clip_type": clip.get("clip_type", "showcase"),
"order": clip.get("order", len(clips)),
"text_content": str(clip.get("text_content", "")),
"duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))),
"transition_effect": clip.get("transition_effect", "cut"),
"asset_id": asset_id,
"start_time": max(0.0, float(clip.get("start_time", 0.0))),
"config": clip.get("config", {}) or {},
})
if not clips:
return None
# 按 order 排序
clips.sort(key=lambda c: c["order"])
# 重新编号 order 保证连续
for i, clip in enumerate(clips):
clip["order"] = i
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
title = data.get("title", "")
if title:
config["title"]["text"] = str(title)
config["title"]["ai_auto"] = True
confidence = float(data.get("confidence", 0.7))
confidence = max(0.0, min(1.0, confidence))
total_duration = round(sum(c["duration"] for c in clips), 1)
return {
"clips": clips,
"config": config,
"total_duration": total_duration,
"confidence": round(confidence, 2),
}
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
return None
def _call_ai_recommend_service(
plan_id: str,
template_id: str,
asset_ids: List[str],
editing_mode: str,
target_duration: float,
) -> Dict[str, Any]:
"""调用 AI 推荐服务生成片段编排方案.
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
"""
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级生成AI推荐方案")
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
# 构建 prompt
system_prompt = (
"你是一个专业的视频剪辑导演助手。"
"根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
"要求:\n"
"1. 片段类型分为三类:intro(开场)、showcase(展示)、outro(结尾)\n"
"2. 每个片段包含:clip_type、order、text_content(字幕/标题文字)、"
"duration(时长秒)、transition_effect(转场效果:fade/cut/dissolve)、"
"asset_id(使用的素材ID)、start_time(素材起始时间秒)\n"
"3. 总时长接近 target_duration,每个素材至少用一次\n"
"4. 转场效果合理分配,不要全用cut\n"
"5. 返回纯JSON,不要其他文字\n"
"返回格式:{\"clips\": [...], \"title\": \"视频标题\", \"confidence\": 0.85}"
)
assets_desc = "\n".join([f" - 素材ID: {aid}" for i, aid in enumerate(asset_ids[:30])])
user_prompt = (
f"剪辑计划ID: {plan_id}\n"
f"模板ID: {template_id}\n"
f"剪辑模式: {editing_mode}\n"
f"目标时长: {target_duration}\n"
f"素材列表(共{len(asset_ids)}个):\n{assets_desc}\n\n"
f"请设计完整的片段编排方案:"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.7,
max_tokens=2048,
)
if result:
parsed = _parse_recommend_response(result, asset_ids, target_duration)
if parsed and len(parsed["clips"]) >= 2:
logger.info(
"豆包AI推荐生成成功: plan_id=%s clips=%d duration=%.1f confidence=%.2f",
plan_id,
len(parsed["clips"]),
parsed["total_duration"],
parsed["confidence"],
)
return parsed
logger.warning("豆包AI推荐返回解析失败,降级到本地方案: %s", result[:100])
# 降级
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
+26 -18
View File
@@ -171,6 +171,7 @@ class _VirtualClip:
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0 # 0 表示使用全局默认值
playback_speed: float = 1.0
status: str = "ready"
config: dict[str, Any] = field(default_factory=dict)
@@ -279,7 +280,7 @@ def _apply_template_clip_effects(
cfg_idx = min(i, len(main_configs) - 1)
template_cfg = main_configs[cfg_idx]
# 1. 转场效果
# 1. 转场效果 + 时长
transition = (
template_cfg.transition_effect.value
if hasattr(template_cfg.transition_effect, "value")
@@ -287,6 +288,16 @@ def _apply_template_clip_effects(
)
if transition and transition != "cut":
clip.transition_effect = transition
# 同步转场时长(模板 clip_config 里的 transition_duration
tpl_cfg = template_cfg.config or {}
tpl_duration = tpl_cfg.get("transition_duration")
if tpl_duration:
try:
dur_val = float(tpl_duration)
if dur_val > 0:
clip.transition_duration = dur_val
except (ValueError, TypeError):
pass
# 2. clip 级效果配置(滤镜、调速等)
template_clip_config = template_cfg.config or {}
@@ -1099,6 +1110,7 @@ def _load_task_info(task_id: str) -> dict | None:
"batch_id": getattr(gen_task, "batch_id", "") or "",
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
"video_title": getattr(gen_task, "video_title", "") or "",
"resolution": getattr(gen_task, "resolution", "") or "",
}
finally:
session.close()
@@ -1157,6 +1169,7 @@ def _render_video(
user_id: str,
temp_path: Path,
output_name: str,
resolution: str = "",
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
@@ -1188,15 +1201,17 @@ def _render_video(
list(template_config.keys()),
)
# 确保输出分辨率配置存在(一键生成默认横屏 1280x720
# RenderAdapter 从 plan.config.export.resolution 读取,
# 如果模板没有配置则用默认值,这里显式设置保持和旧逻辑一致
# 确保输出分辨率配置存在
# 优先级:用户指定 > 模板配置 > 默认 1280x720
plan_cfg = virtual_plan.config or {}
export_cfg = plan_cfg.get("export", {}) or {}
if not export_cfg.get("resolution"):
if resolution:
# 用户在 API 调用时指定的分辨率优先级最高
export_cfg["resolution"] = resolution
elif not export_cfg.get("resolution"):
export_cfg["resolution"] = f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}"
plan_cfg["export"] = export_cfg
virtual_plan.config = plan_cfg
plan_cfg["export"] = export_cfg
virtual_plan.config = plan_cfg
total_duration = sum(c.duration for c in virtual_clips)
logger.info(
@@ -1223,6 +1238,7 @@ def _render_video(
plan_id=f"gen_{task_id}",
job_id=task_id,
work_dir=temp_path,
voiceover_audio_path=voice_path,
)
finally:
db.close()
@@ -1241,17 +1257,8 @@ def _render_video(
render_duration,
)
# 配音混音(素材库音频,后处理混音)
if voice_path:
final_path = temp_path / f"final-{task_id}.mp4"
try:
_mux_audio_track(render_output_path, voice_path, final_path)
output_path = final_path
except Exception as mux_err:
logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err)
output_path = render_output_path
else:
output_path = render_output_path
# 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理
output_path = render_output_path
return output_path, render_duration
@@ -1445,6 +1452,7 @@ def generate_video(self, task_id: str) -> dict:
user_id=user_id,
temp_path=temp_path,
output_name=output_name,
resolution=task_info.get("resolution", ""),
)
if gen_task:
@@ -34,6 +34,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
video_title=getattr(model, "video_title", "") or "",
resolution=getattr(model, "resolution", "") or "",
logs=model.logs or "[]",
created_at=model.created_at,
updated_at=model.updated_at,
@@ -70,6 +71,7 @@ class SQLAlchemyGenerationTaskRepository:
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
video_title=task.video_title or "",
resolution=task.resolution or "",
logs=task.logs,
created_at=task.created_at,
updated_at=task.updated_at,
@@ -230,6 +232,8 @@ class SQLAlchemyGenerationTaskRepository:
model.batch_id = task.batch_id or ""
if hasattr(model, "video_title"):
model.video_title = task.video_title or ""
if hasattr(model, "resolution"):
model.resolution = task.resolution or ""
model.logs = task.logs
self.session.commit()
return task
@@ -291,6 +291,7 @@ class GenerationTaskModel(Base):
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(36), nullable=False, default="", index=True)
video_title = Column(String(255), nullable=False, default="")
resolution = Column(String(20), nullable=False, default="")
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
logs = Column(Text, nullable=False, default="[]", server_default="[]")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -574,3 +575,21 @@ class VerificationCodeModel(Base):
used_at = Column(DateTime, nullable=True)
attempts = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class VideoShareModel(Base):
"""视频分享记录."""
__tablename__ = "video_shares"
id = Column(String(32), primary_key=True)
video_id = Column(String(32), nullable=False, index=True)
user_id = Column(String(32), nullable=False, index=True)
share_token = Column(String(16), nullable=False, unique=True)
password_hash = Column(String(255), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
view_count = Column(Integer, nullable=False, default=0)
download_count = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -67,13 +67,24 @@ class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository):
def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None:
if model is None:
return None
# SQLAlchemy 从数据库读出的 DateTime 是 naive(不带时区),
# 领域模型期望 aware datetime(带 timezone.utc),直接用会报
# "can't compare offset-naive and offset-aware datetimes"
def _ensure_aware(dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
return VerificationCode(
id=model.id,
recipient=model.recipient,
code=model.code,
code_type=model.code_type,
expires_at=model.expires_at,
used_at=model.used_at,
expires_at=_ensure_aware(model.expires_at),
used_at=_ensure_aware(model.used_at),
attempts=model.attempts,
created_at=model.created_at,
created_at=_ensure_aware(model.created_at),
)
+147
View File
@@ -0,0 +1,147 @@
"""视频分享 SQLAlchemy Repository 实现."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import VideoShareModel
from packages.domain.video_share import VideoShare
from packages.ports.video_share_repository import VideoShareRepositoryPort
def _model_to_domain(model: VideoShareModel) -> VideoShare:
return VideoShare(
id=model.id,
video_id=model.video_id,
user_id=model.user_id,
share_token=model.share_token,
password_hash=model.password_hash,
expires_at=model.expires_at,
view_count=model.view_count or 0,
download_count=model.download_count or 0,
is_active=model.is_active if model.is_active is not None else True,
created_at=model.created_at,
updated_at=model.updated_at,
)
class SQLAlchemyVideoShareRepository(VideoShareRepositoryPort):
def __init__(self, session: Session):
self.session = session
def create(self, share: VideoShare) -> VideoShare:
model = VideoShareModel(
id=share.id,
video_id=share.video_id,
user_id=share.user_id,
share_token=share.share_token,
password_hash=share.password_hash,
expires_at=share.expires_at,
view_count=share.view_count,
download_count=share.download_count,
is_active=share.is_active,
created_at=share.created_at,
updated_at=share.updated_at,
)
self.session.add(model)
self.session.commit()
return share
def get_by_token(self, token: str) -> Optional[VideoShare]:
model = self.session.query(VideoShareModel).filter(VideoShareModel.share_token == token).first()
if model is None:
return None
return _model_to_domain(model)
def get_by_id(self, share_id: str, user_id: str) -> Optional[VideoShare]:
model = (
self.session.query(VideoShareModel)
.filter(
VideoShareModel.id == share_id,
VideoShareModel.user_id == user_id,
)
.first()
)
if model is None:
return None
return _model_to_domain(model)
def list_by_video(self, video_id: str, user_id: str) -> List[VideoShare]:
models = (
self.session.query(VideoShareModel)
.filter(
VideoShareModel.video_id == video_id,
VideoShareModel.user_id == user_id,
)
.order_by(VideoShareModel.created_at.desc())
.all()
)
return [_model_to_domain(m) for m in models]
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 20) -> List[VideoShare]:
models = (
self.session.query(VideoShareModel)
.filter(VideoShareModel.user_id == user_id)
.order_by(VideoShareModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [_model_to_domain(m) for m in models]
def count_by_user(self, user_id: str) -> int:
return self.session.query(VideoShareModel).filter(VideoShareModel.user_id == user_id).count()
def update(self, share: VideoShare) -> VideoShare:
model = self.session.query(VideoShareModel).filter(VideoShareModel.id == share.id).first()
if model is None:
return share
model.password_hash = share.password_hash
model.expires_at = share.expires_at
model.is_active = share.is_active
model.view_count = share.view_count
model.download_count = share.download_count
model.updated_at = datetime.now(timezone.utc)
self.session.add(model)
self.session.commit()
return share
def delete(self, share_id: str, user_id: str) -> bool:
model = (
self.session.query(VideoShareModel)
.filter(
VideoShareModel.id == share_id,
VideoShareModel.user_id == user_id,
)
.first()
)
if model is None:
return False
model.is_active = False
model.updated_at = datetime.now(timezone.utc)
self.session.add(model)
self.session.commit()
return True
def increment_view(self, share_id: str) -> None:
self.session.query(VideoShareModel).filter(VideoShareModel.id == share_id).update(
{
"view_count": VideoShareModel.view_count + 1,
"updated_at": datetime.now(timezone.utc),
},
synchronize_session=False,
)
self.session.commit()
def increment_download(self, share_id: str) -> None:
self.session.query(VideoShareModel).filter(VideoShareModel.id == share_id).update(
{
"download_count": VideoShareModel.download_count + 1,
"updated_at": datetime.now(timezone.utc),
},
synchronize_session=False,
)
self.session.commit()
+2
View File
@@ -22,6 +22,7 @@ class CreateGenerationTaskCommand:
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
resolution: str = ""
auto_retry_enabled: bool = False
auto_retry_max: int = 0
@@ -50,6 +51,7 @@ class CreateGenerationTaskUseCase:
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
video_title=command.video_title,
resolution=command.resolution,
auto_retry_enabled=command.auto_retry_enabled,
auto_retry_max=command.auto_retry_max,
)
+35
View File
@@ -0,0 +1,35 @@
"""视频分享 Commands."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class CreateShareCommand:
"""创建分享链接命令."""
video_id: str
user_id: str
password: Optional[str] = None
expires_at: Optional[datetime] = None # None表示永久有效
@dataclass
class VerifySharePasswordCommand:
"""验证分享密码命令."""
share_token: str
password: str
@dataclass
class UpdateShareCommand:
"""更新分享配置命令."""
share_id: str
user_id: str
password: Optional[str] = None # None表示不修改,空字符串表示清除密码
expires_at: Optional[datetime] = None # None表示不修改
+227
View File
@@ -0,0 +1,227 @@
"""视频分享 Use cases."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List, Optional
from packages.application.video_share.commands import (
CreateShareCommand,
UpdateShareCommand,
)
from packages.domain.generated_video import GeneratedVideo
from packages.domain.video_share import VideoShare
from packages.ports.generated_video_repository import GeneratedVideoRepository
from packages.ports.video_share_repository import VideoShareRepositoryPort
class NotFoundError(Exception):
"""分享记录不存在."""
pass
class VideoNotFoundError(Exception):
"""视频不存在."""
pass
class ShareExpiredError(Exception):
"""分享已过期或已撤销."""
pass
class PasswordRequiredError(Exception):
"""需要访问密码."""
pass
class InvalidPasswordError(Exception):
"""密码错误."""
pass
@dataclass
class ShareAccessResult:
"""分享访问结果(验证通过后返回视频信息+分享记录)."""
share: VideoShare
video: GeneratedVideo
password_verified: bool = True
class CreateShareUseCase:
"""创建视频分享链接."""
def __init__(
self,
share_repository: VideoShareRepositoryPort,
video_repository: GeneratedVideoRepository,
) -> None:
self.share_repo = share_repository
self.video_repo = video_repository
def execute(self, command: CreateShareCommand) -> VideoShare:
# 校验视频存在且属于该用户
video = self.video_repo.get(command.video_id)
if video is None:
raise VideoNotFoundError(f"Video {command.video_id} not found")
# 用 user_id 校验(视频的user_id需要匹配)
if hasattr(video, "user_id") and video.user_id and video.user_id != command.user_id:
raise VideoNotFoundError("Video not found")
share = VideoShare.create(
video_id=command.video_id,
user_id=command.user_id,
password=command.password,
expires_at=command.expires_at,
)
return self.share_repo.create(share)
class GetShareByTokenUseCase:
"""通过token获取分享信息(不带视频内容,仅元信息)。
用于分享页加载前判断:是否需要密码、是否过期等。
"""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, token: str) -> VideoShare:
share = self.share_repo.get_by_token(token)
if share is None:
raise NotFoundError(f"Share not found: {token}")
if not share.is_accessible:
raise ShareExpiredError("Share is not accessible")
return share
class AccessShareUseCase:
"""访问分享内容(验证密码+返回视频信息+计数浏览量)。"""
def __init__(
self,
share_repository: VideoShareRepositoryPort,
video_repository: GeneratedVideoRepository,
) -> None:
self.share_repo = share_repository
self.video_repo = video_repository
def execute(self, token: str, password: Optional[str] = None) -> ShareAccessResult:
share = self.share_repo.get_by_token(token)
if share is None:
raise NotFoundError(f"Share not found: {token}")
if not share.is_accessible:
raise ShareExpiredError("Share is not accessible")
# 密码校验
password_verified = True
if share.has_password:
if not password:
raise PasswordRequiredError("Password required")
if not share.verify_password(password):
raise InvalidPasswordError("Invalid password")
password_verified = True
# 获取视频信息
video = self.video_repo.get(share.video_id)
if video is None:
raise VideoNotFoundError("Video not found")
# 浏览量+1
self.share_repo.increment_view(share.id)
share.view_count += 1
return ShareAccessResult(share=share, video=video, password_verified=password_verified)
class ListSharesByVideoUseCase:
"""列出某个视频的所有分享记录."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, video_id: str, user_id: str) -> List[VideoShare]:
return self.share_repo.list_by_video(video_id, user_id)
class ListSharesByUserUseCase:
"""列出用户创建的所有分享记录."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, user_id: str, skip: int = 0, limit: int = 20) -> tuple[List[VideoShare], int]:
items = self.share_repo.list_by_user(user_id, skip=skip, limit=limit)
total = self.share_repo.count_by_user(user_id)
return items, total
class UpdateShareUseCase:
"""更新分享配置(密码、有效期等)."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, command: UpdateShareCommand) -> VideoShare:
share = self.share_repo.get_by_id(command.share_id, command.user_id)
if share is None:
raise NotFoundError(f"Share {command.share_id} not found")
# password=None表示不修改;空字符串表示清除密码
if command.password is not None:
from packages.domain.video_share import _hash_password
if command.password == "":
share.password_hash = None
else:
share.password_hash = _hash_password(command.password)
# expires_at=None表示不修改
if command.expires_at is not None:
if command.expires_at < datetime.now(timezone.utc):
raise ValueError("expires_at cannot be in the past")
share.expires_at = command.expires_at
return self.share_repo.update(share)
class RevokeShareUseCase:
"""撤销/删除分享."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, share_id: str, user_id: str) -> bool:
share = self.share_repo.get_by_id(share_id, user_id)
if share is None:
raise NotFoundError(f"Share {share_id} not found")
return self.share_repo.delete(share_id, user_id)
class RecordShareDownloadUseCase:
"""记录分享下载(下载量+1."""
def __init__(self, share_repository: VideoShareRepositoryPort) -> None:
self.share_repo = share_repository
def execute(self, token: str, password: Optional[str] = None) -> None:
share = self.share_repo.get_by_token(token)
if share is None:
raise NotFoundError(f"Share not found: {token}")
if not share.is_accessible:
raise ShareExpiredError("Share is not accessible")
# 密码校验
if share.has_password:
if not password or not share.verify_password(password):
raise InvalidPasswordError("Invalid password")
self.share_repo.increment_download(share.id)
+3
View File
@@ -91,6 +91,7 @@ class GenerationTask:
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
resolution: str = ""
logs: str = "[]"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -112,6 +113,7 @@ class GenerationTask:
asset_select_mode: str = "",
batch_id: str = "",
video_title: str = "",
resolution: str = "",
auto_retry_enabled: bool = False,
auto_retry_max: int = 0,
) -> "GenerationTask":
@@ -134,6 +136,7 @@ class GenerationTask:
asset_select_mode=asset_select_mode,
batch_id=batch_id,
video_title=video_title.strip(),
resolution=resolution.strip(),
auto_retry_enabled=auto_retry_enabled,
auto_retry_max=auto_retry_max,
)
+108
View File
@@ -0,0 +1,108 @@
"""视频分享领域实体."""
from __future__ import annotations
import secrets
from dataclasses import dataclass, field
from datetime import datetime, timezone
from hashlib import sha256
from typing import Optional
from uuid import uuid4
def _hash_password(password: str) -> str:
"""简单密码哈希(SHA-256 + salt)。
分享链接的密码保护安全级别要求不高,
使用简单的加盐哈希即可,避免引入bcrypt等重依赖。
"""
if not password:
return ""
salt = "xiaoxia_share_salt"
return sha256(f"{salt}:{password}".encode()).hexdigest()
def generate_share_token(length: int = 12) -> str:
"""生成URL友好的分享token."""
# 使用urlsafe的base64,但去掉可能引起歧义的字符
alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
return "".join(secrets.choice(alphabet) for _ in range(length))
@dataclass(slots=True)
class VideoShare:
"""视频分享记录."""
id: str
video_id: str
user_id: str
share_token: str
password_hash: Optional[str] = None
expires_at: Optional[datetime] = None
view_count: int = 0
download_count: int = 0
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))
@classmethod
def create(
cls,
video_id: str,
user_id: str,
*,
password: Optional[str] = None,
expires_at: Optional[datetime] = None,
) -> "VideoShare":
if not video_id.strip():
raise ValueError("video_id cannot be empty")
if not user_id.strip():
raise ValueError("user_id cannot be empty")
if expires_at and expires_at < datetime.now(timezone.utc):
raise ValueError("expires_at cannot be in the past")
return cls(
id=uuid4().hex,
video_id=video_id.strip(),
user_id=user_id.strip(),
share_token=generate_share_token(),
password_hash=_hash_password(password) if password else None,
expires_at=expires_at,
)
@property
def has_password(self) -> bool:
"""是否设置了访问密码."""
return bool(self.password_hash)
@property
def is_expired(self) -> bool:
"""是否已过期."""
if not self.expires_at:
return False
return datetime.now(timezone.utc) > self.expires_at
@property
def is_accessible(self) -> bool:
"""是否可以访问(活跃且未过期)."""
return self.is_active and not self.is_expired
def verify_password(self, password: str) -> bool:
"""验证访问密码."""
if not self.password_hash:
return True # 没有密码直接通过
if not password:
return False
return _hash_password(password) == self.password_hash
def increment_view_count(self) -> None:
"""浏览次数+1."""
self.view_count += 1
def increment_download_count(self) -> None:
"""下载次数+1."""
self.download_count += 1
def revoke(self) -> None:
"""撤销分享."""
self.is_active = False
+62
View File
@@ -0,0 +1,62 @@
"""视频分享 Repository 端口."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, Optional
from packages.domain.video_share import VideoShare
class VideoShareRepositoryPort(ABC):
"""视频分享 Repository 接口."""
@abstractmethod
def create(self, share: VideoShare) -> VideoShare:
"""创建分享记录."""
...
@abstractmethod
def get_by_token(self, token: str) -> Optional[VideoShare]:
"""通过分享token获取分享记录."""
...
@abstractmethod
def get_by_id(self, share_id: str, user_id: str) -> Optional[VideoShare]:
"""通过ID获取分享记录(带用户校验)."""
...
@abstractmethod
def list_by_video(self, video_id: str, user_id: str) -> List[VideoShare]:
"""列出某个视频的所有分享记录."""
...
@abstractmethod
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 20) -> List[VideoShare]:
"""列出用户创建的所有分享记录."""
...
@abstractmethod
def count_by_user(self, user_id: str) -> int:
"""统计用户创建的分享数量."""
...
@abstractmethod
def update(self, share: VideoShare) -> VideoShare:
"""更新分享记录."""
...
@abstractmethod
def delete(self, share_id: str, user_id: str) -> bool:
"""删除分享记录(软删除:is_active=False."""
...
@abstractmethod
def increment_view(self, share_id: str) -> None:
"""浏览次数+1."""
...
@abstractmethod
def increment_download(self, share_id: str) -> None:
"""下载次数+1."""
...
+117
View File
@@ -0,0 +1,117 @@
"""豆包大模型 API 客户端(共享层).
API 和 Worker 两边共用。基于火山引擎方舟平台的 OpenAI 兼容接口。
使用方式:
from packages.shared.ai_client import get_doubao_client
client = get_doubao_client()
if client.is_available:
result = client.chat_completion(messages=[...])
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional
import httpx
from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
class DoubaoClient:
"""豆包大模型 API 客户端.
封装 OpenAI 兼容的 Chat Completion 接口,支持自动重试。
未配置 API Key 时 is_available 为 False,调用方应降级处理。
"""
def __init__(self) -> None:
settings = get_shared_settings()
self.api_key: str = settings.doubao_api_key
self.model: str = settings.doubao_model
self.base_url: str = settings.doubao_base_url.rstrip("/")
self.timeout: int = settings.doubao_timeout
self.max_retries: int = settings.doubao_max_retries
@property
def is_available(self) -> bool:
"""是否可用(配置了 API Key."""
return bool(self.api_key)
def chat_completion(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
) -> Optional[str]:
"""调用 Chat Completion 接口.
Args:
messages: 对话消息列表,[{"role": "user"/"system"/"assistant", "content": "..."}]
temperature: 采样温度,0-2,默认0.7
max_tokens: 最大生成token数,默认1024
Returns:
模型返回的文本内容,失败返回 None
"""
if not self.is_available:
return None
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
last_error: Optional[Exception] = None
for attempt in range(self.max_retries + 1):
try:
response = httpx.post(
url,
headers=headers,
json=payload,
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"]["content"]
return content.strip()
except Exception as e:
last_error = e
if attempt < self.max_retries:
wait = 0.5 * (2**attempt)
logger.warning(
"豆包API调用失败,%.1fs后重试 (第%d/%d次): %s",
wait,
attempt + 1,
self.max_retries + 1,
e,
)
time.sleep(wait)
logger.error("豆包API调用最终失败: %s", last_error)
return None
# ── 单例 ─────────────────────────────────────────────────────────────────────
_client: Optional[DoubaoClient] = None
def get_doubao_client() -> DoubaoClient:
"""获取豆包客户端单例."""
global _client
if _client is None:
_client = DoubaoClient()
return _client
+7
View File
@@ -39,6 +39,13 @@ class SharedSettings(BaseSettings):
# 音色克隆模型名(固定为 voice-enrollment
cosyvoice_clone_model: str = "voice-enrollment"
# 豆包大模型(火山引擎方舟)
doubao_api_key: str = ""
doubao_model: str = "doubao-seed-1-6-250615"
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout: int = 30
doubao_max_retries: int = 2
# Environment
environment: str = "development"
auto_create_schema: bool = False
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# 自动审批:CI全绿后自动approve PR
# 环境变量:GITHUB_TOKEN, REVIEW_TOKEN, PR_NUMBER, PR_HEAD_SHA, GITHUB_API_URL, GITHUB_REPOSITORY
set -eu
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING review
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED)"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑 → 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
exit 0
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
set -eu
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
echo "检查required门禁(与分支保护一致)"
fi
echo
# 初始等待30秒,给CI启动写status的时间
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器
MERGE_405_COUNT=0
MAX_405_RETRIES=10
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得到Worker构建完成
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# CI全绿 → 合并
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "CI全绿,执行自动合并"
echo "等待60秒冷却,给Gitea内部状态同步时间..."
sleep 60
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
fi
sleep 30
continue
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 30
done
echo
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
exit 0
+11 -52
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile
# PR构建专用:只构建不输出,验证Dockerfile能否正常构建
# 无本地缓存(12个runner不共享,反而添乱),只用ACR远程缓存
set -eu
NO_CACHE_FLAG=""
@@ -25,60 +26,18 @@ else
fi
docker buildx inspect --bootstrap
CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-")
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
echo "=== PR Build: build only, no push, read-only cache ==="
echo "=== PR Build: build only, no output, remote cache only ==="
echo "Dockerfile: ${DOCKERFILE}"
echo "Image tag: ${IMAGE_TAG}"
echo ""
build_with_retry() {
local attempt=1
local max_attempts=2
while [ $attempt -le $max_attempts ]; do
local build_output
local exit_code
set +e
build_output=$(docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--load \
. 2>&1)
exit_code=$?
set -e
if [ $exit_code -eq 0 ]; then
echo "$build_output"
return 0
fi
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
echo "$build_output"
echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..."
rm -rf "${LOCAL_CACHE_DIR}"
mkdir -p "${LOCAL_CACHE_DIR}"
docker buildx prune -f -a >/dev/null 2>&1 || true
attempt=$((attempt + 1))
else
echo "$build_output"
return $exit_code
fi
done
echo "Local cache failed, building with registry cache only..."
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--load \
.
}
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
.
build_with_retry
echo ""
echo "PR build OK (not pushed): ${IMAGE_TAG}"
echo "PR build OK (build only, no output): ${IMAGE_TAG}"
+97 -18
View File
@@ -2,11 +2,14 @@
"""
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
作为短作业模式的兜底机制,每5分钟运行一次
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.error
@@ -110,7 +113,55 @@ def has_approval(token, repo, pr_number):
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
def approve_pr(token, repo, pr_number):
def get_ai_review_result(token, repo, pr_number):
"""
检查AI代码审查结果,返回 (has_critical, review_body)
has_critical: 是否有严重问题(需修改的问题 > 0)
review_body: 最新的AI审查评论文本
"""
# AI审查评论标记
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
if code != 200:
return False, None
# 找最新的AI审查评论
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
if not ai_comments:
return False, None
# 按时间排序,取最新的
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
body = latest.get("body", "")
# 解析严重问题数量
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
critical_count = 0
# 方式1:直接匹配数字
match = re.search(r"严重问题数量[:]\s*(\d+)\s*个", body)
if match:
critical_count = int(match.group(1))
else:
# 方式2:数 "需修改的问题" 章节下的条目数
critical_section = re.search(
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
body,
re.DOTALL,
)
if critical_section:
section_text = critical_section.group(1)
# 数编号条目 1. 2. 3.
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
critical_count = len(items)
has_critical = critical_count > 0
return has_critical, body
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
"""审批PR"""
# 创建review
data, code = api_request(
@@ -118,7 +169,7 @@ def approve_pr(token, repo, pr_number):
repo,
f"pulls/{pr_number}/reviews",
method="POST",
data={"event": "PENDING", "body": "CI全绿,自动审批通过。"},
data={"event": "PENDING", "body": reason},
)
if code not in (200, 201):
@@ -137,7 +188,7 @@ def approve_pr(token, repo, pr_number):
repo,
f"pulls/{pr_number}/reviews/{review_id}/events",
method="POST",
data={"event": "APPROVED", "body": "CI全绿,自动审批通过。"},
data={"event": "APPROVED", "body": reason},
)
if code2 in (200, 201):
@@ -149,13 +200,25 @@ def approve_pr(token, repo, pr_number):
repo,
f"pulls/{pr_number}/reviews/{review_id}",
method="POST",
data={"event": "APPROVED", "body": "CI全绿,自动审批通过。"},
data={"event": "APPROVED", "body": reason},
)
if code3 in (200, 201):
return True, "审批提交成功(备用端点)"
return False, f"审批提交失败: HTTP {code2}/{code3}"
def add_pr_label(token, repo, pr_number, label):
"""给PR添加标签"""
data, code = api_request(
token,
repo,
f"issues/{pr_number}/labels",
method="POST",
data={"labels": [label]},
)
return code in (200, 201)
def merge_pr(token, repo, pr_number):
"""合并PRsquash merge"""
# 等待几秒让状态同步
@@ -200,6 +263,7 @@ def main():
parser.add_argument("--merge", action="store_true", help="执行自动合并")
parser.add_argument("--dry-run", default="false", help="试运行模式")
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
args = parser.parse_args()
@@ -233,12 +297,13 @@ def main():
approved_count = 0
merged_count = 0
skipped_count = 0
ai_blocked_count = 0
for pr in prs[: args.max_prs]:
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("ref", "")
base_ref = pr.get("base", {}).get("re", "")
# 跳过draft
if pr.get("draft"):
@@ -269,25 +334,38 @@ def main():
# 检查审批用的CI状态
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
# === AI审查检查 ===
ai_has_critical = False
if not args.skip_ai_review and all_ok and not failed and args.approve:
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
if ai_has_critical:
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
ai_blocked_count += 1
# 给PR打标签便于人工识别
if not dry_run:
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
# === 自动审批 ===
if args.approve and all_ok and not failed:
if args.approve and all_ok and not failed and not ai_has_critical:
if has_approval(args.token, args.repo, pr_num):
print(f" ✅ 已有审批,跳过")
print(" ✅ 已有审批,跳过")
else:
if dry_run:
print(f" 🎯 [DRY-RUN] 将自动审批")
print(" 🎯 [DRY-RUN] 将自动审批")
else:
print(f" 🎯 执行自动审批...")
print(" 🎯 执行自动审批...")
ok, msg = approve_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 审批成功: {msg}")
approved_count += 1
else:
print(f" ❌ 审批失败: {msg}")
elif ai_has_critical:
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
elif failed:
print(f" ❌ CI有失败项,跳过审批")
print(" ❌ CI有失败项,跳过审批")
elif pending:
print(f" ⏳ CI仍在运行,跳过")
print(" ⏳ CI仍在运行,跳过")
# === 自动合并 ===
if args.merge:
@@ -301,9 +379,9 @@ def main():
if merge_ok and approved and not merge_failed:
if dry_run:
print(f" 🎯 [DRY-RUN] 将自动合并")
print(" 🎯 [DRY-RUN] 将自动合并")
else:
print(f" 🎯 执行自动合并...")
print(" 🎯 执行自动合并...")
ok, msg = merge_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 合并成功: {msg}")
@@ -311,18 +389,19 @@ def main():
else:
print(f" ⚠️ 合并失败: {msg}")
elif merge_pending:
print(f" ⏳ 合并条件未满足: CI运行中")
print(" ⏳ 合并条件未满足: CI运行中")
elif merge_failed:
print(f" ❌ 合并条件未满足: CI有失败")
print(" ❌ 合并条件未满足: CI有失败")
elif not approved:
print(f" ⏳ 合并条件未满足: 无审批")
print(" ⏳ 合并条件未满足: 无审批")
print(f"\n=== 扫描结果 ===")
print("\n=== 扫描结果 ===")
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
print(f" 自动审批: {approved_count}")
print(f" 自动合并: {merged_count}")
print(f" AI审查阻止: {ai_blocked_count}")
print(f" 跳过: {skipped_count}")
print(f" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
if __name__ == "__main__":
+7 -20
View File
@@ -252,29 +252,14 @@ echo ""
echo "=== 运行集成测试(pytest-xdist 并行模式) ==="
echo "CPU 核数: $(nproc 2>/dev/null || echo 'unknown')"
# 根据可用内存动态计算 worker 数,防止 OOM
# 每个 worker 约占 200-300MB(含 DB 连接 + FastAPI test client
# 预留 1GB 给系统 + ffmpeg 等子进程
AVAIL_MEM_MB=$(($(grep MemAvailable /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 2097152) / 1024))
RESERVED_MB=1024
PER_WORKER_MB=256
MAX_WORKERS=$(( (AVAIL_MEM_MB - RESERVED_MB) / PER_WORKER_MB ))
# 下限 2,上限 8,CPU 核数也作为上限
CPU_CORES=$(nproc 2>/dev/null || echo 4)
XDIST_WORKERS=$MAX_WORKERS
[ $XDIST_WORKERS -lt 2 ] && XDIST_WORKERS=2
[ $XDIST_WORKERS -gt 8 ] && XDIST_WORKERS=8
[ $XDIST_WORKERS -gt $CPU_CORES ] && XDIST_WORKERS=$CPU_CORES
echo "可用内存: ${AVAIL_MEM_MB}MB, CPU核数: ${CPU_CORES}, xdist workers: ${XDIST_WORKERS}"
# 集成测试使用 pytest-xdist 并行加速(coverage 由单元测试负责,并行模式下 coverage 不稳定)
# -n N: 并行 worker 数
# -n auto: 自动使用 CPU 核数(DooD模式下加--maxprocesses=4防止OOM
# --dist loadfile: 同一测试文件分配到同一 worker(共享 fixture 更高效)
# --maxfail=3: 容忍少量失败(避免偶发 OOM 导致全挂
# --maxfail=1: 遇到失败停止调度新测试(并行模式下等价于 -x
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration \
-q --timeout=60 --maxfail=3 --reruns 2 --reruns-delay 1 \
-q --timeout=60 --maxfail=1 --reruns 3 --reruns-delay 5 \
-m "not performance" \
-n $XDIST_WORKERS --dist loadfile \
-n auto --maxprocesses=4 --dist loadfile \
-p no:cacheprovider
echo "✅ 集成测试通过"
@@ -286,7 +271,9 @@ set +e
PERF_OUTPUT=$(mktemp)
# 性能测试单独串行运行(不参与并行,避免资源竞争影响测量结果)
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration/test_api_performance.py \
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT"
-v --timeout=120 -p no:cacheprovider 2>&1 | tee "$PERF_OUTPUT" \
--reruns 3 \
--reruns-delay=10
echo ""
echo "=== 性能测试摘要 ==="
grep "PERF_STATS:" "$PERF_OUTPUT" || echo "PERF_STATS: 未找到统计数据"
+15 -4
View File
@@ -7,6 +7,11 @@ JOB_NAME="${1:-Unit Tests}"
echo "=== CI Unit Tests 开始 ==="
# --- 配置 pip 国内源(加速下载,减少网络失败)---
python3 -m pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
python3 -m pip config set global.timeout 120
python3 -m pip config set global.retries 5
# --- 安装依赖 ---
echo ""
echo "=== 安装 Python 依赖 ==="
@@ -23,6 +28,12 @@ for i in 1 2 3; do
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-worker.txt && break
echo "pip install requirements-worker.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
@@ -64,8 +75,8 @@ echo "=== 运行单元测试 (模式: $UNIT_TEST_MODE) ==="
if [ "$UNIT_TEST_MODE" = "incremental" ]; then
echo "=== 增量测试模式 ==="
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD" python3 -m coverage run \
--source=apps/api/app,apps/worker/worker_app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest $SELECTED_TEST_FILES -q
@@ -73,8 +84,8 @@ if [ "$UNIT_TEST_MODE" = "incremental" ]; then
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=10 > /dev/null || true
else
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
PYTHONPATH="$PWD/apps/api:$PWD/apps/worker:$PWD" python3 -m coverage run \
--source=apps/api/app,apps/worker/worker_app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest tests/unit -q
+7 -85
View File
@@ -1,96 +1,18 @@
#!/bin/sh
# CI 公共步骤:前端依赖安装Docker Volume 持久化缓存方案)
# 通过 Docker named volume 缓存 node_modules,按 package-lock.json hash 命名
# 缓存命中时跳过 npm ci,直接复用已有 volume
# CI 公共步骤:前端依赖安装
# 直接在 CI 容器内运行(CI 镜像已包含 Node.js),无需 Docker 嵌套
set -eu
MODE="${1:-full}"
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
# npm国内镜像源(加速下载,减少网络失败)
NPM_REGISTRY="https://registry.npmmirror.com"
cd apps/web
# 缓存配置 — 与 step_frontend_run.sh 保持一致
LOCK_FILE="apps/web/package-lock.json"
VOLUME_PREFIX="ci-web-nm-"
KEEP_CACHE_COUNT=5
# 配置国内镜像源加速
npm config set registry https://registry.npmmirror.com
# 计算 package-lock.json 的 md5 hash 作为缓存 key
VOLUME_NAME=""
if [ -f "$LOCK_FILE" ]; then
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
echo "缓存 key: $LOCK_HASH (volume: $VOLUME_NAME)"
else
echo "警告: 未找到 $LOCK_FILE,将不使用持久化缓存"
fi
# 检查 volume 是否存在(缓存命中)
CACHE_HIT=0
if [ -n "$VOLUME_NAME" ]; then
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
CACHE_HIT=1
echo "缓存命中!复用 volume: $VOLUME_NAME"
else
echo "缓存未命中,创建 volume 并安装依赖..."
# 创建 volume(失败则降级为无缓存模式)
if ! docker volume create "$VOLUME_NAME" >/dev/null 2>&1; then
echo "警告: 创建 volume 失败,降级为无缓存模式"
VOLUME_NAME=""
fi
fi
fi
# 构建 docker run 的 volume 挂载参数(空时不挂载)
VOLUME_ARGS=""
if [ -n "$VOLUME_NAME" ]; then
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
fi
# 缓存未命中时执行 npm ci
if [ "$CACHE_HIT" -eq 0 ]; then
for i in 1 2 3; do
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
echo "npm ci 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 10
done
else
echo "跳过 npm ci,直接使用缓存的 node_modules"
fi
# 清理旧缓存 volume(保留最近 N 个,防止磁盘占用无限增长)
if [ -n "$VOLUME_PREFIX" ]; then
echo "清理旧缓存 volume(保留最近 ${KEEP_CACHE_COUNT} 个)..."
ALL_VOLUMES=$(docker volume ls -q --filter "name=${VOLUME_PREFIX}" 2>/dev/null || true)
if [ -n "$ALL_VOLUMES" ]; then
TOTAL=$(echo "$ALL_VOLUMES" | wc -l)
if [ "$TOTAL" -gt "$KEEP_CACHE_COUNT" ]; then
# 按创建时间排序,保留最新的 N 个
SORTED_VOLUMES=$(for v in $ALL_VOLUMES; do
CREATED=$(docker volume inspect --format '{{.CreatedAt}}' "$v" 2>/dev/null || echo "0")
echo "$CREATED $v"
done | sort | awk '{print $2}')
# 删除超出保留数量的旧 volume
REMOVE_COUNT=$((TOTAL - KEEP_CACHE_COUNT))
TO_DELETE=$(echo "$SORTED_VOLUMES" | head -n "$REMOVE_COUNT")
REMOVED=0
for v in $TO_DELETE; do
# 跳过当前正在使用的 volume
if [ "$v" != "$VOLUME_NAME" ]; then
if docker volume rm "$v" >/dev/null 2>&1; then
REMOVED=$((REMOVED + 1))
fi
fi
done
echo "已清理 $REMOVED 个旧缓存 volume,当前共 $((TOTAL - REMOVED))"
else
echo "当前缓存 volume 数量: $TOTAL,无需清理"
fi
fi
fi
# 安装依赖
npm ci --no-audit --no-fund
echo "=== 前端依赖安装完成 ==="
+4 -21
View File
@@ -1,26 +1,9 @@
#!/bin/sh
# CI 公共步骤:前端命令执行(在 docker node 容器中运行)
# 用法:step_frontend_run.sh "要执行的命令"
# 支持 Docker Volume 持久化缓存的 node_modules
# CI 公共步骤:前端命令执行
# 直接在 CI 容器内运行(CI 镜像已包含 Node.js + pnpm),无需 Docker 嵌套
set -eu
CMD="${1:-echo 'no command'}"
# 缓存配置 — 与 step_frontend_install.sh 保持一致
LOCK_FILE="apps/web/package-lock.json"
VOLUME_PREFIX="ci-web-nm-"
# 计算 package-lock.json 的 hash,挂载对应的 volume
VOLUME_ARGS=""
if [ -f "$LOCK_FILE" ]; then
LOCK_HASH=$(md5sum "$LOCK_FILE" | cut -c1-12)
VOLUME_NAME="${VOLUME_PREFIX}${LOCK_HASH}"
if docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then
VOLUME_ARGS="-v ${VOLUME_NAME}:/workspace/apps/web/node_modules"
echo "使用缓存 volume: $VOLUME_NAME"
else
echo "提示: 未找到缓存 volume $VOLUME_NAME,将使用源码目录 node_modules"
fi
fi
docker run --rm -v "$PWD:/workspace" $VOLUME_ARGS -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "$CMD"
cd apps/web
sh -lc "$CMD"
+54 -6
View File
@@ -79,6 +79,25 @@ try:
print(' '.join(py_files))
except Exception:
print('')
")
# 新增文件(added)强制全量检查,防止增量漏检
ADDED_PY_FILES=$(echo "$BODY" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
added = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] == 'added']
print(' '.join(added))
except Exception:
print('')
")
MODIFIED_PY_FILES=$(echo "$BODY" | python3 -c "
import json, sys
try:
files = json.load(sys.stdin)
modified = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] not in ('removed', 'added')]
print(' '.join(modified))
except Exception:
print('')
")
if [ -n "$CHANGED_PY_FILES" ]; then
SCAN_MODE="incremental"
@@ -174,13 +193,42 @@ echo "发现潜在死代码(可能包含框架装饰器注册的函数,为
echo "建议:定期人工审查高置信度(>=90%)条目"
set -e
# --- Release 脚本语法校验 ---
# --- CI脚本语法校验 ---
echo ""
echo "=== [6/6] Release scripts syntax validation ==="
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
echo "✅ Release scripts syntax OK"
echo "=== [6/6] CI & shell scripts syntax validation ==="
SYNTAX_ERROR=0
# 检查所有 CI shell 脚本
for script in scripts/ci/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查所有 CI Python 脚本语法
for script in scripts/ci/*.py; do
if [ -f "$script" ]; then
if ! python3 -m py_compile "$script" 2>&1; then
echo "❌ Python语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查 .gitea/workflows 下的脚本(如果有)
for script in .gitea/workflows/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
if [ "$SYNTAX_ERROR" -ne 0 ]; then
echo "❌ CI脚本语法校验失败,见上方错误"
exit 1
fi
echo "✅ All CI scripts syntax OK"
echo ""
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
Regular → Executable
+7 -15
View File
@@ -1,15 +1,12 @@
#!/bin/bash
# Vitest 增量执行脚本
# Vitest 增量执行脚本(在Docker Node容器中运行)
# PR模式下只跑与改动文件相关的测试,大幅节省时间
# 用法: bash scripts/ci/vitest_incremental.sh
set -eu
cd apps/web
# 如果不是PR事件,直接全量跑
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
echo "非PR模式,全量执行Vitest"
npx --no-install vitest run --coverage
bash scripts/ci/step_frontend_run.sh "npx vitest run --coverage"
exit $?
fi
@@ -17,7 +14,7 @@ fi
PR_NUMBER=$(echo "${GITHUB_REF:-}" | sed 's|refs/pull/||; s|/.*||')
if [ -z "$PR_NUMBER" ]; then
echo "无法获取PR编号,全量执行Vitest"
npx --no-install vitest run --coverage
bash scripts/ci/step_frontend_run.sh "npx vitest run --coverage"
exit $?
fi
@@ -29,9 +26,7 @@ try:
web_files = []
for f in files:
fname = f['filename']
# 只关注前端源码文件
if fname.startswith('apps/web/src/') and fname.endswith(('.ts', '.tsx', '.js', '.jsx')) and f['status'] != 'removed':
# 去掉apps/web/前缀,变成相对路径
web_files.append(fname.replace('apps/web/', ''))
print(' '.join(web_files))
except Exception as e:
@@ -40,36 +35,33 @@ except Exception as e:
if [ -z "$CHANGED_FILES" ]; then
echo "PR未改动前端源码文件,跳过Vitest"
echo "(如果配置了前端单测门禁,请确保至少有一个相关测试)"
exit 0
fi
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -w)
echo "PR改动了 $FILE_COUNT 个前端文件"
echo "改动文件: $CHANGED_FILES"
# 如果改动文件太多(超过30个),全量跑更可靠
if [ "$FILE_COUNT" -gt 30 ]; then
echo "改动文件较多>$FILE_COUNT),降级为全量执行以确保覆盖"
npx --no-install vitest run --coverage
echo "改动文件较多,全量执行Vitest"
bash scripts/ci/step_frontend_run.sh "npx vitest run --coverage"
exit $?
fi
# 使用vitest related 跑增量测试(子命令,非flag)
echo ""
echo "=== 增量执行 Vitest(只跑相关测试)==="
echo "相关源文件: $CHANGED_FILES"
echo ""
# 在Docker Node容器中执行增量测试
set +e
npx --no-install vitest run related $CHANGED_FILES
bash scripts/ci/step_frontend_run.sh "npx vitest run related $CHANGED_FILES"
VITEST_EXIT=$?
set -e
if [ "$VITEST_EXIT" -eq 0 ]; then
echo ""
echo "✅ 增量测试通过"
echo "(仅覆盖与改动相关的测试用例)"
exit 0
else
echo ""
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Agent提交前自动格式化脚本
# 用法:./scripts/format.sh [path1 path2 ...]
# 不传参数则格式化所有后端代码
set -euo pipefail
cd "$(dirname "$0")/.."
echo "=== 代码格式化 ==="
# 后端:black + isort(顺序:先isort后black,与pyproject.toml配置一致)
if command -v black &>/dev/null && command -v isort &>/dev/null; then
TARGETS="${@:-alembic apps packages tests scripts}"
echo "后端格式化: $TARGETS"
python3 -m isort $TARGETS
python3 -m black $TARGETS
echo "✅ 后端格式化完成"
else
echo "⚠️ 未安装black/isort,跳过后端格式化"
fi
# 前端:prettier + eslint --fix(如果有前端改动)
if [ -d "apps/web" ] && command -v npx &>/dev/null; then
if [ "$#" -eq 0 ] || echo "$@" | grep -q "apps/web"; then
echo "前端格式化: apps/web"
(cd apps/web && npx eslint src --ext .ts,.tsx --fix 2>/dev/null || true)
(cd apps/web && npx prettier --write "src/**/*.{ts,tsx,css,json}" 2>/dev/null || true)
echo "✅ 前端格式化完成"
fi
fi
echo "=== 格式化全部完成 ==="
+432
View File
@@ -0,0 +1,432 @@
"""AI 服务层单元测试.
测试覆盖:
- DoubaoAIClient 可用性检测
- 智能标题生成(降级模式)
- 标题解析(多种返回格式)
- 风格校验
- 参数边界
"""
from __future__ import annotations
import json
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, "apps/api")
from app.services.ai_service import ( # noqa: E402
TITLE_STYLES,
_generate_titles_fallback,
_parse_semantic_match_response,
_parse_titles_from_response,
_semantic_match_fallback,
generate_smart_titles,
semantic_match_assets,
)
class TestAIClientAvailability(unittest.TestCase):
"""AI客户端可用性检测(通过mock get_doubao_client."""
def test_generate_fallback_when_client_unavailable(self):
"""客户端不可用时走降级."""
mock_client = MagicMock()
mock_client.is_available = False
mock_client.chat_completion = MagicMock(return_value=None)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试内容", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(len(result["titles"]), 5)
# 不可用时不应调用 chat_completion
mock_client.chat_completion.assert_not_called()
def test_generate_calls_client_when_available(self):
"""客户端可用时调用API."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试", "viral", 5)
self.assertEqual(result["source"], "doubao")
mock_client.chat_completion.assert_called_once()
class TestTitleParsing(unittest.TestCase):
"""标题解析测试 — 覆盖多种返回格式."""
def test_parse_json_array(self):
"""解析 JSON 数组格式."""
content = json.dumps(["标题一", "标题二", "标题三"])
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 3)
self.assertEqual(result[0], "标题一")
def test_parse_json_with_titles_key(self):
"""解析带 titles 字段的 JSON 对象."""
content = json.dumps({"titles": ["标题A", "标题B"]})
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
def test_parse_markdown_code_block_json(self):
"""解析 markdown 代码块包裹的 JSON."""
content = '```json\n["标题1", "标题2"]\n```'
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
def test_parse_numbered_list(self):
"""解析编号列表."""
content = "1. 第一个标题\n2. 第二个标题\n3. 第三个标题"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 3)
self.assertIn("第一个标题", result)
def test_parse_dash_list(self):
"""解析破折号列表."""
content = "- 标题甲\n- 标题乙\n- 标题丙"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 3)
def test_parse_chinese_numbered(self):
"""解析中文数字编号."""
content = "1、标题一\n2、标题二"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
def test_parse_empty_content(self):
"""空内容返回空列表."""
result = _parse_titles_from_response("")
self.assertEqual(result, [])
def test_parse_filters_long_lines(self):
"""过滤过长的行."""
long_title = "这是一个非常长的标题" * 15 # 超过100字
content = f"1. 正常标题\n2. {long_title}\n3. 另一个标题"
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
self.assertNotIn(long_title, result)
def test_parse_invalid_json_falls_back_to_lines(self):
"""无效 JSON 回退到按行解析."""
content = '["标题1", "标题2", 无效'
result = _parse_titles_from_response(content)
# 至少能解析出一些内容
self.assertTrue(len(result) >= 0)
class TestFallbackGeneration(unittest.TestCase):
"""降级生成测试."""
def test_fallback_returns_requested_count(self):
"""返回请求的数量."""
result = _generate_titles_fallback("测试内容", "viral", 5)
self.assertEqual(len(result), 5)
def test_fallback_max_10(self):
"""最多返回10个."""
result = _generate_titles_fallback("测试内容", "viral", 20)
self.assertEqual(len(result), 10)
def test_fallback_different_styles(self):
"""不同风格都能生成."""
for style in ["viral", "emotional", "informative"]:
result = _generate_titles_fallback("测试", style, 3)
self.assertEqual(len(result), 3)
for title in result:
self.assertTrue(len(title) > 0)
def test_fallback_contains_keyword(self):
"""标题包含关键词."""
result = _generate_titles_fallback("旅行攻略", "viral", 5)
has_keyword = any("旅行" in t for t in result)
self.assertTrue(has_keyword)
class TestGenerateSmartTitles(unittest.TestCase):
"""智能标题生成集成测试."""
def test_generate_without_api_key_fallback(self):
"""无 API Key 时走降级路径."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频内容", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(result["style"], "viral")
self.assertEqual(len(result["titles"]), 5)
def test_generate_invalid_style_defaults_to_viral(self):
"""无效风格默认 viral."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试", "invalid_style", 5)
self.assertEqual(result["style"], "viral")
def test_generate_count_bounds(self):
"""数量边界处理."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
# 小于最小值
result = generate_smart_titles("测试", "viral", 1)
self.assertEqual(len(result["titles"]), 3)
# 大于最大值
result = generate_smart_titles("测试", "viral", 100)
self.assertEqual(len(result["titles"]), 10)
def test_generate_with_api_success(self):
"""API 调用成功路径."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(
return_value=json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频", "viral", 5)
self.assertEqual(result["source"], "doubao")
self.assertEqual(len(result["titles"]), 5)
self.assertIn("AI标题1", result["titles"])
def test_generate_with_api_failure_fallback(self):
"""API 调用失败时降级."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=None)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(len(result["titles"]), 5)
def test_generate_api_returns_unparseable_fallback(self):
"""API 返回无法解析时降级."""
mock_client = MagicMock()
mock_client.is_available = True
# 返回无法解析的内容(只有一个标题且格式异常)
mock_client.chat_completion = MagicMock(return_value="一段文字说明,不是标题列表")
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = generate_smart_titles("测试视频", "viral", 5)
# 只有1个有效标题,不足2个触发降级
self.assertEqual(result["source"], "fallback")
class TestTitleStyles(unittest.TestCase):
"""标题风格定义测试."""
def test_all_styles_have_required_fields(self):
"""所有风格都有必要字段."""
for _key, info in TITLE_STYLES.items():
self.assertIn("name", info)
self.assertIn("description", info)
self.assertIn("examples", info)
self.assertTrue(len(info["examples"]) >= 2)
def test_three_styles_defined(self):
"""定义了三种风格."""
self.assertEqual(len(TITLE_STYLES), 3)
self.assertIn("viral", TITLE_STYLES)
self.assertIn("emotional", TITLE_STYLES)
self.assertIn("informative", TITLE_STYLES)
# ── 语义匹配测试 ──────────────────────────────────────────────────────────────
class TestSemanticMatchFallback(unittest.TestCase):
"""降级关键词匹配测试."""
def _make_assets(self):
return [
{"id": "a1", "name": "海边日落风景", "tags": ["风景", "海边", "日落"], "description": "美丽的海边日落"},
{"id": "a2", "name": "城市夜景航拍", "tags": ["城市", "夜景", "航拍"], "description": "城市夜景航拍素材"},
{"id": "a3", "name": "美食制作过程", "tags": ["美食", "烹饪", "教程"], "description": "美食制作教程"},
]
def test_fallback_returns_sorted_scores(self):
"""返回按匹配度降序排列."""
assets = self._make_assets()
result = _semantic_match_fallback("海边日落风景视频", assets)
self.assertEqual(len(result), 3)
# 第一个应该是海边日落
self.assertEqual(result[0]["id"], "a1")
self.assertGreater(result[0]["match_score"], result[2]["match_score"])
def test_fallback_each_has_match_score(self):
"""每个素材都有 match_score."""
assets = self._make_assets()
result = _semantic_match_fallback("测试", assets)
for item in result:
self.assertIn("match_score", item)
self.assertGreaterEqual(item["match_score"], 0.0)
self.assertLessEqual(item["match_score"], 1.0)
self.assertIn("match_reason", item)
def test_fallback_unrelated_desc_low_scores(self):
"""完全不相关的描述得分低."""
assets = self._make_assets()
result = _semantic_match_fallback("篮球比赛运动", assets)
# 所有素材得分都应该较低
for item in result:
self.assertLess(item["match_score"], 0.8)
def test_fallback_empty_keywords_default_score(self):
"""无有效关键词时给默认分."""
assets = self._make_assets()
result = _semantic_match_fallback("a", assets) # 单字符无有效关键词
for item in result:
self.assertEqual(item["match_score"], 0.5)
self.assertEqual(item["match_reason"], "fallback_default")
def test_fallback_name_match_higher(self):
"""名称命中得分更高."""
assets = [
{"id": "a1", "name": "美食探店vlog", "tags": [], "description": ""},
{"id": "a2", "name": "风景视频", "tags": ["美食"], "description": ""},
]
result = _semantic_match_fallback("美食", assets)
# a1名称含美食,a2标签含美食,名称命中应有额外加分
self.assertEqual(result[0]["id"], "a1")
self.assertGreater(result[0]["match_score"], result[1]["match_score"])
class TestSemanticMatchParsing(unittest.TestCase):
"""语义匹配返回解析测试."""
def test_parse_dict_format(self):
"""解析 {id: score} 格式."""
content = json.dumps({"asset1": 0.85, "asset2": 0.62, "asset3": 0.3})
result = _parse_semantic_match_response(content, ["asset1", "asset2", "asset3"])
self.assertIsNotNone(result)
self.assertEqual(len(result), 3)
self.assertAlmostEqual(result["asset1"], 0.85)
def test_parse_matches_list_format(self):
"""解析 {matches: [...]} 格式."""
content = json.dumps(
{
"matches": [
{"asset_id": "a1", "score": 0.9},
{"asset_id": "a2", "score": 0.7},
]
}
)
result = _parse_semantic_match_response(content, ["a1", "a2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 0.9)
self.assertAlmostEqual(result["a2"], 0.7)
def test_parse_array_format(self):
"""解析数组格式."""
content = json.dumps(
[
{"id": "x1", "score": 0.5},
{"id": "x2", "score": 0.88},
]
)
result = _parse_semantic_match_response(content, ["x1", "x2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["x1"], 0.5)
def test_parse_score_clamped(self):
"""分数被限制在0-1."""
content = json.dumps({"a1": 1.5, "a2": -0.2})
result = _parse_semantic_match_response(content, ["a1", "a2"])
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 1.0)
self.assertAlmostEqual(result["a2"], 0.0)
def test_parse_markdown_code_block(self):
"""解析markdown代码块."""
content = '```json\n{"a1": 0.7}\n```'
result = _parse_semantic_match_response(content, ["a1", "a2"])
# 只有1个素材评分,少于一半(需要至少1个,max(1, 2//2)=1
self.assertIsNotNone(result)
self.assertAlmostEqual(result["a1"], 0.7)
def test_parse_empty_returns_none(self):
"""空内容返回None."""
result = _parse_semantic_match_response("", ["a1"])
self.assertIsNone(result)
def test_parse_invalid_json_returns_none(self):
"""无效JSON返回None."""
result = _parse_semantic_match_response("不是json", ["a1", "a2", "a3"])
self.assertIsNone(result)
class TestSemanticMatchAssets(unittest.TestCase):
"""semantic_match_assets 集成测试."""
def _make_assets(self):
return [
{"id": "a1", "name": "海边日落", "tags": ["风景"], "description": ""},
{"id": "a2", "name": "城市夜景", "tags": ["城市"], "description": ""},
{"id": "a3", "name": "美食制作", "tags": ["美食"], "description": ""},
]
def test_fallback_mode_without_api_key(self):
"""无API Key时走降级."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("海边", self._make_assets())
self.assertEqual(result["source"], "fallback")
self.assertEqual(result["total"], 3)
self.assertEqual(len(result["matches"]), 3)
def test_empty_assets(self):
"""空素材列表."""
result = semantic_match_assets("test", [])
self.assertEqual(result["total"], 0)
self.assertEqual(len(result["matches"]), 0)
def test_top_k_limit(self):
"""top_k 限制返回数量."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("测试", self._make_assets(), top_k=2)
self.assertEqual(len(result["matches"]), 2)
def test_with_doubao_success(self):
"""豆包调用成功路径."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=json.dumps({"a1": 0.9, "a2": 0.5, "a3": 0.2}))
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("风景视频", self._make_assets())
self.assertEqual(result["source"], "doubao")
self.assertEqual(len(result["matches"]), 3)
# 按分数降序,a1最高
self.assertEqual(result["matches"][0]["id"], "a1")
self.assertAlmostEqual(result["matches"][0]["match_score"], 0.9)
def test_with_doubao_failure_fallback(self):
"""豆包调用失败降级."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=None)
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("测试", self._make_assets())
self.assertEqual(result["source"], "fallback")
def test_each_match_has_required_fields(self):
"""每个匹配结果都有必要字段."""
mock_client = MagicMock()
mock_client.is_available = False
with patch("app.services.ai_service.get_doubao_client", return_value=mock_client):
result = semantic_match_assets("测试", self._make_assets())
for item in result["matches"]:
self.assertIn("id", item)
self.assertIn("match_score", item)
self.assertIn("match_reason", item)
if __name__ == "__main__":
unittest.main()
+359
View File
@@ -0,0 +1,359 @@
"""Worker AI 任务单元测试.
测试覆盖:
- AI推荐(豆包调用成功/失败/降级)
- 推荐响应解析(多种格式)
- 封面生成降级
"""
from __future__ import annotations
import json
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, "apps/worker")
sys.path.insert(0, "packages")
from worker_app.tasks.ai_tasks import ( # noqa: E402
_fallback_recommend_clips,
_parse_recommend_response,
run_ai_recommend,
run_generate_cover,
)
class TestFallbackRecommend(unittest.TestCase):
"""降级推荐方案测试."""
def test_fallback_returns_expected_structure(self):
"""降级推荐返回正确结构."""
result = _fallback_recommend_clips(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1", "a2", "a3"],
editing_mode="one_take",
target_duration=30.0,
)
self.assertIn("clips", result)
self.assertIn("config", result)
self.assertIn("total_duration", result)
self.assertIn("confidence", result)
def test_fallback_clips_structure(self):
"""每个片段都有必要字段."""
result = _fallback_recommend_clips(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1", "a2"],
editing_mode="one_take",
target_duration=20.0,
)
clips = result["clips"]
self.assertTrue(len(clips) >= 3) # intro + showcase + outro
for clip in clips:
self.assertIn("clip_type", clip)
self.assertIn("order", clip)
self.assertIn("text_content", clip)
self.assertIn("duration", clip)
self.assertIn("transition_effect", clip)
self.assertIn("asset_id", clip)
self.assertIn("start_time", clip)
self.assertIn("config", clip)
def test_fallback_first_is_intro_last_is_outro(self):
"""第一个是开场,最后一个是结尾."""
result = _fallback_recommend_clips(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1", "a2", "a3"],
editing_mode="one_take",
target_duration=30.0,
)
clips = result["clips"]
self.assertEqual(clips[0]["clip_type"], "intro")
self.assertEqual(clips[-1]["clip_type"], "outro")
def test_fallback_order_sequential(self):
"""order 连续递增."""
result = _fallback_recommend_clips(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1", "a2"],
editing_mode="one_take",
target_duration=30.0,
)
for i, clip in enumerate(result["clips"]):
self.assertEqual(clip["order"], i)
def test_fallback_empty_assets(self):
"""空素材列表也能生成."""
result = _fallback_recommend_clips(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=[],
editing_mode="one_take",
target_duration=10.0,
)
self.assertTrue(len(result["clips"]) >= 2)
def test_fallback_confidence_in_range(self):
"""置信度在0-1之间."""
result = _fallback_recommend_clips(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1"],
editing_mode="one_take",
target_duration=10.0,
)
self.assertGreaterEqual(result["confidence"], 0.0)
self.assertLessEqual(result["confidence"], 1.0)
class TestRecommendResponseParsing(unittest.TestCase):
"""推荐响应解析测试."""
def _asset_ids(self):
return ["a1", "a2", "a3"]
def test_parse_valid_response(self):
"""解析正常响应."""
data = {
"clips": [
{"clip_type": "intro", "order": 0, "text_content": "开场",
"duration": 3.0, "transition_effect": "fade",
"asset_id": "a1", "start_time": 0.0, "config": {}},
{"clip_type": "showcase", "order": 1, "text_content": "展示",
"duration": 5.0, "transition_effect": "cut",
"asset_id": "a2", "start_time": 1.0, "config": {}},
{"clip_type": "outro", "order": 2, "text_content": "结尾",
"duration": 2.0, "transition_effect": "fade",
"asset_id": "", "start_time": 0.0, "config": {}},
],
"title": "精彩视频",
"confidence": 0.85,
}
result = _parse_recommend_response(
json.dumps(data), self._asset_ids(), 30.0
)
self.assertIsNotNone(result)
self.assertEqual(len(result["clips"]), 3)
self.assertEqual(result["clips"][0]["clip_type"], "intro")
self.assertEqual(result["confidence"], 0.85)
self.assertIn("精彩视频", result["config"].get("title", {}).get("text", ""))
def test_parse_markdown_code_block(self):
"""解析markdown代码块."""
data = {"clips": [
{"clip_type": "showcase", "order": 0, "text_content": "t",
"duration": 3, "transition_effect": "cut",
"asset_id": "a1", "start_time": 0, "config": {}}
], "confidence": 0.7}
content = "```json\n" + json.dumps(data) + "\n```"
result = _parse_recommend_response(content, self._asset_ids(), 30.0)
self.assertIsNotNone(result)
self.assertEqual(len(result["clips"]), 1)
def test_parse_empty_content(self):
"""空内容返回None."""
result = _parse_recommend_response("", self._asset_ids(), 30.0)
self.assertIsNone(result)
def test_parse_invalid_json(self):
"""无效JSON返回None."""
result = _parse_recommend_response("不是json", self._asset_ids(), 30.0)
self.assertIsNone(result)
def test_parse_no_clips(self):
"""无clips字段返回None."""
result = _parse_recommend_response(
json.dumps({"title": "abc"}), self._asset_ids(), 30.0
)
self.assertIsNone(result)
def test_parse_filters_invalid_asset_ids(self):
"""过滤不在输入列表中的asset_id."""
data = {"clips": [
{"clip_type": "showcase", "order": 0, "text_content": "t",
"duration": 3, "transition_effect": "cut",
"asset_id": "fake-id", "start_time": 0, "config": {}}
], "confidence": 0.7}
result = _parse_recommend_response(
json.dumps(data), self._asset_ids(), 30.0
)
self.assertIsNotNone(result)
# 非法asset_id被清空
self.assertEqual(result["clips"][0]["asset_id"], "")
def test_parse_clamps_duration(self):
"""时长被限制在合理范围."""
data = {"clips": [
{"clip_type": "showcase", "order": 0, "text_content": "t",
"duration": 100, "transition_effect": "cut",
"asset_id": "a1", "start_time": 0, "config": {}}
]}
result = _parse_recommend_response(
json.dumps(data), self._asset_ids(), 30.0
)
self.assertIsNotNone(result)
self.assertLessEqual(result["clips"][0]["duration"], 30.0)
def test_parse_reorders_clips(self):
"""clips按order排序并重新编号."""
data = {"clips": [
{"clip_type": "showcase", "order": 5, "text_content": "b",
"duration": 3, "transition_effect": "cut",
"asset_id": "a2", "start_time": 0, "config": {}},
{"clip_type": "intro", "order": 0, "text_content": "a",
"duration": 3, "transition_effect": "fade",
"asset_id": "a1", "start_time": 0, "config": {}},
]}
result = _parse_recommend_response(
json.dumps(data), self._asset_ids(), 30.0
)
self.assertIsNotNone(result)
# 第一个应该是order=0的intro
self.assertEqual(result["clips"][0]["clip_type"], "intro")
# order被重新编号为连续
self.assertEqual(result["clips"][0]["order"], 0)
self.assertEqual(result["clips"][1]["order"], 1)
def test_parse_confidence_clamped(self):
"""confidence被限制在0-1."""
data = {"clips": [
{"clip_type": "showcase", "order": 0, "text_content": "t",
"duration": 3, "transition_effect": "cut",
"asset_id": "a1", "start_time": 0, "config": {}}
], "confidence": 2.5}
result = _parse_recommend_response(
json.dumps(data), self._asset_ids(), 30.0
)
self.assertIsNotNone(result)
self.assertLessEqual(result["confidence"], 1.0)
class TestRunAIRecommend(unittest.TestCase):
"""run_ai_recommend 集成测试."""
def test_fallback_when_client_unavailable(self):
"""客户端不可用时走降级."""
mock_client = MagicMock()
mock_client.is_available = False
mock_client.chat_completion = MagicMock(return_value=None)
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
result = run_ai_recommend(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1", "a2"],
editing_mode="one_take",
target_duration=20.0,
)
self.assertIn("clips", result)
self.assertIn("total_duration", result)
mock_client.chat_completion.assert_not_called()
def test_doubao_success(self):
"""豆包调用成功路径."""
mock_client = MagicMock()
mock_client.is_available = True
mock_response = {
"clips": [
{"clip_type": "intro", "order": 0, "text_content": "开场",
"duration": 3.0, "transition_effect": "fade",
"asset_id": "a1", "start_time": 0.0, "config": {}},
{"clip_type": "outro", "order": 1, "text_content": "结尾",
"duration": 2.0, "transition_effect": "fade",
"asset_id": "a2", "start_time": 0.0, "config": {}},
],
"title": "AI生成标题",
"confidence": 0.9,
}
mock_client.chat_completion = MagicMock(return_value=json.dumps(mock_response))
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
result = run_ai_recommend(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1", "a2"],
editing_mode="one_take",
target_duration=30.0,
)
self.assertEqual(result["confidence"], 0.9)
self.assertEqual(len(result["clips"]), 2)
mock_client.chat_completion.assert_called_once()
def test_doubao_failure_fallback(self):
"""豆包调用失败降级."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value=None)
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
result = run_ai_recommend(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1"],
editing_mode="one_take",
target_duration=10.0,
)
# 降级后有结果
self.assertTrue(len(result["clips"]) >= 2)
mock_client.chat_completion.assert_called_once()
def test_doubao_unparseable_fallback(self):
"""豆包返回无法解析时降级."""
mock_client = MagicMock()
mock_client.is_available = True
mock_client.chat_completion = MagicMock(return_value="一堆废话不是json")
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
result = run_ai_recommend(
plan_id="plan-1",
template_id="tpl-1",
asset_ids=["a1"],
editing_mode="one_take",
target_duration=10.0,
)
# 降级后有结果
self.assertTrue(len(result["clips"]) >= 2)
class TestGenerateCover(unittest.TestCase):
"""封面生成测试(降级路径)."""
def test_ai_frame_type(self):
"""AI封面模式返回预期结构."""
result = run_generate_cover(
plan_id="plan-1",
asset_ids=["a1"],
cover_type="ai_frame",
)
self.assertIn("type", result)
self.assertEqual(result["type"], "ai_frame")
self.assertIn("image_url", result)
def test_manual_type(self):
"""手动选帧模式."""
result = run_generate_cover(
plan_id="plan-1",
asset_ids=["a1"],
cover_type="manual",
frame_time=5.0,
)
self.assertEqual(result["type"], "manual")
self.assertEqual(result["frame_time"], 5.0)
def test_upload_type(self):
"""上传封面模式."""
result = run_generate_cover(
plan_id="plan-1",
asset_ids=["a1"],
cover_type="upload",
)
self.assertEqual(result["type"], "upload")
if __name__ == "__main__":
unittest.main()
+738
View File
@@ -0,0 +1,738 @@
"""Auth bind_contact + wechat_sync use cases unit tests.
Covers BindContactUseCase, SendVerificationCodeUseCase, WechatSyncUseCase.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from packages.application.auth.bind_contact_use_case import (
BindContactRequest,
BindContactResponse,
BindContactUseCase,
SendVerificationCodeRequest,
SendVerificationCodeResponse,
SendVerificationCodeUseCase,
)
from packages.application.auth.wechat_sync_use_case import (
WechatSyncRequest,
WechatSyncResponse,
WechatSyncUseCase,
)
# ── Test helpers ─────────────────────────────────────────
@dataclass
class FakeUser:
id: str = "user-123"
email: str = "test@example.com"
display_name: str = "Test User"
username: str = "testuser"
password_hash: str = ""
email_verified: bool = False
phone: str = ""
phone_verified: bool = False
binding_completed_at: datetime | None = None
last_login_at: datetime | None = None
last_login_ip: str | None = None
wechat_openid: str | None = None
wechat_unionid: str | None = None
email_verification_token: str | None = None
password_reset_token: str | None = None
password_reset_expires_at: datetime | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
class FakeUserRepository:
def __init__(self, user=None):
self._user = user
self.saved_user = None
self.save_called = 0
def find_by_id(self, user_id):
if self._user and self._user.id == user_id:
return self._user
return None
def find_by_email(self, email):
if self._user and self._user.email == email:
return self._user
return None
def find_by_phone(self, phone):
if self._user and self._user.phone == phone:
return self._user
return None
def find_by_username(self, username):
if self._user and self._user.username == username:
return self._user
return None
def find_by_wechat_openid(self, openid):
if self._user and self._user.wechat_openid == openid:
return self._user
return None
def find_by_wechat_unionid(self, unionid):
if self._user and self._user.wechat_unionid == unionid:
return self._user
return None
def save(self, user):
self.saved_user = user
self.save_called += 1
self._user = user
return user
class FakeVerificationCode:
def __init__(self, code="123456", created_at=None, expires_at=None):
self.code = code
self.created_at = created_at or datetime.now(timezone.utc)
self.expires_at = expires_at or (datetime.now(timezone.utc) + timedelta(minutes=5))
class FakeVerificationCodeService:
def __init__(self, verify_success=True, verify_error=None, generate_code="123456"):
self._verify_success = verify_success
self._verify_error = verify_error
self._generate_code = generate_code
self.verified = []
self.generated = []
def verify(self, recipient, code_type, code_value):
self.verified.append(
{
"recipient": recipient,
"code_type": code_type,
"code_value": code_value,
}
)
return self._verify_success, self._verify_error
def generate(self, recipient, code_type):
self.generated.append({"recipient": recipient, "code_type": code_type})
return FakeVerificationCode(code=self._generate_code), None
class FakeSessionStore:
def __init__(self):
self.saved_sessions = []
def save_session(self, **kwargs):
self.saved_sessions.append(kwargs)
return True
class FakeSmsService:
def __init__(self):
self.sent = []
def send_verification_code(self, phone, code):
self.sent.append({"phone": phone, "code": code})
class FakeEmailService:
def __init__(self):
self.sent = []
def send_email(self, to, subject, body):
self.sent.append({"to": to, "subject": subject, "body": body})
# ── BindContactUseCase tests ────────────────────────────
class TestBindContactUseCase:
def _make_use_case(self, user_repo=None, verify_svc=None):
return BindContactUseCase(
user_repository=user_repo or FakeUserRepository(),
verification_code_service=verify_svc or FakeVerificationCodeService(),
)
def test_bind_phone_success(self):
user = FakeUser(id="user-1", phone="", phone_verified=False)
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=True)
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(
user_id="user-1",
phone="13800138000",
phone_code="123456",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert isinstance(response, BindContactResponse)
assert response.user.phone == "13800138000"
assert response.user.phone_verified is True
# verification was called
assert len(verify_svc.verified) == 1
assert verify_svc.verified[0]["recipient"] == "13800138000"
def test_bind_email_success(self):
user = FakeUser(id="user-1", email="old@example.com", email_verified=False)
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=True)
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(
user_id="user-1",
email="new@example.com",
email_code="123456",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.user.email == "new@example.com"
assert response.user.email_verified is True
assert len(verify_svc.verified) == 1
def test_bind_both_phone_and_email(self):
user = FakeUser(id="user-1", phone="", phone_verified=False, email_verified=False)
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=True)
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(
user_id="user-1",
phone="13800138000",
phone_code="123456",
email="new@example.com",
email_code="123456",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.user.phone == "13800138000"
assert response.user.phone_verified is True
assert response.user.email == "new@example.com"
assert response.user.email_verified is True
assert response.user.binding_completed_at is not None
assert len(verify_svc.verified) == 2
def test_binding_complete_with_real_email(self):
"""Both phone and email verified, real email (not wechat.local) → binding completed."""
user = FakeUser(
id="user-1",
email="",
phone="",
phone_verified=False,
email_verified=False,
)
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=True)
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(
user_id="user-1",
phone="13800138000",
phone_code="123456",
email="user@real.com",
email_code="123456",
)
response, error = use_case.execute(req)
assert error is None
assert response.user.binding_completed_at is not None
assert response.to_dict()["user"]["binding_complete"] is True
def test_binding_not_complete_with_wechat_email(self):
"""WeChat placeholder email doesn't count for binding completion."""
user = FakeUser(
id="user-1",
email="abc@wechat.local",
email_verified=True,
phone="",
phone_verified=False,
)
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=True)
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(
user_id="user-1",
phone="13800138000",
phone_code="123456",
)
response, error = use_case.execute(req)
assert error is None
# wechat.local email doesn't count
assert response.user.binding_completed_at is None
assert response.to_dict()["user"]["binding_complete"] is False
def test_no_phone_no_email_returns_error(self):
repo = FakeUserRepository()
use_case = self._make_use_case(user_repo=repo)
req = BindContactRequest(user_id="user-1")
response, error = use_case.execute(req)
assert response is None
assert "至少填写手机号或邮箱" in error
def test_user_not_found(self):
repo = FakeUserRepository() # no user
use_case = self._make_use_case(user_repo=repo)
req = BindContactRequest(user_id="nonexistent", phone="13800138000", phone_code="123")
response, error = use_case.execute(req)
assert response is None
assert "用户不存在" in error
def test_invalid_phone_format(self):
user = FakeUser(id="user-1")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = BindContactRequest(user_id="user-1", phone="123", phone_code="123456")
response, error = use_case.execute(req)
assert response is None
assert error is not None
def test_invalid_email_format(self):
user = FakeUser(id="user-1")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = BindContactRequest(user_id="user-1", email="not-an-email", email_code="123456")
response, error = use_case.execute(req)
assert response is None
assert error is not None
def test_phone_missing_code(self):
user = FakeUser(id="user-1")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = BindContactRequest(user_id="user-1", phone="13800138000")
response, error = use_case.execute(req)
assert response is None
assert "请输入手机验证码" in error
def test_email_missing_code(self):
user = FakeUser(id="user-1")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = BindContactRequest(user_id="user-1", email="a@b.com")
response, error = use_case.execute(req)
assert response is None
assert "请输入邮箱验证码" in error
def test_phone_already_bound_to_other_user(self):
other_user = FakeUser(id="user-2", phone="13800138000")
current_user = FakeUser(id="user-1", phone="")
# repo only finds "other" user for phone lookup
repo = FakeUserRepository(user=other_user)
# But we also need find_by_id to find the current user
# Our simple repo can only hold one user. Let's use MagicMock instead.
repo = MagicMock()
repo.find_by_id.return_value = current_user
repo.find_by_phone.return_value = other_user
verify_svc = FakeVerificationCodeService()
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456")
response, error = use_case.execute(req)
assert response is None
assert "已被其他账号绑定" in error
def test_email_already_bound_to_other_user(self):
current_user = FakeUser(id="user-1", email="old@example.com")
other_user = FakeUser(id="user-2", email="new@example.com")
repo = MagicMock()
repo.find_by_id.return_value = current_user
repo.find_by_email.return_value = other_user
verify_svc = FakeVerificationCodeService()
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
req = BindContactRequest(user_id="user-1", email="new@example.com", email_code="123456")
response, error = use_case.execute(req)
assert response is None
assert "已被其他账号绑定" in error
def test_phone_verification_failed(self):
user = FakeUser(id="user-1", phone="")
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=False, verify_error="验证码错误或已过期")
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="wrong")
response, error = use_case.execute(req)
assert response is None
assert "手机验证码错误" in error
def test_email_verification_failed(self):
user = FakeUser(id="user-1")
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=False, verify_error="验证码错误")
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(user_id="user-1", email="a@b.com", email_code="wrong")
response, error = use_case.execute(req)
assert response is None
assert "邮箱验证码错误" in error
def test_bind_same_phone_to_self_ok(self):
"""Binding the same phone to the same user should work (no conflict)."""
user = FakeUser(id="user-1", phone="13800138000", phone_verified=False)
repo = FakeUserRepository(user=user)
verify_svc = FakeVerificationCodeService(verify_success=True)
use_case = self._make_use_case(user_repo=repo, verify_svc=verify_svc)
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123456")
response, error = use_case.execute(req)
assert error is None
assert response is not None
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_id.side_effect = RuntimeError("DB down")
verify_svc = FakeVerificationCodeService()
use_case = BindContactUseCase(user_repository=repo, verification_code_service=verify_svc)
req = BindContactRequest(user_id="user-1", phone="13800138000", phone_code="123")
response, error = use_case.execute(req)
assert response is None
assert "绑定失败" in error
# ── SendVerificationCodeUseCase tests ───────────────────
class TestSendVerificationCodeUseCase:
def test_send_phone_code_success(self):
verify_svc = FakeVerificationCodeService(generate_code="654321")
sms_svc = FakeSmsService()
use_case = SendVerificationCodeUseCase(
verification_code_service=verify_svc,
sms_service=sms_svc,
)
req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="bind")
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert isinstance(response, SendVerificationCodeResponse)
assert response.expires_in > 0
assert response.resend_after == 60
assert len(verify_svc.generated) == 1
assert verify_svc.generated[0]["code_type"] == "phone_bind"
# SMS was sent
assert len(sms_svc.sent) == 1
assert sms_svc.sent[0]["phone"] == "13800138000"
assert sms_svc.sent[0]["code"] == "654321"
def test_send_email_code_success(self):
verify_svc = FakeVerificationCodeService(generate_code="111222")
email_svc = FakeEmailService()
use_case = SendVerificationCodeUseCase(
verification_code_service=verify_svc,
email_service=email_svc,
)
req = SendVerificationCodeRequest(target="email", value="test@example.com", purpose="bind")
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert len(verify_svc.generated) == 1
assert verify_svc.generated[0]["code_type"] == "email_bind"
assert len(email_svc.sent) == 1
assert email_svc.sent[0]["to"] == "test@example.com"
assert "111222" in email_svc.sent[0]["body"]
def test_invalid_phone_format(self):
verify_svc = FakeVerificationCodeService()
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
req = SendVerificationCodeRequest(target="phone", value="123", purpose="bind")
response, error = use_case.execute(req)
assert response is None
assert error is not None
def test_invalid_email_format(self):
verify_svc = FakeVerificationCodeService()
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
req = SendVerificationCodeRequest(target="email", value="not-email", purpose="bind")
response, error = use_case.execute(req)
assert response is None
assert error is not None
def test_unsupported_target_type(self):
verify_svc = FakeVerificationCodeService()
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
req = SendVerificationCodeRequest(target="carrier_pigeon", value="hello", purpose="bind")
response, error = use_case.execute(req)
assert response is None
assert "不支持的目标类型" in error
def test_email_recipient_lowercased(self):
verify_svc = FakeVerificationCodeService()
email_svc = FakeEmailService()
use_case = SendVerificationCodeUseCase(
verification_code_service=verify_svc,
email_service=email_svc,
)
req = SendVerificationCodeRequest(target="email", value="TEST@Example.COM", purpose="login")
response, error = use_case.execute(req)
assert error is None
# recipient should be lowercased
assert verify_svc.generated[0]["recipient"] == "test@example.com"
def test_no_sms_service_phone_still_returns_success(self):
"""If no SMS service is configured, code is generated but not sent."""
verify_svc = FakeVerificationCodeService()
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
req = SendVerificationCodeRequest(target="phone", value="13800138000", purpose="login")
response, error = use_case.execute(req)
assert error is None
assert response is not None
# code was generated
assert len(verify_svc.generated) == 1
def test_exception_handling(self):
verify_svc = MagicMock()
verify_svc.generate.side_effect = RuntimeError("Redis down")
use_case = SendVerificationCodeUseCase(verification_code_service=verify_svc)
req = SendVerificationCodeRequest(target="email", value="a@b.com", purpose="bind")
response, error = use_case.execute(req)
assert response is None
assert "发送失败" in error
def test_to_dict_returns_correct_fields(self):
resp = SendVerificationCodeResponse(expires_in=300, resend_after=60)
d = resp.to_dict()
assert d["expires_in"] == 300
assert d["resend_after"] == 60
# ── WechatSyncUseCase tests ─────────────────────────────
class TestWechatSyncUseCase:
def _make_use_case(self, user_repo=None, session_store=None, secret_key="test-secret-key-for-jwt"):
return WechatSyncUseCase(
user_repository=user_repo or FakeUserRepository(),
session_store=session_store or FakeSessionStore(),
jwt_secret_key=secret_key,
)
def test_existing_user_login_by_openid(self):
user = FakeUser(
id="user-1",
wechat_openid="openid-abc",
display_name="WeChat User",
)
repo = FakeUserRepository(user=user)
store = FakeSessionStore()
use_case = self._make_use_case(user_repo=repo, session_store=store)
req = WechatSyncRequest(openid="openid-abc")
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert isinstance(response, WechatSyncResponse)
assert response.user_id == "user-1"
assert response.is_new_user is False
assert response.access_token
assert response.refresh_token
# session created
assert len(store.saved_sessions) == 1
session = store.saved_sessions[0]
assert session["user_id"] == "user-1"
assert "wechat_" in session["device_info"]
# last login updated
assert repo.saved_user.last_login_at is not None
assert repo.saved_user.last_login_ip == "bff_gateway"
def test_existing_user_by_unionid_binds_openid(self):
user = FakeUser(
id="user-1",
wechat_openid=None, # no openid
wechat_unionid="unionid-xyz",
display_name="Existing User",
)
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = WechatSyncRequest(openid="openid-new", unionid="unionid-xyz")
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.user_id == "user-1"
assert response.is_new_user is False
# openid was bound
assert repo.saved_user.wechat_openid == "openid-new"
def test_new_user_creation(self):
repo = FakeUserRepository() # no existing user
store = FakeSessionStore()
use_case = self._make_use_case(user_repo=repo, session_store=store)
req = WechatSyncRequest(
openid="openid-new123",
nickname="微信昵称",
avatar_url="https://example.com/avatar.png",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.is_new_user is True
assert response.nickname == "微信昵称"
assert response.user_id
assert len(response.user_id) == 32 # uuid4 hex
# user was saved
assert repo.saved_user is not None
assert repo.saved_user.wechat_openid == "openid-new123"
assert repo.saved_user.email_verified is True
assert "wechat.local" in repo.saved_user.email
assert repo.saved_user.username.startswith("wx_")
assert repo.saved_user.display_name == "微信昵称"
def test_empty_openid_returns_error(self):
use_case = self._make_use_case()
req = WechatSyncRequest(openid="")
response, error = use_case.execute(req)
assert response is None
assert "openid is required" in error
def test_default_nickname_when_empty(self):
repo = FakeUserRepository()
use_case = self._make_use_case(user_repo=repo)
req = WechatSyncRequest(openid="openid-1", nickname="")
response, error = use_case.execute(req)
assert error is None
assert response.is_new_user is True
assert response.nickname == "微信用户"
def test_username_uniqueness_suffix(self):
"""When username already exists, a numeric suffix is added."""
# First user with same openid prefix
existing = FakeUser(username="wx_openidnew123_")
repo = FakeUserRepository(user=existing)
# Our simple FakeUserRepository only holds one user.
# Use MagicMock for more control.
repo = MagicMock()
repo.find_by_wechat_openid.return_value = None
repo.find_by_wechat_unionid.return_value = None
# first find_by_username returns a user (conflict), second time None (unique)
call_count = {"n": 0}
def mock_find_by_username(username):
call_count["n"] += 1
if call_count["n"] == 1:
return FakeUser(username=username) # conflict
return None # unique on second try
repo.find_by_username.side_effect = mock_find_by_username
repo.save = MagicMock(side_effect=lambda u: u)
use_case = WechatSyncUseCase(
user_repository=repo,
session_store=FakeSessionStore(),
jwt_secret_key="test-secret",
)
req = WechatSyncRequest(openid="openid-new123", nickname="Test")
response, error = use_case.execute(req)
assert error is None
assert response.is_new_user is True
# username should have _1 suffix
saved_user = repo.save.call_args[0][0]
assert saved_user.username.endswith("_1")
def test_to_dict_has_token_alias_for_compat(self):
user = FakeUser(id="u-1", wechat_openid="oid-1", display_name="Name")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = WechatSyncRequest(openid="oid-1")
response, _ = use_case.execute(req)
d = response.to_dict()
assert d["access_token"] == d["token"] # compat alias
assert d["is_new_user"] is False
assert d["user"]["id"] == "u-1"
assert d["user_info"]["display_name"] == "Name"
def test_access_token_has_correct_claims(self):
import jwt as pyjwt
user = FakeUser(id="user-99", wechat_openid="oid-99")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(user_repo=repo)
req = WechatSyncRequest(openid="oid-99")
response, _ = use_case.execute(req)
payload = pyjwt.decode(response.access_token, "test-secret-key-for-jwt", algorithms=["HS256"])
assert payload["sub"] == "user-99"
assert payload["type"] == "user_auth"
assert "sid" in payload
assert "exp" in payload
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_wechat_openid.side_effect = RuntimeError("DB down")
use_case = self._make_use_case(user_repo=repo)
req = WechatSyncRequest(openid="abc")
response, error = use_case.execute(req)
assert response is None
assert "Internal error" in error
+244
View File
@@ -0,0 +1,244 @@
"""
JWT + Password 委托层单元测试(第二十一波)
覆盖:
- JWTHandler (create/verify/configure/get)
- PasswordHandler (hash/verify/needs_rehash/validate_strength/configure/get)
"""
from unittest.mock import patch
import pytest
from jwt.exceptions import InvalidTokenError
from packages.application.auth.jwt_handler import (
JWTHandler,
configure_jwt_handler,
get_jwt_handler,
)
from packages.application.auth.password_handler import (
PasswordHandler,
configure_password_handler,
get_password_handler,
)
SECRET_KEY = "test-secret-key-for-unit-testing-only-not-for-production"
# ============================================================
# JWTHandler
# ============================================================
class TestJWTHandler:
"""JWTHandler JWT 委托层"""
def test_create_and_verify_access_token(self):
"""创建并验证 access_token"""
handler = JWTHandler(secret_key=SECRET_KEY)
token = handler.create_access_token(user_id="user-123", role="admin")
assert isinstance(token, str)
assert len(token) > 0
payload = handler.verify_access_token(token)
assert payload["sub"] == "user-123"
assert payload["role"] == "admin"
assert "exp" in payload
assert "type" in payload
assert payload["type"] == "access"
def test_create_token_with_additional_claims(self):
"""携带额外 claims"""
handler = JWTHandler(secret_key=SECRET_KEY)
token = handler.create_access_token(
user_id="user-1",
role="user",
additional_claims={"email": "a@b.com", "org_id": "org-1"},
)
payload = handler.verify_access_token(token)
assert payload["email"] == "a@b.com"
assert payload["org_id"] == "org-1"
def test_create_token_default_role(self):
"""默认 role 为空字符串"""
handler = JWTHandler(secret_key=SECRET_KEY)
token = handler.create_access_token(user_id="user-1")
payload = handler.verify_access_token(token)
assert payload["role"] == ""
def test_verify_generic_token(self):
"""verify_token 通用验证方法"""
handler = JWTHandler(secret_key=SECRET_KEY)
token = handler.create_access_token(user_id="user-1")
payload = handler.verify_token(token)
assert payload["sub"] == "user-1"
def test_verify_invalid_token_raises(self):
"""无效 token 验证失败"""
handler = JWTHandler(secret_key=SECRET_KEY)
with pytest.raises(InvalidTokenError):
handler.verify_access_token("invalid-token")
def test_verify_wrong_secret(self):
"""用不同密钥签名的 token 验证失败"""
handler1 = JWTHandler(secret_key="key-a")
handler2 = JWTHandler(secret_key="key-b")
token = handler1.create_access_token(user_id="user-1")
with pytest.raises(InvalidTokenError):
handler2.verify_access_token(token)
def test_custom_algorithm(self):
"""自定义算法"""
handler = JWTHandler(secret_key=SECRET_KEY, algorithm="HS256")
token = handler.create_access_token(user_id="user-1")
payload = handler.verify_access_token(token)
assert payload["sub"] == "user-1"
def test_custom_expire_minutes(self):
"""自定义过期时间"""
handler = JWTHandler(secret_key=SECRET_KEY, access_token_expire_minutes=60)
token = handler.create_access_token(user_id="user-1")
payload = handler.verify_access_token(token)
assert payload["sub"] == "user-1"
# ============================================================
# JWTHandler - 全局配置
# ============================================================
class TestJWTGlobalConfig:
"""JWT 全局配置与获取"""
def test_configure_and_get(self):
"""配置后可以获取"""
handler = configure_jwt_handler(secret_key=SECRET_KEY, access_token_expire_minutes=15)
assert isinstance(handler, JWTHandler)
got = get_jwt_handler()
assert got is handler
def test_reconfigure_replaces(self):
"""重新配置会替换"""
h1 = configure_jwt_handler(secret_key="key-a")
h2 = configure_jwt_handler(secret_key="key-b")
assert h1 is not h2
assert get_jwt_handler() is h2
# ============================================================
# PasswordHandler
# ============================================================
class TestPasswordHandler:
"""PasswordHandler 密码委托层"""
def test_hash_and_verify_correct(self):
"""哈希并验证正确密码"""
handler = PasswordHandler()
hashed = handler.hash_password("MySecurePass123")
assert isinstance(hashed, str)
assert hashed != "MySecurePass123"
assert handler.verify_password("MySecurePass123", hashed) is True
def test_verify_wrong_password(self):
"""验证错误密码"""
handler = PasswordHandler()
hashed = handler.hash_password("CorrectPass123")
assert handler.verify_password("WrongPass456", hashed) is False
def test_hash_is_unique_each_time(self):
"""同密码每次哈希不同(salt"""
handler = PasswordHandler()
h1 = handler.hash_password("SamePass123")
h2 = handler.hash_password("SamePass123")
assert h1 != h2
# 但都能验证通过
assert handler.verify_password("SamePass123", h1)
assert handler.verify_password("SamePass123", h2)
def test_needs_rehash_new_hash(self):
"""新生成的哈希不需要重新计算"""
handler = PasswordHandler()
hashed = handler.hash_password("TestPass123")
assert handler.needs_rehash(hashed) is False
def test_validate_strength_strong(self):
"""强密码校验通过"""
handler = PasswordHandler()
ok, err = handler.validate_strength("StrongPass123")
assert ok is True
assert err is None or err == ""
def test_validate_strength_too_short(self):
"""密码太短"""
handler = PasswordHandler()
ok, err = handler.validate_strength("Ab1")
assert ok is False
assert err is not None
def test_validate_strength_no_uppercase(self):
"""缺少大写字母"""
handler = PasswordHandler()
ok, err = handler.validate_strength("lowercase123")
assert ok is False
assert err is not None
def test_validate_strength_no_lowercase(self):
"""缺少小写字母"""
handler = PasswordHandler()
ok, err = handler.validate_strength("UPPERCASE123")
assert ok is False
assert err is not None
def test_validate_strength_no_digit(self):
"""缺少数字"""
handler = PasswordHandler()
ok, err = handler.validate_strength("NoDigitHere")
assert ok is False
assert err is not None
def test_hash_empty_password(self):
"""空密码哈希报错"""
handler = PasswordHandler()
with pytest.raises((ValueError, Exception)):
handler.hash_password("")
def test_custom_rounds(self):
"""自定义 rounds(用低轮次测试更快)"""
handler = PasswordHandler(rounds=4)
hashed = handler.hash_password("TestPass123")
assert handler.verify_password("TestPass123", hashed)
# ============================================================
# PasswordHandler - 全局配置
# ============================================================
class TestPasswordGlobalConfig:
"""Password 全局配置与获取"""
def test_get_default_handler(self):
"""未配置时 get 返回默认实例"""
# 重置默认实例
with patch("packages.application.auth.password_handler._default_handler", None):
handler = get_password_handler()
assert isinstance(handler, PasswordHandler)
def test_configure_and_get(self):
"""配置后可以获取"""
handler = configure_password_handler(rounds=4)
assert isinstance(handler, PasswordHandler)
got = get_password_handler()
assert got is handler
def test_reconfigure_replaces(self):
"""重新配置会替换"""
h1 = configure_password_handler(rounds=4)
h2 = configure_password_handler(rounds=6)
assert h1 is not h2
+589
View File
@@ -0,0 +1,589 @@
"""Auth login use cases unit tests.
Covers LoginUseCase, RefreshTokenUseCase, LogoutUseCase, and helper functions.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from packages.application.auth.login_use_case import (
LEGACY_SHA256_HEX_LENGTH,
LoginRequest,
LoginResponse,
LoginUseCase,
LogoutRequest,
LogoutUseCase,
RefreshTokenRequest,
RefreshTokenUseCase,
_is_legacy_sha256_hash,
_legacy_sha256,
)
from packages.application.auth.password_hasher import password_hasher
# ── Test helpers ─────────────────────────────────────────
@dataclass
class FakeUser:
id: str = "user-123"
email: str = "test@example.com"
display_name: str = "Test User"
username: str = "testuser"
password_hash: str = ""
email_verified: bool = True
last_login_at: datetime | None = None
last_login_ip: str | None = None
wechat_openid: str | None = None
wechat_unionid: str | None = None
class FakeUserRepository:
def __init__(self, user: FakeUser | None = None):
self._user = user
self.saved_user: FakeUser | None = None
def find_by_email(self, email: str) -> FakeUser | None:
if self._user and self._user.email == email:
return self._user
return None
def get(self, user_id: str) -> FakeUser | None:
if self._user and self._user.id == user_id:
return self._user
return None
def save(self, user: FakeUser) -> FakeUser:
self.saved_user = user
self._user = user
return user
class FakeSessionStore:
def __init__(self):
self._sessions: dict[str, dict] = {}
self._refresh_index: dict[str, str] = {} # refresh_token -> session_id
self.saved_sessions: list[dict] = []
self.deleted_sessions: list[str] = []
self.delete_all_called_for: str | None = None
self.delete_all_return_value = 0
def save_session(self, **kwargs) -> bool:
session_id = kwargs.get("session_id", "")
self._sessions[session_id] = kwargs
if kwargs.get("refresh_token"):
self._refresh_index[kwargs["refresh_token"]] = session_id
self.saved_sessions.append(kwargs)
return True
def get_session_by_refresh_token(self, refresh_token: str) -> dict | None:
session_id = self._refresh_index.get(refresh_token)
if not session_id:
return None
return self._sessions.get(session_id)
def get_refresh_token(self, session_id: str) -> str | None:
session = self._sessions.get(session_id)
if not session:
return None
return session.get("refresh_token")
def delete_session(self, session_id: str) -> bool:
self.deleted_sessions.append(session_id)
if session_id in self._sessions:
session = self._sessions.pop(session_id)
rt = session.get("refresh_token")
if rt and rt in self._refresh_index:
del self._refresh_index[rt]
return True
return False
def delete_all_user_sessions(self, user_id: str) -> int:
self.delete_all_called_for = user_id
count = self.delete_all_return_value
# actually clean up
to_delete = [sid for sid, s in self._sessions.items() if s.get("user_id") == user_id]
for sid in to_delete:
self.delete_session(sid)
return count or len(to_delete)
# ── Helper function tests ───────────────────────────────
class TestIsLegacySha256Hash:
def test_valid_sha256_hex(self):
h = hashlib.sha256(b"password").hexdigest()
assert _is_legacy_sha256_hash(h) is True
def test_bcrypt_hash_not_legacy(self):
h = password_hasher.hash_password("password")
assert _is_legacy_sha256_hash(h) is False
def test_empty_string(self):
assert _is_legacy_sha256_hash("") is False
def test_short_string(self):
assert _is_legacy_sha256_hash("abc123") is False
def test_64_chars_non_hex(self):
s = "g" * 64 # 'g' is not hex
assert _is_legacy_sha256_hash(s) is False
def test_exact_64_hex_chars(self):
s = "a" * 64
assert _is_legacy_sha256_hash(s) is True
def test_mixed_case_hex(self):
s = "AbCdEf01" * 8 # 64 chars mixed case hex
assert len(s) == 64
assert _is_legacy_sha256_hash(s) is True
class TestLegacySha256:
def test_matches_hashlib(self):
password = "mypassword"
expected = hashlib.sha256(password.encode()).hexdigest()
assert _legacy_sha256(password) == expected
def test_empty_password(self):
assert _legacy_sha256("") == hashlib.sha256(b"").hexdigest()
def test_unicode_password(self):
result = _legacy_sha256("密码测试")
assert len(result) == LEGACY_SHA256_HEX_LENGTH
assert all(c in "0123456789abcdef" for c in result)
# ── LoginRequest tests ──────────────────────────────────
class TestLoginRequest:
def test_email_stripped_and_lowercased(self):
req = LoginRequest(email=" Test@Example.COM ", password="pass")
assert req.email == "test@example.com"
def test_default_device_info(self):
req = LoginRequest(email="a@b.com", password="pass")
assert req.device_info == "Unknown"
def test_default_ip_address(self):
req = LoginRequest(email="a@b.com", password="pass")
assert req.ip_address == "unknown"
def test_custom_device_and_ip(self):
req = LoginRequest(email="a@b.com", password="pass", device_info="iPhone", ip_address="1.2.3.4")
assert req.device_info == "iPhone"
assert req.ip_address == "1.2.3.4"
# ── LoginUseCase tests ──────────────────────────────────
class TestLoginUseCase:
def _make_user_with_password(self, password: str = "password123") -> FakeUser:
return FakeUser(password_hash=password_hasher.hash_password(password))
def test_successful_login(self):
user = self._make_user_with_password("mypassword")
repo = FakeUserRepository(user)
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="test@example.com", password="mypassword")
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert isinstance(response, LoginResponse)
assert response.user_id == "user-123"
assert response.email == "test@example.com"
assert response.username == "testuser"
assert response.display_name == "Test User"
assert response.access_token
assert response.refresh_token
assert response.expires_in > 0
# session was saved
assert len(store.saved_sessions) == 1
saved = store.saved_sessions[0]
assert saved["user_id"] == "user-123"
assert saved["device_info"] == "Unknown"
assert saved["ip_address"] == "unknown"
assert saved["expires_in_seconds"] == 30 * 24 * 3600
# last_login updated
assert repo.saved_user is not None
assert repo.saved_user.last_login_at is not None
assert repo.saved_user.last_login_ip == "unknown"
def test_successful_login_with_device_and_ip(self):
user = self._make_user_with_password("pass")
repo = FakeUserRepository(user)
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(
email="test@example.com",
password="pass",
device_info="Chrome/Win10",
ip_address="192.168.1.1",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
saved = store.saved_sessions[0]
assert saved["device_info"] == "Chrome/Win10"
assert saved["ip_address"] == "192.168.1.1"
assert repo.saved_user.last_login_ip == "192.168.1.1"
def test_empty_email_returns_error(self):
repo = FakeUserRepository()
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="", password="pass")
response, error = use_case.execute(req)
assert response is None
assert "Email is required" in error
def test_empty_password_returns_error(self):
repo = FakeUserRepository()
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="a@b.com", password="")
response, error = use_case.execute(req)
assert response is None
assert "Password is required" in error
def test_user_not_found_returns_error(self):
repo = FakeUserRepository()
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="nobody@example.com", password="pass")
response, error = use_case.execute(req)
assert response is None
assert "Invalid email or password" in error
def test_wrong_password_returns_error(self):
user = self._make_user_with_password("correctpassword")
repo = FakeUserRepository(user)
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="test@example.com", password="wrongpassword")
response, error = use_case.execute(req)
assert response is None
assert "Invalid email or password" in error
# no session created
assert len(store.saved_sessions) == 0
def test_legacy_sha256_hash_login_success_and_upgrade(self):
password = "oldpassword"
legacy_hash = _legacy_sha256(password)
assert _is_legacy_sha256_hash(legacy_hash)
user = FakeUser(password_hash=legacy_hash)
repo = FakeUserRepository(user)
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="test@example.com", password=password)
response, error = use_case.execute(req)
assert error is None
assert response is not None
# password should have been upgraded to bcrypt
assert repo.saved_user is not None
assert not _is_legacy_sha256_hash(repo.saved_user.password_hash)
assert repo.saved_user.password_hash.startswith("$2b$")
# new hash should verify correctly
assert password_hasher.verify_password(password, repo.saved_user.password_hash)
def test_legacy_sha256_hash_wrong_password(self):
password = "rightpassword"
legacy_hash = _legacy_sha256(password)
user = FakeUser(password_hash=legacy_hash)
repo = FakeUserRepository(user)
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="test@example.com", password="wrongpassword")
response, error = use_case.execute(req)
assert response is None
assert "Invalid email or password" in error
# password hash unchanged
assert repo.saved_user is None
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_email.side_effect = RuntimeError("DB connection failed")
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="a@b.com", password="pass")
response, error = use_case.execute(req)
assert response is None
assert error is not None
assert "Login failed" in error
def test_access_token_contains_correct_claims(self):
import jwt as pyjwt
user = self._make_user_with_password("pass")
repo = FakeUserRepository(user)
store = FakeSessionStore()
use_case = LoginUseCase(user_repository=repo, session_store=store)
req = LoginRequest(email="test@example.com", password="pass")
response, error = use_case.execute(req)
assert error is None
assert response is not None
payload = pyjwt.decode(
response.access_token,
use_case.jwt_secret_key,
algorithms=["HS256"],
)
assert payload["sub"] == "user-123"
assert payload["type"] == "user_auth"
assert "sid" in payload
assert "iat" in payload
assert "exp" in payload
# ── RefreshTokenUseCase tests ───────────────────────────
class TestRefreshTokenUseCase:
def test_successful_refresh(self):
user = FakeUser()
repo = FakeUserRepository(user)
store = FakeSessionStore()
# create a session first
store.save_session(
session_id="sess-1",
user_id="user-123",
refresh_token="refresh-token-xyz",
device_info="Chrome",
ip_address="1.2.3.4",
expires_in_seconds=3600,
)
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="refresh-token-xyz")
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.user_id == "user-123"
assert response.access_token
# same refresh token returned
assert response.refresh_token == "refresh-token-xyz"
def test_empty_refresh_token(self):
repo = FakeUserRepository()
store = FakeSessionStore()
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="")
response, error = use_case.execute(req)
assert response is None
assert "Refresh token is required" in error
def test_invalid_refresh_token(self):
repo = FakeUserRepository()
store = FakeSessionStore()
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="nonexistent-token")
response, error = use_case.execute(req)
assert response is None
assert "Invalid or expired refresh token" in error
def test_session_missing_session_id(self):
repo = FakeUserRepository()
store = FakeSessionStore()
# session with no session_id
store._refresh_index["bad-token"] = "bad-sess"
store._sessions["bad-sess"] = {"user_id": "user-123"} # no session_id
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="bad-token")
response, error = use_case.execute(req)
assert response is None
assert "Invalid session data" in error
def test_session_missing_user_id(self):
repo = FakeUserRepository()
store = FakeSessionStore()
store._refresh_index["bad-token"] = "bad-sess"
store._sessions["bad-sess"] = {"session_id": "bad-sess"} # no user_id
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="bad-token")
response, error = use_case.execute(req)
assert response is None
assert "Invalid session data" in error
def test_refresh_token_mismatch(self):
user = FakeUser()
repo = FakeUserRepository(user)
store = FakeSessionStore()
store.save_session(
session_id="sess-1",
user_id="user-123",
refresh_token="original-token",
device_info="Chrome",
ip_address="1.2.3.4",
expires_in_seconds=3600,
)
# Manually add a stale reverse index pointing to same session
store._refresh_index["stale-token"] = "sess-1"
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="stale-token")
response, error = use_case.execute(req)
assert response is None
assert "Refresh token mismatch" in error
def test_user_not_found(self):
repo = FakeUserRepository() # no users
store = FakeSessionStore()
store.save_session(
session_id="sess-1",
user_id="nonexistent-user",
refresh_token="valid-token",
device_info="Chrome",
ip_address="1.2.3.4",
expires_in_seconds=3600,
)
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="valid-token")
response, error = use_case.execute(req)
assert response is None
assert "User not found" in error
def test_exception_handling(self):
repo = MagicMock()
repo.get.side_effect = RuntimeError("DB down")
store = FakeSessionStore()
store.save_session(
session_id="sess-1",
user_id="user-123",
refresh_token="valid-token",
device_info="Chrome",
ip_address="1.2.3.4",
expires_in_seconds=3600,
)
use_case = RefreshTokenUseCase(user_repository=repo, session_store=store)
req = RefreshTokenRequest(refresh_token="valid-token")
response, error = use_case.execute(req)
assert response is None
assert "Token refresh failed" in error
# ── LogoutUseCase tests ─────────────────────────────────
class TestLogoutUseCase:
def test_logout_single_device_success(self):
store = FakeSessionStore()
store.save_session(
session_id="sess-1",
user_id="user-123",
refresh_token="token1",
device_info="Chrome",
ip_address="1.2.3.4",
expires_in_seconds=3600,
)
use_case = LogoutUseCase(session_store=store)
req = LogoutRequest(user_id="user-123", session_id="sess-1")
success, error = use_case.execute(req)
assert success is True
assert error is None
assert "sess-1" in store.deleted_sessions
def test_logout_single_device_no_session_id(self):
store = FakeSessionStore()
use_case = LogoutUseCase(session_store=store)
req = LogoutRequest(user_id="user-123", session_id=None)
success, error = use_case.execute(req)
assert success is False
assert "Session ID is required" in error
def test_logout_single_device_session_not_found(self):
store = FakeSessionStore()
use_case = LogoutUseCase(session_store=store)
req = LogoutRequest(user_id="user-123", session_id="nonexistent")
success, error = use_case.execute(req)
assert success is False
assert "Session not found" in error
def test_logout_all_devices(self):
store = FakeSessionStore()
store.delete_all_return_value = 3
use_case = LogoutUseCase(session_store=store)
req = LogoutRequest(user_id="user-123", logout_all_devices=True)
success, error = use_case.execute(req)
assert success is True
assert error is None
assert store.delete_all_called_for == "user-123"
def test_logout_all_with_empty_session_id(self):
store = FakeSessionStore()
use_case = LogoutUseCase(session_store=store)
# logout_all should work even without session_id
req = LogoutRequest(user_id="user-123", session_id=None, logout_all_devices=True)
success, error = use_case.execute(req)
assert success is True
assert error is None
def test_exception_handling(self):
store = MagicMock()
store.delete_session.side_effect = RuntimeError("Redis down")
use_case = LogoutUseCase(session_store=store)
req = LogoutRequest(user_id="user-123", session_id="sess-1")
success, error = use_case.execute(req)
assert success is False
assert "Logout failed" in error
+705
View File
@@ -0,0 +1,705 @@
"""Auth register + password reset use cases unit tests.
Covers RegisterUserUseCase, VerifyEmailUseCase,
RequestPasswordResetUseCase, ResetPasswordUseCase.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from packages.application.auth.password_hasher import password_hasher
from packages.application.auth.password_reset_use_case import (
RequestPasswordResetRequest,
RequestPasswordResetUseCase,
ResetPasswordRequest,
ResetPasswordUseCase,
)
from packages.application.auth.register_user_use_case import (
RegisterUserRequest,
RegisterUserResponse,
RegisterUserUseCase,
VerifyEmailRequest,
VerifyEmailUseCase,
)
# ── Test helpers ─────────────────────────────────────────
class FakeUser:
def __init__(self, **kwargs):
self.id = kwargs.get("id", "user-123")
self.email = kwargs.get("email", "test@example.com")
self.display_name = kwargs.get("display_name", "Test User")
self.username = kwargs.get("username", "testuser")
self.password_hash = kwargs.get("password_hash", "")
self.email_verified = kwargs.get("email_verified", False)
self.email_verification_token = kwargs.get("email_verification_token", None)
self.password_reset_token = kwargs.get("password_reset_token", None)
self.password_reset_expires_at = kwargs.get("password_reset_expires_at", None)
self.created_at = kwargs.get("created_at", datetime.now(timezone.utc))
class FakeUserRepository:
def __init__(self, user=None):
self._user = user
self.saved_user = None
self.save_called = 0
def find_by_email(self, email):
if self._user and self._user.email == email:
return self._user
return None
def find_by_username(self, username):
if self._user and self._user.username == username:
return self._user
return None
def find_by_verification_token(self, token):
if self._user and self._user.email_verification_token == token:
return self._user
return None
def find_by_password_reset_token(self, token):
if self._user and self._user.password_reset_token == token:
return self._user
return None
def save(self, user):
self.saved_user = user
self.save_called += 1
self._user = user
return user
class FakeEmailService:
def __init__(self, send_success=True, send_error=None):
self._send_success = send_success
self._send_error = send_error
self.sent_emails = []
self.verification_emails = []
self.password_reset_emails = []
def send_verification_email(self, to_email, username, verification_url):
self.verification_emails.append(
{
"to": to_email,
"username": username,
"url": verification_url,
}
)
self.sent_emails.append(("verification", to_email))
return self._send_success, self._send_error
def send_password_reset_email(self, to_email, username, reset_url):
self.password_reset_emails.append(
{
"to": to_email,
"username": username,
"url": reset_url,
}
)
self.sent_emails.append(("password_reset", to_email))
return self._send_success, self._send_error
class FailingEmailService:
"""Email service that raises an exception."""
def send_verification_email(self, **kwargs):
raise RuntimeError("SMTP connection failed")
def send_password_reset_email(self, **kwargs):
raise RuntimeError("SMTP connection failed")
# ── RegisterUserUseCase tests ───────────────────────────
class TestRegisterUserUseCase:
def _make_use_case(self, repo=None, email_service=None):
return RegisterUserUseCase(
user_repository=repo or FakeUserRepository(),
base_url="https://app.example.com",
email_service=email_service or FakeEmailService(),
)
def test_successful_registration(self):
repo = FakeUserRepository()
email_svc = FakeEmailService()
use_case = self._make_use_case(repo=repo, email_service=email_svc)
req = RegisterUserRequest(
email="newuser@example.com",
password="StrongPass123!",
username="newuser",
display_name="New User",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert isinstance(response, RegisterUserResponse)
assert response.email == "newuser@example.com"
assert response.username == "newuser"
assert response.display_name == "New User"
assert response.user_id
assert response.email_verification_sent is True
# user was saved
assert repo.saved_user is not None
assert repo.saved_user.email == "newuser@example.com"
assert repo.saved_user.email_verified is False
assert repo.saved_user.email_verification_token is not None
# password was hashed
assert repo.saved_user.password_hash != "StrongPass123!"
assert password_hasher.verify_password("StrongPass123!", repo.saved_user.password_hash)
# email was sent
assert len(email_svc.verification_emails) == 1
sent = email_svc.verification_emails[0]
assert sent["to"] == "newuser@example.com"
assert sent["username"] == "newuser"
assert "verify-email?token=" in sent["url"]
assert "https://app.example.com" in sent["url"]
def test_email_stripped_and_lowercased(self):
repo = FakeUserRepository()
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(
email=" NEWUSER@EXAMPLE.COM ",
password="StrongPass123!",
username="newuser",
display_name="New User",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.email == "newuser@example.com"
assert repo.saved_user.email == "newuser@example.com"
def test_username_stripped(self):
repo = FakeUserRepository()
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(
email="a@b.com",
password="StrongPass123!",
username=" myuser ",
display_name="Display",
)
response, error = use_case.execute(req)
assert error is None
assert response.username == "myuser"
def test_display_name_stripped(self):
repo = FakeUserRepository()
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(
email="a@b.com",
password="StrongPass123!",
username="user",
display_name=" My Name ",
)
response, error = use_case.execute(req)
assert error is None
assert response.display_name == "My Name"
def test_empty_email_returns_error(self):
use_case = self._make_use_case()
req = RegisterUserRequest(email="", password="StrongPass123!", username="u", display_name="D")
response, error = use_case.execute(req)
assert response is None
assert "Email is required" in error
def test_empty_username_returns_error(self):
use_case = self._make_use_case()
req = RegisterUserRequest(email="a@b.com", password="StrongPass123!", username=" ", display_name="D")
response, error = use_case.execute(req)
assert response is None
assert "Username is required" in error
def test_empty_display_name_returns_error(self):
use_case = self._make_use_case()
req = RegisterUserRequest(email="a@b.com", password="StrongPass123!", username="u", display_name=" ")
response, error = use_case.execute(req)
assert response is None
assert "Display name is required" in error
def test_weak_password_returns_error(self):
use_case = self._make_use_case()
req = RegisterUserRequest(email="a@b.com", password="weak", username="u", display_name="D")
response, error = use_case.execute(req)
assert response is None
assert error is not None
# password validation error message
assert len(error) > 0
def test_email_already_registered(self):
existing = FakeUser(email="existing@example.com", username="existinguser")
repo = FakeUserRepository(user=existing)
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(
email="existing@example.com",
password="StrongPass123!",
username="newuser",
display_name="New User",
)
response, error = use_case.execute(req)
assert response is None
assert "Email already registered" in error
# no new user saved
assert repo.save_called == 0
def test_username_already_taken(self):
existing = FakeUser(email="other@example.com", username="taken")
repo = FakeUserRepository(user=existing)
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(
email="new@example.com",
password="StrongPass123!",
username="taken",
display_name="New User",
)
response, error = use_case.execute(req)
assert response is None
assert "Username already taken" in error
def test_email_service_failure_user_still_created(self):
repo = FakeUserRepository()
email_svc = FakeEmailService(send_success=False, send_error="SMTP error")
use_case = self._make_use_case(repo=repo, email_service=email_svc)
req = RegisterUserRequest(
email="a@b.com",
password="StrongPass123!",
username="user",
display_name="User",
)
response, error = use_case.execute(req)
# user still created
assert error is None
assert response is not None
assert response.email_verification_sent is False
assert repo.saved_user is not None
def test_email_service_exception_user_still_created(self):
repo = FakeUserRepository()
use_case = RegisterUserUseCase(
user_repository=repo,
base_url="https://app.example.com",
email_service=FailingEmailService(),
)
req = RegisterUserRequest(
email="a@b.com",
password="StrongPass123!",
username="user",
display_name="User",
)
response, error = use_case.execute(req)
assert error is None
assert response is not None
assert response.email_verification_sent is False
assert repo.saved_user is not None
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_email.side_effect = RuntimeError("DB down")
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(
email="a@b.com",
password="StrongPass123!",
username="user",
display_name="User",
)
response, error = use_case.execute(req)
assert response is None
assert "Registration failed" in error
def test_user_id_is_generated(self):
repo = FakeUserRepository()
use_case = self._make_use_case(repo=repo)
req = RegisterUserRequest(email="a@b.com", password="StrongPass123!", username="u", display_name="D")
response, _ = use_case.execute(req)
assert response.user_id
assert len(response.user_id) == 32 # uuid4 hex
# ── VerifyEmailUseCase tests ────────────────────────────
class TestVerifyEmailUseCase:
def test_successful_verification(self):
user = FakeUser(email_verified=False, email_verification_token="test-token-123")
repo = FakeUserRepository(user=user)
use_case = VerifyEmailUseCase(user_repository=repo)
req = VerifyEmailRequest(token="test-token-123")
success, error = use_case.execute(req)
assert success is True
assert error is None
assert repo.saved_user.email_verified is True
assert repo.saved_user.email_verification_token is None
def test_empty_token(self):
repo = FakeUserRepository()
use_case = VerifyEmailUseCase(user_repository=repo)
req = VerifyEmailRequest(token="")
success, error = use_case.execute(req)
assert success is False
assert "Verification token is required" in error
def test_invalid_token(self):
repo = FakeUserRepository()
use_case = VerifyEmailUseCase(user_repository=repo)
req = VerifyEmailRequest(token="nonexistent-token")
success, error = use_case.execute(req)
assert success is False
assert "Invalid or expired verification token" in error
def test_already_verified_returns_success(self):
user = FakeUser(email_verified=True, email_verification_token="some-token")
repo = FakeUserRepository(user=user)
use_case = VerifyEmailUseCase(user_repository=repo)
req = VerifyEmailRequest(token="some-token")
success, error = use_case.execute(req)
assert success is True
assert error is None
# idempotent - no save needed
# (depends on implementation - current returns early without save)
def test_token_cleared_after_verification(self):
user = FakeUser(email_verified=False, email_verification_token="tok123")
repo = FakeUserRepository(user=user)
use_case = VerifyEmailUseCase(user_repository=repo)
req = VerifyEmailRequest(token="tok123")
use_case.execute(req)
assert repo.saved_user.email_verification_token is None
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_verification_token.side_effect = RuntimeError("DB down")
use_case = VerifyEmailUseCase(user_repository=repo)
req = VerifyEmailRequest(token="token")
success, error = use_case.execute(req)
assert success is False
assert "Email verification failed" in error
# ── RequestPasswordResetUseCase tests ───────────────────
class TestRequestPasswordResetUseCase:
def _make_use_case(self, repo=None, email_service=None, expire_hours=1):
return RequestPasswordResetUseCase(
user_repository=repo or FakeUserRepository(),
base_url="https://app.example.com",
token_expire_hours=expire_hours,
email_service=email_service or FakeEmailService(),
)
def test_successful_request(self):
user = FakeUser(email="user@example.com", username="testuser")
repo = FakeUserRepository(user=user)
email_svc = FakeEmailService()
use_case = self._make_use_case(repo=repo, email_service=email_svc)
req = RequestPasswordResetRequest(email="user@example.com")
success, error = use_case.execute(req)
assert success is True
assert error is None
# token set on user
assert repo.saved_user.password_reset_token is not None
assert len(repo.saved_user.password_reset_token) > 10
assert repo.saved_user.password_reset_expires_at is not None
# email sent
assert len(email_svc.password_reset_emails) == 1
sent = email_svc.password_reset_emails[0]
assert sent["to"] == "user@example.com"
assert "reset-password?token=" in sent["url"]
def test_empty_email(self):
use_case = self._make_use_case()
req = RequestPasswordResetRequest(email="")
success, error = use_case.execute(req)
assert success is False
assert "Email is required" in error
def test_nonexistent_user_returns_true_security(self):
repo = FakeUserRepository() # no users
use_case = self._make_use_case(repo=repo)
req = RequestPasswordResetRequest(email="nobody@example.com")
success, error = use_case.execute(req)
# Always returns true to prevent user enumeration
assert success is True
assert error is None
assert repo.save_called == 0 # no save
def test_token_expiry_set_correctly(self):
user = FakeUser(email="u@e.com")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(repo=repo, expire_hours=2)
before = datetime.now(timezone.utc)
req = RequestPasswordResetRequest(email="u@e.com")
use_case.execute(req)
after = datetime.now(timezone.utc)
expires_at = repo.saved_user.password_reset_expires_at
assert expires_at is not None
# should be ~2 hours from now
min_expected = before + timedelta(hours=2)
max_expected = after + timedelta(hours=2)
assert min_expected <= expires_at <= max_expected + timedelta(seconds=1)
def test_default_expire_hours(self):
user = FakeUser(email="u@e.com")
repo = FakeUserRepository(user=user)
use_case = RequestPasswordResetUseCase(
user_repository=repo,
base_url="https://app.example.com",
email_service=FakeEmailService(),
)
before = datetime.now(timezone.utc)
req = RequestPasswordResetRequest(email="u@e.com")
use_case.execute(req)
expires_at = repo.saved_user.password_reset_expires_at
assert expires_at is not None
# default is 1 hour
assert timedelta(minutes=55) < (expires_at - before) < timedelta(hours=1, minutes=1)
def test_email_service_failure_still_returns_true(self):
user = FakeUser(email="u@e.com")
repo = FakeUserRepository(user=user)
email_svc = FakeEmailService(send_success=False, send_error="SMTP down")
use_case = self._make_use_case(repo=repo, email_service=email_svc)
req = RequestPasswordResetRequest(email="u@e.com")
success, error = use_case.execute(req)
# still returns true for security
assert success is True
assert error is None
# token still set
assert repo.saved_user.password_reset_token is not None
def test_email_service_exception_still_returns_true(self):
user = FakeUser(email="u@e.com")
repo = FakeUserRepository(user=user)
use_case = RequestPasswordResetUseCase(
user_repository=repo,
base_url="https://app.example.com",
email_service=FailingEmailService(),
)
req = RequestPasswordResetRequest(email="u@e.com")
success, error = use_case.execute(req)
assert success is True
assert error is None
assert repo.saved_user.password_reset_token is not None
def test_email_stripped_lowercased(self):
user = FakeUser(email="u@e.com")
repo = FakeUserRepository(user=user)
use_case = self._make_use_case(repo=repo)
req = RequestPasswordResetRequest(email=" U@E.COM ")
# email would be normalized but find_by_email should still find it
# since our fake repo compares exact strings
# Let's just check the normalization happens
assert req.email == "u@e.com"
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_email.side_effect = RuntimeError("DB down")
use_case = self._make_use_case(repo=repo)
req = RequestPasswordResetRequest(email="a@b.com")
success, error = use_case.execute(req)
assert success is False
assert "Password reset request failed" in error
# ── ResetPasswordUseCase tests ──────────────────────────
class TestResetPasswordUseCase:
def test_successful_reset(self):
token = "reset-token-123"
old_hash = password_hasher.hash_password("oldpassword")
user = FakeUser(
password_hash=old_hash,
password_reset_token=token,
password_reset_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
repo = FakeUserRepository(user=user)
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token=token, new_password="NewStrongPass456!")
success, error = use_case.execute(req)
assert success is True
assert error is None
# password updated
assert repo.saved_user.password_hash != old_hash
assert password_hasher.verify_password("NewStrongPass456!", repo.saved_user.password_hash)
# token cleared
assert repo.saved_user.password_reset_token is None
assert repo.saved_user.password_reset_expires_at is None
def test_empty_token(self):
use_case = ResetPasswordUseCase(user_repository=FakeUserRepository())
req = ResetPasswordRequest(token="", new_password="StrongPass123!")
success, error = use_case.execute(req)
assert success is False
assert "Reset token is required" in error
def test_empty_new_password(self):
use_case = ResetPasswordUseCase(user_repository=FakeUserRepository())
req = ResetPasswordRequest(token="token", new_password="")
success, error = use_case.execute(req)
assert success is False
assert "New password is required" in error
def test_weak_new_password(self):
use_case = ResetPasswordUseCase(user_repository=FakeUserRepository())
req = ResetPasswordRequest(token="token", new_password="weak")
success, error = use_case.execute(req)
assert success is False
assert error is not None
# some validation error
assert len(error) > 0
def test_invalid_token(self):
repo = FakeUserRepository() # no user with this token
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token="bad-token", new_password="StrongPass123!")
success, error = use_case.execute(req)
assert success is False
assert "Invalid or expired reset token" in error
def test_expired_token(self):
token = "expired-token"
user = FakeUser(
password_reset_token=token,
password_reset_expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
repo = FakeUserRepository(user=user)
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
success, error = use_case.execute(req)
assert success is False
assert "expired" in error.lower()
def test_naive_datetime_expiry_treated_as_utc(self):
token = "naive-token"
# naive datetime representing UTC time 1 hour in the past
naive_expired = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
user = FakeUser(
password_reset_token=token,
password_reset_expires_at=naive_expired,
)
repo = FakeUserRepository(user=user)
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
success, error = use_case.execute(req)
assert success is False
assert "expired" in error.lower()
def test_no_expiry_set_does_not_expire(self):
token = "no-expiry-token"
user = FakeUser(
password_reset_token=token,
password_reset_expires_at=None,
)
repo = FakeUserRepository(user=user)
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
success, error = use_case.execute(req)
# no expiry = should work
assert success is True
assert error is None
def test_token_cleared_after_reset(self):
token = "clear-me"
user = FakeUser(
password_reset_token=token,
password_reset_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
repo = FakeUserRepository(user=user)
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token=token, new_password="StrongPass123!")
use_case.execute(req)
assert repo.saved_user.password_reset_token is None
assert repo.saved_user.password_reset_expires_at is None
def test_exception_handling(self):
repo = MagicMock()
repo.find_by_password_reset_token.side_effect = RuntimeError("DB down")
use_case = ResetPasswordUseCase(user_repository=repo)
req = ResetPasswordRequest(token="token", new_password="StrongPass123!")
success, error = use_case.execute(req)
assert success is False
assert "Password reset failed" in error
+3 -18
View File
@@ -456,25 +456,10 @@ class TestJWTService:
service.verify_token(token)
def test_verify_token_tampered_signature(self, service):
"""篡改payload的token无法验证(签名不匹配)"""
import base64
import json
"""篡改签名的token无法验证"""
token = service.create_access_token(user_id="user_123")
parts = token.split(".")
assert len(parts) == 3
# 篡改 payload 部分(改用户ID),会导致签名不匹配
payload_b64 = parts[1]
# 补 padding 以便解码
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes)
payload["sub"] = "hacked_user"
new_payload_bytes = json.dumps(payload).encode()
new_payload_b64 = base64.urlsafe_b64encode(new_payload_bytes).rstrip(b"=").decode()
tampered = f"{parts[0]}.{new_payload_b64}.{parts[2]}"
# 篡改最后一个字符
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
with pytest.raises(InvalidTokenError):
service.verify_token(tampered)
File diff suppressed because it is too large Load Diff
+4
View File
@@ -73,6 +73,7 @@ class TestGenerationTaskCreate:
asset_select_mode="smart",
batch_id="batch-001",
video_title="测试视频",
resolution="1080x1920",
auto_retry_enabled=True,
auto_retry_max=3,
)
@@ -87,6 +88,7 @@ class TestGenerationTaskCreate:
assert task.asset_select_mode == "smart"
assert task.batch_id == "batch-001"
assert task.video_title == "测试视频"
assert task.resolution == "1080x1920"
assert task.auto_retry_enabled is True
assert task.auto_retry_max == 3
@@ -134,12 +136,14 @@ class TestGenerationTaskCreate:
strategy_id=" strat-789 ",
template_id=" tmpl-001 ",
video_title=" 测试视频 ",
resolution=" 1080x1920 ",
)
assert task.project_id == "proj-123"
assert task.asset_library_id == "lib-456"
assert task.strategy_id == "strat-789"
assert task.template_id == "tmpl-001"
assert task.video_title == "测试视频"
assert task.resolution == "1080x1920"
def test_create_default_empty_lists(self):
"""测试 None 列表默认化为空列表"""
+320
View File
@@ -0,0 +1,320 @@
"""
生成任务应用层用例单元测试(第十九波)
覆盖:
- CreateGenerationTaskUseCase
- GetGenerationTaskUseCase
- ListUserTasksFilteredUseCase
- RetryGenerationTaskUseCase
- Command / Filter / Result 对象
"""
from unittest.mock import MagicMock
import pytest
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
GetGenerationTaskUseCase,
ListGenerationTasksResult,
ListTasksFilter,
ListUserTasksFilteredUseCase,
RetryGenerationTaskUseCase,
)
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
@pytest.fixture
def mock_repo():
return MagicMock()
def make_task(status=GenerationTaskStatus.PENDING, **kwargs):
task = GenerationTask(
id="task-1",
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="strat-1",
template_id="tmpl-1",
asset_ids=["asset-1"],
title_ids=["title-1"],
voice_ids=["voice-1"],
created_by_user_id="user-1",
video_title="测试标题",
)
if status != GenerationTaskStatus.PENDING:
object.__setattr__(task, "status", status)
# 应用额外 kwargs
for k, v in kwargs.items():
object.__setattr__(task, k, v)
return task
# ============================================================
# CreateGenerationTaskUseCase
# ============================================================
class TestCreateGenerationTaskUseCase:
"""CreateGenerationTaskUseCase 创建生成任务"""
def test_create_success(self, mock_repo):
"""正常创建任务"""
mock_repo.create.side_effect = lambda t: t
cmd = CreateGenerationTaskCommand(
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="strat-1",
voice_library_id="vlib-1",
template_id="tmpl-1",
asset_ids=["a1", "a2"],
title_ids=["t1"],
voice_ids=["v1"],
created_by_user_id="user-1",
source_edit_plan_id="plan-1",
asset_select_mode="auto",
batch_id="batch-1",
video_title="我的视频",
auto_retry_enabled=True,
auto_retry_max=3,
)
uc = CreateGenerationTaskUseCase(mock_repo)
task = uc.execute(cmd)
assert task.project_id == "proj-1"
assert task.asset_library_id == "lib-1"
assert task.strategy_id == "strat-1"
assert task.voice_library_id == "vlib-1"
assert task.template_id == "tmpl-1"
assert task.asset_ids == ["a1", "a2"]
assert task.title_ids == ["t1"]
assert task.voice_ids == ["v1"]
assert task.created_by_user_id == "user-1"
assert task.source_edit_plan_id == "plan-1"
assert task.asset_select_mode == "auto"
assert task.batch_id == "batch-1"
assert task.video_title == "我的视频"
assert task.auto_retry_enabled is True
assert task.auto_retry_max == 3
assert task.status == GenerationTaskStatus.PENDING
assert task.progress == 0.0
assert task.result_count == 0
mock_repo.create.assert_called_once()
def test_create_default_values(self, mock_repo):
"""默认参数值"""
mock_repo.create.side_effect = lambda t: t
cmd = CreateGenerationTaskCommand(
project_id="proj-1",
asset_library_id="lib-1",
)
uc = CreateGenerationTaskUseCase(mock_repo)
task = uc.execute(cmd)
assert task.asset_ids == []
assert task.title_ids == []
assert task.voice_ids == []
assert task.created_by_user_id == ""
assert task.video_title == ""
assert task.auto_retry_enabled is False
assert task.auto_retry_max == 0
def test_create_id_is_generated(self, mock_repo):
"""ID 会自动生成"""
mock_repo.create.side_effect = lambda t: t
cmd = CreateGenerationTaskCommand(project_id="proj-1", asset_library_id="lib-1")
uc = CreateGenerationTaskUseCase(mock_repo)
task = uc.execute(cmd)
assert task.id
assert isinstance(task.id, str)
assert len(task.id) > 10 # uuid hex
# ============================================================
# GetGenerationTaskUseCase
# ============================================================
class TestGetGenerationTaskUseCase:
"""GetGenerationTaskUseCase 获取任务"""
def test_get_existing(self, mock_repo):
"""获取存在的任务"""
task = make_task()
mock_repo.get.return_value = task
uc = GetGenerationTaskUseCase(mock_repo)
result = uc.execute("task-1")
assert result is task
mock_repo.get.assert_called_once_with("task-1")
def test_get_not_found(self, mock_repo):
"""获取不存在的任务返回 None"""
mock_repo.get.return_value = None
uc = GetGenerationTaskUseCase(mock_repo)
result = uc.execute("nonexistent")
assert result is None
# ============================================================
# ListUserTasksFilteredUseCase
# ============================================================
class TestListUserTasksFilteredUseCase:
"""ListUserTasksFilteredUseCase 按用户筛选任务"""
def test_list_without_filters(self, mock_repo):
"""无筛选条件查询"""
tasks = [make_task(), make_task()]
mock_repo.list_by_user_filtered.return_value = tasks
mock_repo.count_by_user_filtered.return_value = 2
uc = ListUserTasksFilteredUseCase(mock_repo)
result = uc.execute("user-1")
assert isinstance(result, ListGenerationTasksResult)
assert len(result.items) == 2
assert result.total == 2
mock_repo.list_by_user_filtered.assert_called_once_with("user-1", status=None, limit=None, offset=0)
mock_repo.count_by_user_filtered.assert_called_once_with("user-1", status=None)
def test_list_with_status_filter(self, mock_repo):
"""按状态筛选"""
mock_repo.list_by_user_filtered.return_value = []
mock_repo.count_by_user_filtered.return_value = 0
uc = ListUserTasksFilteredUseCase(mock_repo)
uc.execute("user-1", status="running")
mock_repo.list_by_user_filtered.assert_called_once_with("user-1", status="running", limit=None, offset=0)
mock_repo.count_by_user_filtered.assert_called_once_with("user-1", status="running")
def test_list_with_pagination(self, mock_repo):
"""分页查询"""
mock_repo.list_by_user_filtered.return_value = []
mock_repo.count_by_user_filtered.return_value = 100
uc = ListUserTasksFilteredUseCase(mock_repo)
result = uc.execute("user-1", limit=10, offset=20)
assert result.total == 100
mock_repo.list_by_user_filtered.assert_called_once_with("user-1", status=None, limit=10, offset=20)
def test_list_empty_result(self, mock_repo):
"""空结果"""
mock_repo.list_by_user_filtered.return_value = []
mock_repo.count_by_user_filtered.return_value = 0
uc = ListUserTasksFilteredUseCase(mock_repo)
result = uc.execute("user-1", status="failed")
assert result.items == []
assert result.total == 0
# ============================================================
# RetryGenerationTaskUseCase
# ============================================================
class TestRetryGenerationTaskUseCase:
"""RetryGenerationTaskUseCase 重试失败任务"""
def test_retry_success(self, mock_repo):
"""失败任务重试成功"""
task = make_task(
status=GenerationTaskStatus.FAILED,
error_message="网络超时",
retry_count=0,
)
mock_repo.get.return_value = task
mock_repo.update.side_effect = lambda t: t
uc = RetryGenerationTaskUseCase(mock_repo)
result = uc.execute("task-1")
assert result.status == GenerationTaskStatus.PENDING
assert result.retry_count == 1
assert result.error_message == ""
assert result.error_info == {}
assert result.progress == 0.0
assert result.result_count == 0
assert result.started_at is None
assert result.completed_at is None
mock_repo.update.assert_called_once()
def test_retry_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
uc = RetryGenerationTaskUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute("nonexistent")
def test_retry_not_failed(self, mock_repo):
"""非失败状态不能重试"""
task = make_task(status=GenerationTaskStatus.RUNNING)
mock_repo.get.return_value = task
uc = RetryGenerationTaskUseCase(mock_repo)
with pytest.raises(ValueError, match="只有失败状态"):
uc.execute("task-1")
def test_retry_pending_not_allowed(self, mock_repo):
"""pending 状态不能重试"""
task = make_task(status=GenerationTaskStatus.PENDING)
mock_repo.get.return_value = task
uc = RetryGenerationTaskUseCase(mock_repo)
with pytest.raises(ValueError, match="只有失败状态"):
uc.execute("task-1")
def test_retry_preserves_id(self, mock_repo):
"""重试复用同一个 task_id"""
task = make_task(status=GenerationTaskStatus.FAILED)
original_id = task.id
mock_repo.get.return_value = task
mock_repo.update.side_effect = lambda t: t
uc = RetryGenerationTaskUseCase(mock_repo)
result = uc.execute("task-1")
assert result.id == original_id
# ============================================================
# Command / Filter / Result 对象
# ============================================================
class TestCommandAndDataObjects:
"""命令对象和数据对象"""
def test_create_command_defaults(self):
cmd = CreateGenerationTaskCommand()
assert cmd.project_id == ""
assert cmd.asset_library_id == ""
assert cmd.asset_ids == []
assert cmd.title_ids == []
assert cmd.voice_ids == []
assert cmd.auto_retry_enabled is False
assert cmd.auto_retry_max == 0
def test_list_filter_defaults(self):
f = ListTasksFilter()
assert f.status is None
def test_list_result(self):
task = make_task()
r = ListGenerationTasksResult(items=[task], total=1)
assert len(r.items) == 1
assert r.total == 1
+576
View File
@@ -0,0 +1,576 @@
"""
Job 应用层用例单元测试(第十八波)
覆盖:
- CreateJobUseCase
- SubmitJobUseCase
- UpdateJobProgressUseCase
- CompleteJobUseCase
- FailJobUseCase
- RetryJobUseCase
- CancelJobUseCase
- GetJobUseCase
- ListJobsUseCase
- GetJobStatisticsUseCase
"""
from unittest.mock import MagicMock
import pytest
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
CompleteJobUseCase,
CreateJobCommand,
CreateJobUseCase,
FailJobCommand,
FailJobUseCase,
GetJobStatisticsUseCase,
GetJobUseCase,
ListJobsUseCase,
RetryJobUseCase,
SubmitJobUseCase,
UpdateJobProgressCommand,
UpdateJobProgressUseCase,
)
from packages.domain.job import Job, JobStatus, JobType
@pytest.fixture
def mock_repo():
return MagicMock()
def make_job(
status=JobStatus.PENDING,
job_type=JobType.VIDEO_COMPOSE,
project_id="proj-1",
**kwargs,
):
job = Job.create(
project_id=project_id,
job_type=job_type,
**kwargs,
)
# 绕过状态机直接设置状态(测试构造用)
if status != JobStatus.PENDING:
object.__setattr__(job, "status", status)
return job
# ============================================================
# CreateJobUseCase
# ============================================================
class TestCreateJobUseCase:
"""CreateJobUseCase 创建任务"""
def test_create_success(self, mock_repo):
"""正常创建任务"""
mock_repo.create.side_effect = lambda j: j
cmd = CreateJobCommand(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload={"key": "val"},
source_id="src-1",
created_by_user_id="user-1",
max_retries=5,
)
uc = CreateJobUseCase(mock_repo)
job = uc.execute(cmd)
assert job.project_id == "proj-1"
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.payload == {"key": "val"}
assert job.source_id == "src-1"
assert job.created_by_user_id == "user-1"
assert job.max_retries == 5
assert job.status == JobStatus.PENDING
assert job.progress == 0.0
mock_repo.create.assert_called_once()
def test_create_default_values(self, mock_repo):
"""默认参数"""
mock_repo.create.side_effect = lambda j: j
cmd = CreateJobCommand(project_id="proj-1", job_type="video_compose")
uc = CreateJobUseCase(mock_repo)
job = uc.execute(cmd)
assert job.payload == {}
assert job.source_id == ""
assert job.created_by_user_id == ""
assert job.max_retries == 3
def test_create_string_job_type(self, mock_repo):
"""字符串类型的 job_type 也支持"""
mock_repo.create.side_effect = lambda j: j
cmd = CreateJobCommand(project_id="proj-1", job_type="asset_ingest")
uc = CreateJobUseCase(mock_repo)
job = uc.execute(cmd)
assert job.job_type == JobType.ASSET_INGEST
# ============================================================
# SubmitJobUseCase
# ============================================================
class TestSubmitJobUseCase:
"""SubmitJobUseCase 提交任务"""
def test_submit_success(self, mock_repo):
"""正常提交 pending 任务"""
job = make_job(status=JobStatus.PENDING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
uc = SubmitJobUseCase(mock_repo)
result = uc.execute(job.id, celery_task_id="celery-123")
assert result.status == JobStatus.RUNNING
assert result.celery_task_id == "celery-123"
assert result.current_stage == "已提交,等待执行"
assert result.started_at is not None
mock_repo.update.assert_called_once()
def test_submit_job_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
uc = SubmitJobUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute("nonexistent")
def test_submit_already_running(self, mock_repo):
"""已经是 running 状态不能再提交"""
job = make_job(status=JobStatus.RUNNING)
mock_repo.get.return_value = job
uc = SubmitJobUseCase(mock_repo)
with pytest.raises(ValueError, match="只有 pending 状态"):
uc.execute(job.id)
def test_submit_without_celery_id(self, mock_repo):
"""不传 celery_task_id 也可以"""
job = make_job(status=JobStatus.PENDING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
uc = SubmitJobUseCase(mock_repo)
result = uc.execute(job.id)
assert result.status == JobStatus.RUNNING
assert result.celery_task_id == ""
# ============================================================
# UpdateJobProgressUseCase
# ============================================================
class TestUpdateJobProgressUseCase:
"""UpdateJobProgressUseCase 更新进度"""
def test_update_progress_success(self, mock_repo):
"""正常更新进度"""
job = make_job(status=JobStatus.RUNNING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
cmd = UpdateJobProgressCommand(job_id=job.id, progress=50.0, current_stage="处理中")
uc = UpdateJobProgressUseCase(mock_repo)
result = uc.execute(cmd)
assert result.progress == 50.0
assert result.current_stage == "处理中"
mock_repo.update.assert_called_once()
def test_update_progress_job_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
cmd = UpdateJobProgressCommand(job_id="nope", progress=50.0)
uc = UpdateJobProgressUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute(cmd)
def test_update_progress_not_running(self, mock_repo):
"""非 running 状态不能更新进度"""
job = make_job(status=JobStatus.PENDING)
mock_repo.get.return_value = job
cmd = UpdateJobProgressCommand(job_id=job.id, progress=50.0)
uc = UpdateJobProgressUseCase(mock_repo)
with pytest.raises(ValueError, match="只有 running 状态"):
uc.execute(cmd)
# ============================================================
# CompleteJobUseCase
# ============================================================
class TestCompleteJobUseCase:
"""CompleteJobUseCase 完成任务"""
def test_complete_from_running(self, mock_repo):
"""从 running 状态完成"""
job = make_job(status=JobStatus.RUNNING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
cmd = CompleteJobCommand(job_id=job.id, result={"output": "ok"})
uc = CompleteJobUseCase(mock_repo)
result = uc.execute(cmd)
assert result.status == JobStatus.SUCCESS
assert result.progress == 100.0
assert result.result == {"output": "ok"}
assert result.completed_at is not None
mock_repo.update.assert_called_once()
def test_complete_from_pending(self, mock_repo):
"""从 pending 状态也可以直接完成"""
job = make_job(status=JobStatus.PENDING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
cmd = CompleteJobCommand(job_id=job.id)
uc = CompleteJobUseCase(mock_repo)
result = uc.execute(cmd)
assert result.status == JobStatus.SUCCESS
assert result.progress == 100.0
def test_complete_job_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
cmd = CompleteJobCommand(job_id="nope")
uc = CompleteJobUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute(cmd)
def test_complete_already_failed(self, mock_repo):
"""已失败的任务不能直接标记完成"""
job = make_job(status=JobStatus.FAILED)
job.error_message = "some error"
mock_repo.get.return_value = job
cmd = CompleteJobCommand(job_id=job.id)
uc = CompleteJobUseCase(mock_repo)
with pytest.raises(ValueError, match="只有 running/pending"):
uc.execute(cmd)
# ============================================================
# FailJobUseCase
# ============================================================
class TestFailJobUseCase:
"""FailJobUseCase 失败任务"""
def test_fail_from_running(self, mock_repo):
"""从 running 状态失败"""
job = make_job(status=JobStatus.RUNNING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
cmd = FailJobCommand(job_id=job.id, error_message="网络超时")
uc = FailJobUseCase(mock_repo)
result = uc.execute(cmd)
assert result.status == JobStatus.FAILED
assert result.error_message == "网络超时"
assert result.current_stage == "失败"
assert result.completed_at is not None
mock_repo.update.assert_called_once()
def test_fail_pending_rejected_by_domain(self, mock_repo):
"""pending 状态不能直接失败(领域状态机约束)"""
job = make_job(status=JobStatus.PENDING)
mock_repo.get.return_value = job
cmd = FailJobCommand(job_id=job.id, error_message="资源不足")
uc = FailJobUseCase(mock_repo)
with pytest.raises(ValueError, match="非法状态转换"):
uc.execute(cmd)
def test_fail_job_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
cmd = FailJobCommand(job_id="nope", error_message="err")
uc = FailJobUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute(cmd)
# ============================================================
# RetryJobUseCase
# ============================================================
class TestRetryJobUseCase:
"""RetryJobUseCase 重试任务"""
def test_retry_success(self, mock_repo):
"""失败任务重试成功"""
job = make_job(status=JobStatus.FAILED, max_retries=3)
job.retry_count = 0
job.error_message = "timeout"
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
uc = RetryJobUseCase(mock_repo)
result = uc.execute(job.id)
assert result.status == JobStatus.PENDING
assert result.retry_count == 1
assert result.progress == 0.0
assert result.error_message == ""
assert result.started_at is None
assert result.completed_at is None
assert result.celery_task_id == ""
mock_repo.update.assert_called_once()
def test_retry_job_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
uc = RetryJobUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute("nope")
def test_retry_exceeds_max_retries(self, mock_repo):
"""超过最大重试次数不可重试"""
job = make_job(status=JobStatus.FAILED, max_retries=3)
job.retry_count = 3
mock_repo.get.return_value = job
uc = RetryJobUseCase(mock_repo)
with pytest.raises(ValueError, match="不可重试"):
uc.execute(job.id)
# ============================================================
# CancelJobUseCase
# ============================================================
class TestCancelJobUseCase:
"""CancelJobUseCase 取消任务"""
def test_cancel_pending(self, mock_repo):
"""取消 pending 任务"""
job = make_job(status=JobStatus.PENDING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
uc = CancelJobUseCase(mock_repo)
result = uc.execute(job.id)
assert result.status == JobStatus.CANCELLED
assert result.current_stage == "已取消"
mock_repo.update.assert_called_once()
def test_cancel_running(self, mock_repo):
"""取消 running 任务"""
job = make_job(status=JobStatus.RUNNING)
mock_repo.get.return_value = job
mock_repo.update.side_effect = lambda j: j
uc = CancelJobUseCase(mock_repo)
result = uc.execute(job.id)
assert result.status == JobStatus.CANCELLED
def test_cancel_job_not_found(self, mock_repo):
"""任务不存在"""
mock_repo.get.return_value = None
uc = CancelJobUseCase(mock_repo)
with pytest.raises(ValueError, match="任务不存在"):
uc.execute("nope")
def test_cancel_already_success(self, mock_repo):
"""已成功的任务不能取消"""
job = make_job(status=JobStatus.SUCCESS)
mock_repo.get.return_value = job
uc = CancelJobUseCase(mock_repo)
with pytest.raises(ValueError, match="终态"):
uc.execute(job.id)
def test_cancel_already_failed(self, mock_repo):
"""已失败的任务不能取消(走重试)"""
job = make_job(status=JobStatus.FAILED)
job.error_message = "err"
mock_repo.get.return_value = job
uc = CancelJobUseCase(mock_repo)
with pytest.raises(ValueError, match="终态"):
uc.execute(job.id)
# ============================================================
# GetJobUseCase
# ============================================================
class TestGetJobUseCase:
"""GetJobUseCase 获取任务"""
def test_get_existing(self, mock_repo):
"""获取存在的任务"""
job = make_job()
mock_repo.get.return_value = job
uc = GetJobUseCase(mock_repo)
result = uc.execute(job.id)
assert result is job
mock_repo.get.assert_called_once_with(job.id)
def test_get_not_found(self, mock_repo):
"""获取不存在的任务返回 None"""
mock_repo.get.return_value = None
uc = GetJobUseCase(mock_repo)
result = uc.execute("nope")
assert result is None
# ============================================================
# ListJobsUseCase
# ============================================================
class TestListJobsUseCase:
"""ListJobsUseCase 列出任务"""
def test_list_by_project(self, mock_repo):
"""按项目列出"""
jobs = [make_job(), make_job()]
mock_repo.list_by_project.return_value = jobs
uc = ListJobsUseCase(mock_repo)
result = uc.execute(project_id="proj-1")
assert len(result) == 2
mock_repo.list_by_project.assert_called_once()
def test_list_by_project_with_filters(self, mock_repo):
"""按项目 + 类型 + 状态过滤"""
mock_repo.list_by_project.return_value = []
uc = ListJobsUseCase(mock_repo)
uc.execute(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
status=JobStatus.RUNNING,
limit=20,
offset=10,
)
mock_repo.list_by_project.assert_called_once_with(
"proj-1",
job_type=JobType.VIDEO_COMPOSE,
status=JobStatus.RUNNING,
limit=20,
offset=10,
)
def test_list_by_user(self, mock_repo):
"""按用户列出"""
jobs = [make_job()]
mock_repo.list_by_user.return_value = jobs
uc = ListJobsUseCase(mock_repo)
result = uc.execute(user_id="user-1")
assert len(result) == 1
mock_repo.list_by_user.assert_called_once()
def test_list_no_filter_raises(self, mock_repo):
"""不指定 project_id 或 user_id 报错"""
uc = ListJobsUseCase(mock_repo)
with pytest.raises(ValueError, match="必须指定"):
uc.execute()
def test_list_project_takes_precedence(self, mock_repo):
"""同时传 project_id 和 user_id,优先按项目查"""
mock_repo.list_by_project.return_value = []
uc = ListJobsUseCase(mock_repo)
uc.execute(project_id="proj-1", user_id="user-1")
mock_repo.list_by_project.assert_called_once()
mock_repo.list_by_user.assert_not_called()
# ============================================================
# GetJobStatisticsUseCase
# ============================================================
class TestGetJobStatisticsUseCase:
"""GetJobStatisticsUseCase 任务统计"""
def test_stats_counts(self, mock_repo):
"""统计各状态数量"""
mock_repo.count_by_project.side_effect = lambda pid, status=None: {
None: 10, # total
JobStatus.PENDING: 2,
JobStatus.RUNNING: 3,
JobStatus.SUCCESS: 4,
JobStatus.FAILED: 1,
}[status]
uc = GetJobStatisticsUseCase(mock_repo)
stats = uc.execute("proj-1")
assert stats["project_id"] == "proj-1"
assert stats["total"] == 10
assert stats["pending"] == 2
assert stats["running"] == 3
assert stats["success"] == 4
assert stats["failed"] == 1
# 总共调用 5 次 count_by_project
assert mock_repo.count_by_project.call_count == 5
# ============================================================
# Command 对象
# ============================================================
class TestCommandObjects:
"""命令对象基本属性"""
def test_create_job_command_defaults(self):
cmd = CreateJobCommand(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
assert cmd.payload == {}
assert cmd.source_id == ""
assert cmd.created_by_user_id == ""
assert cmd.max_retries == 3
def test_update_progress_command_defaults(self):
cmd = UpdateJobProgressCommand(job_id="j1", progress=50.0)
assert cmd.current_stage == ""
def test_complete_job_command_defaults(self):
cmd = CompleteJobCommand(job_id="j1")
assert cmd.result == {}
def test_fail_job_command(self):
cmd = FailJobCommand(job_id="j1", error_message="err")
assert cmd.error_message == "err"
+50
View File
@@ -309,6 +309,8 @@ class TestTemplateClipEffectMapping:
asset_id: str = f"asset_{idx}"
duration: float = 5.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
config: dict = field(default_factory=dict)
return FakeClip(config=config or {})
@@ -396,6 +398,54 @@ class TestTemplateClipEffectMapping:
# 模板是 cut 时,保留原有值(避免无意义覆盖)
assert clips[0].transition_effect == "fade"
def test_transition_duration_mapped(self):
"""转场时长(transition_duration)从模板 config 正确映射到 clip."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(3)]
clip_configs = [
self._make_template_clip_config("main", transition="fade", config={"transition_duration": 0.8}),
self._make_template_clip_config("main", transition="dissolve", config={"transition_duration": 1.2}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 前两个按顺序映射,第三个复用最后一个
assert clips[0].transition_effect == "fade"
assert clips[0].transition_duration == 0.8
assert clips[1].transition_effect == "dissolve"
assert clips[1].transition_duration == 1.2
assert clips[2].transition_effect == "dissolve"
assert clips[2].transition_duration == 1.2
def test_transition_duration_ignored_for_cut(self):
"""模板转场为 cut 时,transition_duration 不生效(保持默认0."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clip_configs = [
self._make_template_clip_config("main", transition="cut", config={"transition_duration": 0.5}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# cut 转场不映射,transition_duration 也不应用
assert clips[0].transition_duration == 0.0
def test_transition_duration_invalid_value_skipped(self):
"""transition_duration 为无效值时安全跳过."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clip_configs = [
self._make_template_clip_config("main", transition="fade", config={"transition_duration": "abc"}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
assert clips[0].transition_effect == "fade"
assert clips[0].transition_duration == 0.0 # 无效值保持默认
def test_intro_outro_extracted(self):
"""intro/outro 类型 clip_config 正确提取为 plan 级 intro_outro 配置."""
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
+413
View File
@@ -0,0 +1,413 @@
"""SmartAssetSelector 智能素材选择服务单元测试."""
from __future__ import annotations
import unittest
from dataclasses import dataclass
from app.services.smart_asset_selector import (
SmartAssetSelector,
_MEDIUM_BUCKET_MAX,
_SHORT_BUCKET_MAX,
)
@dataclass
class MockAsset:
"""模拟 Asset 实体."""
id: str
quality_score: float | None = None
width: int | None = None
height: int | None = None
duration: float | None = None
file_size: int = 0
mime_type: str = "video/mp4"
status: str = "ready"
@property
def status_value(self) -> str:
return self.status
class TestSmartAssetSelectorScoring(unittest.TestCase):
"""评分维度测试."""
def setUp(self):
self.selector = SmartAssetSelector()
def test_quality_score_normalization(self):
"""质量分正确归一化到 0-1."""
asset_high = MockAsset(id="1", quality_score=90.0)
asset_low = MockAsset(id="2", quality_score=30.0)
asset_none = MockAsset(id="3", quality_score=None)
detail_high = self.selector._score_asset(asset_high)
detail_low = self.selector._score_asset(asset_low)
detail_none = self.selector._score_asset(asset_none)
# 90分 → 0.9 × 0.5权重 = 0.45 基础贡献
self.assertAlmostEqual(detail_high.quality_score, 0.9, delta=0.01)
# 30分 → 0.3 × 0.5权重 = 0.15 基础贡献
self.assertAlmostEqual(detail_low.quality_score, 0.3, delta=0.01)
# 无质量分给默认 0.5
self.assertAlmostEqual(detail_none.quality_score, 0.5, delta=0.01)
def test_resolution_score_1080p_full(self):
"""1080p 分辨率得满分."""
asset = MockAsset(id="1", width=1920, height=1080)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.resolution_score, 1.0, delta=0.01)
def test_resolution_score_4k_full(self):
"""4K 也得满分(高于目标分辨率不扣分)."""
asset = MockAsset(id="1", width=3840, height=2160)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.resolution_score, 1.0, delta=0.01)
def test_resolution_score_720p_lower(self):
"""720p 低于 1080p,得分低于 1."""
asset = MockAsset(id="1", width=1280, height=720)
detail = self.selector._score_asset(asset)
self.assertLess(detail.resolution_score, 1.0)
self.assertGreater(detail.resolution_score, 0.3)
def test_resolution_score_none(self):
"""分辨率未知给中评分."""
asset = MockAsset(id="1", width=None, height=None)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.resolution_score, 0.5, delta=0.01)
def test_duration_score_optimal(self):
"""最佳时长区间内得满分."""
asset = MockAsset(id="1", duration=10.0)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.duration_score, 1.0, delta=0.01)
def test_duration_score_too_short(self):
"""时长过短扣分."""
asset = MockAsset(id="1", duration=1.0)
detail = self.selector._score_asset(asset)
self.assertLess(detail.duration_score, 1.0)
def test_duration_score_too_long(self):
"""时长过长扣分."""
asset = MockAsset(id="1", duration=120.0)
detail = self.selector._score_asset(asset)
self.assertLess(detail.duration_score, 1.0)
def test_duration_score_none(self):
"""时长未知给中评分."""
asset = MockAsset(id="1", duration=None)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.duration_score, 0.5, delta=0.01)
def test_total_score_weighted_sum(self):
"""总分是各维度的加权和."""
asset = MockAsset(
id="1",
quality_score=100.0, # 1.0 × 0.5 = 0.5
width=1920, # 1.0 × 0.2 = 0.2
height=1080,
duration=10.0, # 1.0 × 0.2 = 0.2
file_size=10_000_000, # ~8Mbps10秒 → 约 1.0 × 0.1 = 0.1
)
detail = self.selector._score_asset(asset)
# 理论上接近 1.0
self.assertGreater(detail.total_score, 0.85)
self.assertLessEqual(detail.total_score, 1.0)
class TestSmartAssetSelectorSelection(unittest.TestCase):
"""选择逻辑测试."""
def setUp(self):
self.selector = SmartAssetSelector(min_quality_score=0) # 测试时关闭质量门槛
def _make_assets(self, count: int, base_quality: float = 80.0) -> list[MockAsset]:
assets = []
for i in range(count):
assets.append(
MockAsset(
id=f"asset_{i}",
quality_score=base_quality - i * 5, # 质量递减
width=1920,
height=1080,
duration=10.0 + i,
file_size=5_000_000 + i * 100_000,
)
)
return assets
def test_select_all_when_count_zero(self):
"""count=0 时返回全部符合条件的."""
assets = self._make_assets(10)
result = self.selector.select(assets, count=0)
self.assertEqual(len(result.selected_ids), 10)
self.assertEqual(result.total_candidates, 10)
def test_select_top_n(self):
"""返回指定数量的 top N."""
assets = self._make_assets(10)
result = self.selector.select(assets, count=3)
self.assertEqual(len(result.selected_ids), 3)
# 最高分的应该是 asset_0(质量分最高)
self.assertEqual(result.selected_ids[0], "asset_0")
def test_select_more_than_available(self):
"""请求数量超过候选数量时返回全部."""
assets = self._make_assets(5)
result = self.selector.select(assets, count=10)
self.assertEqual(len(result.selected_ids), 5)
def test_filter_non_ready(self):
"""非 ready 状态的素材被过滤."""
assets = [
MockAsset(id="1", quality_score=90.0, status="ready"),
MockAsset(id="2", quality_score=80.0, status="processing"),
MockAsset(id="3", quality_score=70.0, status="ready"),
]
result = self.selector.select(assets, count=0)
self.assertEqual(len(result.selected_ids), 2)
self.assertIn("1", result.selected_ids)
self.assertIn("3", result.selected_ids)
self.assertNotIn("2", result.selected_ids)
def test_filter_non_video(self):
"""非视频素材被过滤."""
assets = [
MockAsset(id="1", quality_score=90.0, mime_type="video/mp4"),
MockAsset(id="2", quality_score=80.0, mime_type="image/jpeg"),
MockAsset(id="3", quality_score=70.0, mime_type="video/quicktime"),
]
result = self.selector.select(assets, count=0)
self.assertEqual(len(result.selected_ids), 2)
def test_min_quality_filter(self):
"""最低质量分门槛过滤."""
selector = SmartAssetSelector(min_quality_score=60.0)
assets = [
MockAsset(id="1", quality_score=90.0),
MockAsset(id="2", quality_score=50.0), # 低于门槛
MockAsset(id="3", quality_score=70.0),
MockAsset(id="4", quality_score=30.0), # 低于门槛
]
result = selector.select(assets, count=0)
self.assertEqual(len(result.selected_ids), 2)
self.assertEqual(result.filtered_out, 2)
self.assertIn("1", result.selected_ids)
self.assertIn("3", result.selected_ids)
def test_empty_input(self):
"""空输入返回空结果."""
result = self.selector.select([], count=5)
self.assertEqual(result.selected_ids, [])
self.assertEqual(result.total_candidates, 0)
self.assertEqual(result.avg_score, 0.0)
def test_sorted_by_score_descending(self):
"""结果按总分降序排列."""
assets = self._make_assets(5)
result = self.selector.select(assets, count=0, ensure_diversity=False)
scores = [d.total_score for d in result.details]
# 应该是降序
self.assertEqual(scores, sorted(scores, reverse=True))
class TestSmartAssetSelectorDiversity(unittest.TestCase):
"""多样性选择测试."""
def setUp(self):
self.selector = SmartAssetSelector(min_quality_score=0)
def _make_assets(self, count: int, base_quality: float = 80.0) -> list[MockAsset]:
assets = []
for i in range(count):
assets.append(
MockAsset(
id=f"asset_{i}",
quality_score=base_quality - i * 5,
width=1920,
height=1080,
duration=10.0 + i,
file_size=5_000_000 + i * 100_000,
)
)
return assets
def test_diversity_all_short(self):
"""全是短素材时不报错,正常返回."""
assets = []
for i in range(10):
assets.append(
MockAsset(
id=f"short_{i}",
quality_score=80.0 + i,
width=1920,
height=1080,
duration=2.0 + i * 0.1, # 都 < 5s
file_size=1_000_000,
)
)
result = self.selector.select(assets, count=5, ensure_diversity=True)
self.assertEqual(len(result.selected_ids), 5)
def test_diversity_mixed_buckets(self):
"""混合时长素材时,各桶都有代表."""
assets = []
# 短素材(质量分高)
for i in range(5):
assets.append(
MockAsset(
id=f"short_{i}",
quality_score=95.0 - i,
width=1920,
height=1080,
duration=3.0,
file_size=2_000_000,
)
)
# 中素材(质量分中等)
for i in range(5):
assets.append(
MockAsset(
id=f"medium_{i}",
quality_score=85.0 - i,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
)
# 长素材(质量分低)
for i in range(5):
assets.append(
MockAsset(
id=f"long_{i}",
quality_score=75.0 - i,
width=1920,
height=1080,
duration=60.0,
file_size=20_000_000,
)
)
result = self.selector.select(assets, count=6, ensure_diversity=True)
selected = result.selected_ids
# 6个素材,每个桶至少有1个(基础配额 max(1, 6//3)=2
short_count = sum(1 for sid in selected if sid.startswith("short_"))
medium_count = sum(1 for sid in selected if sid.startswith("medium_"))
long_count = sum(1 for sid in selected if sid.startswith("long_"))
# 每个桶至少1个
self.assertGreaterEqual(short_count, 1)
self.assertGreaterEqual(medium_count, 1)
self.assertGreaterEqual(long_count, 1)
self.assertEqual(len(selected), 6)
def test_diversity_disabled_returns_top(self):
"""关闭多样性时,直接返回 top N(可能全是短素材)."""
assets = []
# 短素材(质量分最高)
for i in range(10):
assets.append(
MockAsset(
id=f"short_{i}",
quality_score=95.0 - i,
width=1920,
height=1080,
duration=3.0,
file_size=2_000_000,
)
)
# 长素材(质量分低)
for i in range(5):
assets.append(
MockAsset(
id=f"long_{i}",
quality_score=70.0,
width=1920,
height=1080,
duration=60.0,
file_size=20_000_000,
)
)
result = self.selector.select(assets, count=5, ensure_diversity=False)
selected = result.selected_ids
# 全是短素材(因为质量分高)
self.assertTrue(all(s.startswith("short_") for s in selected))
def test_avg_score_calculated(self):
"""平均分正确计算."""
assets = self._make_assets(3)
result = self.selector.select(assets, count=3, ensure_diversity=False)
expected_avg = sum(d.total_score for d in result.details) / 3
self.assertAlmostEqual(result.avg_score, expected_avg, delta=0.001)
class TestSmartAssetSelectorEdgeCases(unittest.TestCase):
"""边界情况测试."""
def setUp(self):
self.selector = SmartAssetSelector(min_quality_score=0)
def _make_assets(self, count: int, base_quality: float = 80.0) -> list[MockAsset]:
assets = []
for i in range(count):
assets.append(
MockAsset(
id=f"asset_{i}",
quality_score=base_quality - i * 5,
width=1920,
height=1080,
duration=10.0 + i,
file_size=5_000_000 + i * 100_000,
)
)
return assets
def test_single_asset(self):
"""单个素材正常返回."""
assets = [MockAsset(id="1", quality_score=80.0, width=1920, height=1080, duration=10.0)]
result = self.selector.select(assets, count=1)
self.assertEqual(len(result.selected_ids), 1)
self.assertEqual(result.selected_ids[0], "1")
def test_zero_width_height(self):
"""宽高为0时按未知处理."""
asset = MockAsset(id="1", width=0, height=0)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.resolution_score, 0.5, delta=0.01)
def test_negative_duration(self):
"""负时长按未知处理."""
asset = MockAsset(id="1", duration=-5.0)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.duration_score, 0.5, delta=0.01)
def test_zero_file_size_with_duration(self):
"""文件大小为0时码率评分中等."""
asset = MockAsset(id="1", file_size=0, duration=10.0)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.bitrate_score, 0.5, delta=0.01)
def test_bitrate_score_optimal(self):
"""最佳码率范围得满分."""
# 5 Mbps × 10秒 = 6.25 MB → file_size = 6,250,000 bytes
asset = MockAsset(id="1", file_size=6_250_000, duration=10.0)
detail = self.selector._score_asset(asset)
self.assertAlmostEqual(detail.bitrate_score, 1.0, delta=0.01)
def test_details_match_selected_ids(self):
"""details 列表和 selected_ids 顺序一致."""
assets = self._make_assets(5)
result = self.selector.select(assets, count=3, ensure_diversity=False)
self.assertEqual(len(result.details), 3)
for i, aid in enumerate(result.selected_ids):
self.assertEqual(result.details[i].asset_id, aid)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
"""
TTS 相关单元测试(第二十二波)
覆盖:
- AudioMerger (空列表/单文件/多文件合并/格式/异常)
"""
import os
import subprocess
import tempfile
import pytest
from packages.application.tts_job.audio_merger import (
AudioMergeError,
AudioMerger,
)
def _make_silence(duration: float = 0.5, sample_rate: int = 22050, fmt: str = "mp3") -> str:
"""生成一段静音音频文件,返回路径。"""
tmp = tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False)
tmp.close()
cmd = [
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"anullsrc=r={sample_rate}:cl=mono",
"-t",
str(duration),
"-q:a",
"9",
tmp.name,
]
subprocess.run(cmd, capture_output=True, check=True)
return tmp.name
class TestAudioMerger:
"""AudioMerger 音频合并器"""
def test_empty_list_raises(self):
"""空列表抛错"""
merger = AudioMerger()
with pytest.raises(AudioMergeError, match="没有可合并"):
merger.merge([])
def test_single_file_returns_content(self):
"""单个文件直接返回内容"""
path = _make_silence(duration=0.3)
try:
merger = AudioMerger()
result = merger.merge([path])
assert isinstance(result, bytes)
assert len(result) > 100 # 应该有有效数据
# 应该和文件本身一致
with open(path, "rb") as f:
original = f.read()
assert result == original
finally:
os.unlink(path)
def test_two_files_merged(self):
"""两个文件合并"""
p1 = _make_silence(duration=0.3)
p2 = _make_silence(duration=0.4)
try:
merger = AudioMerger()
result = merger.merge([p1, p2])
assert isinstance(result, bytes)
assert len(result) > 200 # 合并后应该有数据
# 写出来用 ffprobe 验证时长
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
tmp.write(result)
tmp.close()
try:
probe = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
tmp.name,
],
capture_output=True,
text=True,
check=True,
)
duration = float(probe.stdout.strip())
# 0.3 + 0.4 = 0.7 秒左右,允许一定误差
assert 0.5 < duration < 1.0
finally:
os.unlink(tmp.name)
finally:
os.unlink(p1)
os.unlink(p2)
def test_three_files_merged(self):
"""三个文件合并"""
paths = [_make_silence(duration=0.2) for _ in range(3)]
try:
merger = AudioMerger()
result = merger.merge(paths)
assert isinstance(result, bytes)
assert len(result) > 200
finally:
for p in paths:
os.unlink(p)
def test_wav_format(self):
"""wav 格式合并"""
p1 = _make_silence(duration=0.2, fmt="wav")
p2 = _make_silence(duration=0.2, fmt="wav")
try:
merger = AudioMerger()
result = merger.merge([p1, p2], output_format="wav")
assert isinstance(result, bytes)
# WAV 头部以 RIFF 开头
assert result[:4] == b"RIFF"
finally:
os.unlink(p1)
os.unlink(p2)
def test_nonexistent_file_raises(self):
"""不存在的文件会抛错"""
merger = AudioMerger()
with pytest.raises(AudioMergeError):
merger.merge(["/nonexistent/path/a.mp3", "/nonexistent/path/b.mp3"])
def test_cleanup_temp_dir(self):
"""临时目录会被清理"""
p1 = _make_silence(duration=0.2)
p2 = _make_silence(duration=0.2)
try:
import tempfile as _tf
before = set(os.listdir(_tf.gettempdir()))
merger = AudioMerger()
merger.merge([p1, p2])
after = set(os.listdir(_tf.gettempdir()))
# 不应该残留 tts_merge_ 前缀的目录
new_items = after - before
tts_items = [i for i in new_items if i.startswith("tts_merge_")]
assert len(tts_items) == 0, f"残留临时目录: {tts_items}"
finally:
os.unlink(p1)
os.unlink(p2)
# ============================================================
# TTSStreamingService - 入口路由与边界
# ============================================================
class TestTTSStreamingServiceRouting:
"""TTSStreamingService 入口路由与边界条件"""
@pytest.mark.asyncio
async def test_empty_text_returns_error(self):
"""空文本返回错误"""
from unittest.mock import AsyncMock, MagicMock
from packages.application.tts_job.streaming_service import TTSStreamingService
mock_cosy = MagicMock()
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
await svc.synthesize_and_stream(ws, {"text": ""})
ws.send_json.assert_called_once()
call_args = ws.send_json.call_args[0][0]
assert call_args["type"] == "error"
assert "不能为空" in call_args["message"]
# 不应该调用 cosyvoice
mock_cosy.submit_synthesize_task.assert_not_called()
@pytest.mark.asyncio
async def test_text_too_long_returns_error(self):
"""文本过长返回错误"""
from unittest.mock import AsyncMock, MagicMock
from packages.application.tts_job.streaming_service import (
_MAX_TEXT_LENGTH,
TTSStreamingService,
)
mock_cosy = MagicMock()
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
long_text = "a" * (_MAX_TEXT_LENGTH + 1)
await svc.synthesize_and_stream(ws, {"text": long_text})
ws.send_json.assert_called_once()
call_args = ws.send_json.call_args[0][0]
assert call_args["type"] == "error"
assert "过长" in call_args["message"]
mock_cosy.submit_synthesize_task.assert_not_called()
@pytest.mark.asyncio
async def test_short_text_routes_to_short_path(self):
"""短文本走短文本路径(单段合成)"""
from unittest.mock import AsyncMock, MagicMock, patch
from packages.application.tts_job.streaming_service import TTSStreamingService
mock_cosy = MagicMock()
mock_cosy.submit_synthesize_task.return_value = {
"audio_url": "https://example.com/audio.mp3",
"duration": 3.5,
}
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
# mock 掉音频下载
fake_audio = b"fake_audio_data" * 100
with patch.object(svc, "_download_audio", return_value=fake_audio):
await svc.synthesize_and_stream(ws, {"text": "你好世界", "voice_id": "v1"})
# 应该调用了 cosy
mock_cosy.submit_synthesize_task.assert_called_once()
# 应该有 started 和 done 消息
msg_types = [c[0][0]["type"] for c in ws.send_json.call_args_list]
assert "started" in msg_types
assert "done" in msg_types
# 应该有音频分块发送
assert ws.send_bytes.call_count > 0
@pytest.mark.asyncio
async def test_short_text_cosy_error(self):
"""短文本合成失败返回错误"""
from unittest.mock import AsyncMock, MagicMock
from packages.application.cosyvoice_service import CosyVoiceError
from packages.application.tts_job.streaming_service import TTSStreamingService
mock_cosy = MagicMock()
mock_cosy.submit_synthesize_task.side_effect = CosyVoiceError("音色不存在")
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
await svc.synthesize_and_stream(ws, {"text": "你好", "voice_id": "v-bad"})
# 最后一条消息应该是 error
last_msg = ws.send_json.call_args_list[-1][0][0]
assert last_msg["type"] == "error"
assert "音色不存在" in last_msg["message"]
@pytest.mark.asyncio
async def test_short_text_no_audio_url(self):
"""合成结果没有 audio_url 返回错误"""
from unittest.mock import AsyncMock, MagicMock
from packages.application.tts_job.streaming_service import TTSStreamingService
mock_cosy = MagicMock()
mock_cosy.submit_synthesize_task.return_value = {"duration": 1.0} # 没有 audio_url
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
await svc.synthesize_and_stream(ws, {"text": "你好"})
last_msg = ws.send_json.call_args_list[-1][0][0]
assert last_msg["type"] == "error"
assert "音频 URL" in last_msg["message"]
@pytest.mark.asyncio
async def test_stream_audio_chunks_returns_total(self):
"""_stream_audio_chunks 返回正确字节数,分块正确"""
from unittest.mock import AsyncMock, MagicMock
from packages.application.tts_job.streaming_service import (
_AUDIO_CHUNK_SIZE,
TTSStreamingService,
)
mock_cosy = MagicMock()
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
# 生成 10000 字节的假音频
audio_data = b"x" * 10000
total = await svc._stream_audio_chunks(ws, audio_data)
assert total == 10000
# 应该分 ceil(10000/4096) = 3 块
expected_chunks = (10000 + _AUDIO_CHUNK_SIZE - 1) // _AUDIO_CHUNK_SIZE
assert ws.send_bytes.call_count == expected_chunks
# 验证所有块拼接起来等于原数据
all_bytes = b"".join(c[0][0] for c in ws.send_bytes.call_args_list)
assert all_bytes == audio_data
@pytest.mark.asyncio
async def test_send_json_handles_error(self):
"""_send_json 发送失败不抛出异常"""
from unittest.mock import AsyncMock, MagicMock
from packages.application.tts_job.streaming_service import TTSStreamingService
mock_cosy = MagicMock()
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
ws = AsyncMock()
ws.send_json.side_effect = Exception("连接已断开")
# 不应该抛异常
await svc._send_json(ws, {"type": "done"})
ws.send_json.assert_called_once()
+549
View File
@@ -0,0 +1,549 @@
"""TTS Workflow service unit tests.
Covers TTSWorkflowService - start_synthesis, poll_and_process_synthesis,
process_synthesis_result, process_synthesis_failure, segment synthesis.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError
from packages.application.tts_job.workflow import (
TTSJobNotFoundError,
TTSWorkflowError,
TTSWorkflowService,
)
from packages.domain.tts_job import TTSJob, TTSJobStatus
# ── Helpers ─────────────────────────────────────────────
def make_job(**kwargs):
defaults = dict(
id="job-123",
user_id="user-1",
input_text="Hello world",
voice_id="voice-1",
sample_rate=22050,
format="mp3",
)
defaults.update(kwargs)
return TTSJob(**defaults)
class FakeTTSJobRepository:
def __init__(self, job=None):
self._job = job
self.updated_jobs = []
self.get_called = 0
def get(self, job_id):
self.get_called += 1
if self._job and self._job.id == job_id:
return self._job
return None
def update(self, job):
self.updated_jobs.append(job)
self._job = job
return job
class FakeCosyVoiceService:
def __init__(self, submit_result=None, submit_error=None, poll_result=None, poll_error=None):
self._submit_result = submit_result or {
"audio_url": "https://temp.example.com/audio.mp3",
"task_id": "",
"request_id": "req-1",
"duration": 5.0,
"file_size": 1024,
}
self._submit_error = submit_error
self._poll_result = poll_result
self._poll_error = poll_error
self.submit_calls = []
self.poll_calls = []
def submit_synthesize_task(self, **kwargs):
self.submit_calls.append(kwargs)
if self._submit_error:
raise self._submit_error
return self._submit_result
def poll_synthesize_task(self, task_id, timeout=120.0):
self.poll_calls.append({"task_id": task_id, "timeout": timeout})
if self._poll_error:
raise self._poll_error
return self._poll_result or {
"audio_url": "https://temp.example.com/audio.mp3",
"duration": 5.0,
"file_size": 1024,
}
class FakeStorageService:
def __init__(self, upload_url="https://oss.example.com/tts-outputs/user-1/job-123.mp3", upload_error=None):
self._upload_url = upload_url
self._upload_error = upload_error
self.uploads = []
def upload_file(self, file_obj, storage_key, content_type=None):
self.uploads.append({"storage_key": storage_key, "content_type": content_type})
if self._upload_error:
raise self._upload_error
return self._upload_url
# ── start_synthesis tests ───────────────────────────────
class TestStartSynthesis:
def test_successful_sync_completion(self):
"""start_synthesis with sync audio_url → job marked completed."""
job = make_job()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
submit_result={
"audio_url": "https://temp.example.com/audio.mp3",
"request_id": "req-abc",
"task_id": "",
"duration": 3.5,
"file_size": 5000,
}
)
storage = FakeStorageService(upload_url="https://oss.example.com/final.mp3")
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"fake audio data"):
result = svc.start_synthesis("job-123")
assert result.id == "job-123"
assert result.status == TTSJobStatus.COMPLETED.value
assert result.output_audio_url == "https://oss.example.com/final.mp3"
assert result.output_audio_key == "tts-outputs/user-1/job-123.mp3"
assert result.duration == 3.5
assert result.file_size == 5000
assert result.metadata["cosyvoice_request_id"] == "req-abc"
# verify cosyvoice was called
assert len(cosy.submit_calls) == 1
assert cosy.submit_calls[0]["text"] == "Hello world"
assert cosy.submit_calls[0]["voice_id"] == "voice-1"
# verify storage upload
assert len(storage.uploads) == 1
assert storage.uploads[0]["storage_key"] == "tts-outputs/user-1/job-123.mp3"
def test_job_not_found_raises(self):
repo = FakeTTSJobRepository() # no job
cosy = FakeCosyVoiceService()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
with pytest.raises(TTSJobNotFoundError):
svc.start_synthesis("nonexistent")
def test_job_marked_processing_before_submit(self):
job = make_job()
assert job.status == TTSJobStatus.PENDING.value
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
submit_result={
"audio_url": "",
"task_id": "task-abc",
"request_id": "req-1",
}
)
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
svc.start_synthesis("job-123")
# first update should mark processing
first_update = repo.updated_jobs[0]
assert first_update.status == TTSJobStatus.PROCESSING.value
def test_cosyvoice_error_marks_failed(self):
job = make_job()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit"))
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.FAILED.value
assert "API rate limit" in result.error_message
def test_cosyvoice_auth_error_marks_failed(self):
job = make_job()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid key"))
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.FAILED.value
assert "Invalid key" in result.error_message
def test_value_error_marks_failed(self):
job = make_job()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(submit_error=ValueError("text is empty"))
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.FAILED.value
assert "text is empty" in result.error_message
def test_oss_transfer_failure_falls_back_to_temp_url(self):
job = make_job()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
submit_result={
"audio_url": "https://temp.example.com/original.mp3",
"request_id": "req-1",
"task_id": "",
"duration": 2.0,
"file_size": 1000,
}
)
storage = FakeStorageService(upload_error=RuntimeError("OSS down"))
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"fake"):
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.COMPLETED.value
# falls back to temp URL
assert result.output_audio_url == "https://temp.example.com/original.mp3"
assert result.output_audio_key == ""
def test_no_audio_url_task_id_saved(self):
"""Async path: no audio_url, save task_id to metadata."""
job = make_job()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
submit_result={
"audio_url": "",
"task_id": "async-task-123",
"request_id": "req-async",
}
)
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.PROCESSING.value
assert result.metadata["cosyvoice_task_id"] == "async-task-123"
assert result.metadata["cosyvoice_request_id"] == "req-async"
# ── poll_and_process_synthesis tests ────────────────────
class TestPollAndProcessSynthesis:
def test_already_completed_returns_immediately(self):
job = make_job()
job.mark_processing()
job.mark_completed(output_audio_url="https://oss.example.com/done.mp3", output_audio_key="key")
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
result = svc.poll_and_process_synthesis("job-123")
assert result.status == TTSJobStatus.COMPLETED.value
# no new submit calls
assert len(cosy.submit_calls) == 0
assert len(cosy.poll_calls) == 0
def test_job_not_found_raises(self):
repo = FakeTTSJobRepository()
cosy = FakeCosyVoiceService()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
with pytest.raises(TTSJobNotFoundError):
svc.poll_and_process_synthesis("nonexistent")
def test_no_task_id_resynthesizes(self):
"""No task_id in metadata → re-sync synthesize."""
job = make_job()
job.mark_processing()
job.metadata = {} # no task_id
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
submit_result={
"audio_url": "https://temp.example.com/retry.mp3",
"request_id": "req-retry",
"task_id": "",
"duration": 3.0,
"file_size": 2048,
}
)
storage = FakeStorageService(upload_url="https://oss.example.com/retry-final.mp3")
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
result = svc.poll_and_process_synthesis("job-123")
assert result.status == TTSJobStatus.COMPLETED.value
assert result.output_audio_url == "https://oss.example.com/retry-final.mp3"
assert len(cosy.submit_calls) == 1
def test_old_task_id_poll_fails_resynthesizes(self):
"""Old task_id poll fails → fallback to re-synthesize."""
job = make_job()
job.mark_processing()
job.metadata = {"cosyvoice_task_id": "old-task-id"}
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
poll_error=CosyVoiceError("task not found"),
submit_result={
"audio_url": "https://temp.example.com/retry2.mp3",
"request_id": "req-retry2",
"task_id": "",
"duration": 3.0,
"file_size": 1000,
},
)
storage = FakeStorageService()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
result = svc.poll_and_process_synthesis("job-123")
assert len(cosy.poll_calls) == 1
assert cosy.poll_calls[0]["task_id"] == "old-task-id"
assert len(cosy.submit_calls) == 1 # resynthesize
assert result.status == TTSJobStatus.COMPLETED.value
# ── process_synthesis_result tests ──────────────────────
class TestProcessSynthesisResult:
def test_success_marks_completed(self):
job = make_job()
job.mark_processing()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService()
storage = FakeStorageService(upload_url="https://oss.example.com/final.mp3")
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"fake audio"):
result = svc.process_synthesis_result(
"job-123",
audio_url="https://temp.example.com/audio.mp3",
duration=10.5,
file_size=50000,
)
assert result.status == TTSJobStatus.COMPLETED.value
assert result.output_audio_url == "https://oss.example.com/final.mp3"
assert result.output_audio_key == "tts-outputs/user-1/job-123.mp3"
assert result.duration == 10.5
assert result.file_size == 50000
def test_job_not_found_raises(self):
repo = FakeTTSJobRepository()
cosy = FakeCosyVoiceService()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
with pytest.raises(TTSJobNotFoundError):
svc.process_synthesis_result("nonexistent", audio_url="https://x.mp3")
def test_oss_upload_failure_uses_temp_url(self):
job = make_job()
job.mark_processing()
repo = FakeTTSJobRepository(job=job)
storage = FakeStorageService(upload_error=RuntimeError("network error"))
svc = TTSWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService(), storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"data"):
result = svc.process_synthesis_result("job-123", audio_url="https://temp.example.com/x.mp3")
assert result.status == TTSJobStatus.COMPLETED.value
assert result.output_audio_url == "https://temp.example.com/x.mp3"
assert result.output_audio_key == ""
# ── process_synthesis_failure tests ─────────────────────
class TestProcessSynthesisFailure:
def test_marks_job_failed(self):
job = make_job()
job.mark_processing()
repo = FakeTTSJobRepository(job=job)
svc = TTSWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
result = svc.process_synthesis_failure("job-123", "CosyVoice 429 rate limited")
assert result.status == TTSJobStatus.FAILED.value
assert "429 rate limited" in result.error_message
def test_job_not_found_raises(self):
repo = FakeTTSJobRepository()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
with pytest.raises(TTSJobNotFoundError):
svc.process_synthesis_failure("nonexistent", "error")
# ── Segment synthesis tests ─────────────────────────────
class TestSegmentSynthesis:
def test_long_text_triggers_segment_synthesis(self):
"""Text over 500 chars → segment synthesis path."""
long_text = "你好" * 300 # 600 chars, over 500 threshold
job = make_job(input_text=long_text)
repo = FakeTTSJobRepository(job=job)
# Each segment returns audio_url (sync path)
def mock_submit(**kwargs):
return {
"audio_url": f"https://seg.example.com/{kwargs.get('text', '')[:10]}.mp3",
"task_id": "",
"request_id": "r",
"duration": 1.0,
"file_size": 100,
}
cosy = FakeCosyVoiceService()
cosy.submit_synthesize_task = MagicMock(side_effect=mock_submit)
storage = FakeStorageService()
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with (
patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"),
patch("packages.application.tts_job.workflow.safe_download_file") as mock_download,
patch("packages.application.tts_job.workflow.AudioMerger") as mock_merger_class,
):
mock_merger = MagicMock()
mock_merger.merge.return_value = b"merged audio data"
mock_merger_class.return_value = mock_merger
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.COMPLETED.value
assert result.file_size == len(b"merged audio data")
# Multiple segments submitted
assert cosy.submit_synthesize_task.call_count >= 2
def test_segment_failure_marks_job_failed(self):
"""One segment fails → whole job fails."""
long_text = "A" * 600
job = make_job(input_text=long_text)
repo = FakeTTSJobRepository(job=job)
call_count = {"n": 0}
def mock_submit(**kwargs):
call_count["n"] += 1
if call_count["n"] == 2:
raise CosyVoiceError("segment 2 failed")
return {
"audio_url": "https://seg.example.com/s.mp3",
"task_id": "",
"request_id": "r",
"duration": 1.0,
"file_size": 100,
}
cosy = FakeCosyVoiceService()
cosy.submit_synthesize_task = MagicMock(side_effect=mock_submit)
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.FAILED.value
assert "分段" in result.error_message
def test_segment_task_ids_saved_for_async(self):
"""Async segment results → task_ids saved to metadata."""
long_text = "B" * 600
job = make_job(input_text=long_text)
repo = FakeTTSJobRepository(job=job)
def mock_submit(**kwargs):
return {"audio_url": "", "task_id": f"task-{kwargs.get('text', '')[:5]}", "request_id": "r"}
cosy = FakeCosyVoiceService()
cosy.submit_synthesize_task = MagicMock(side_effect=mock_submit)
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=FakeStorageService())
result = svc.start_synthesis("job-123")
assert result.status == TTSJobStatus.PROCESSING.value
assert "segment_task_ids" in result.metadata
assert len(result.metadata["segment_task_ids"]) >= 2
assert result.metadata["segment_count"] >= 2
# ── _resynthesize_and_complete tests ────────────────────
class TestResynthesizeAndComplete:
def test_resynthesize_success(self):
job = make_job(input_text="Retry me")
job.mark_processing()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(
submit_result={
"audio_url": "https://temp.example.com/retry.mp3",
"request_id": "req-r",
"task_id": "",
"duration": 2.5,
"file_size": 1500,
}
)
storage = FakeStorageService(upload_url="https://oss.example.com/retry.mp3")
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy, storage_service=storage)
with patch("packages.application.tts_job.workflow.safe_download_bytes", return_value=b"audio"):
# call via poll_and_process_synthesis which uses _resynthesize_and_complete
result = svc.poll_and_process_synthesis("job-123")
assert result.status == TTSJobStatus.COMPLETED.value
assert len(cosy.submit_calls) == 1
def test_resynthesize_failure_marks_failed(self):
job = make_job()
job.mark_processing()
repo = FakeTTSJobRepository(job=job)
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("permanent failure"))
svc = TTSWorkflowService(repository=repo, cosyvoice_service=cosy)
result = svc.poll_and_process_synthesis("job-123")
assert result.status == TTSJobStatus.FAILED.value
assert "permanent failure" in result.error_message
# ── Error classes tests ─────────────────────────────────
class TestErrorClasses:
def test_workflow_error_inherits_from_exception(self):
err = TTSWorkflowError("test error")
assert isinstance(err, Exception)
assert str(err) == "test error"
def test_not_found_error_inherits_from_exception(self):
err = TTSJobNotFoundError("not found")
assert isinstance(err, Exception)
assert "not found" in str(err)
def test_storage_property_lazy_init(self):
"""_storage property lazily initializes storage service."""
from packages.shared.storage import SharedStorageService
# With provided storage_service
storage = FakeStorageService()
svc = TTSWorkflowService(repository=MagicMock(), cosyvoice_service=MagicMock(), storage_service=storage)
assert svc._storage is storage
+449
View File
@@ -0,0 +1,449 @@
"""
验证码服务单元测试(第十七波)
覆盖:
- VerificationCodeService.generate
- VerificationCodeService.verify
- 频控逻辑(冷却 + 每日上限)
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from packages.application.auth.verification_code_service import (
CODE_TYPE_EMAIL_BIND,
CODE_TYPE_EMAIL_LOGIN,
CODE_TYPE_PHONE_BIND,
DAILY_LIMIT,
DEFAULT_TTL_SECONDS,
MAX_ATTEMPTS,
RESEND_COOLDOWN_SECONDS,
VerificationCodeService,
)
from packages.domain.verification_code import VerificationCode
@pytest.fixture
def mock_repo():
"""mock 验证码仓储"""
return MagicMock()
@pytest.fixture
def service(mock_repo):
"""验证码服务实例"""
return VerificationCodeService(repo=mock_repo)
def make_code(
recipient="test@example.com",
code_type=CODE_TYPE_EMAIL_BIND,
code="123456",
ttl=300,
used=False,
attempts=0,
created_at=None,
):
"""构造一个验证码实体"""
now = created_at or datetime.now(timezone.utc)
return VerificationCode(
id="test-code-id",
recipient=recipient,
code=code,
code_type=code_type,
expires_at=now + timedelta(seconds=ttl),
used_at=now if used else None,
attempts=attempts,
created_at=now,
)
# ============================================================
# generate - 参数校验
# ============================================================
class TestGenerateParamValidation:
"""generate 参数校验"""
def test_empty_recipient(self, service):
"""空接收方"""
code, err = service.generate("", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "不能为空" in err
def test_whitespace_recipient_stripped(self, service, mock_repo):
"""前后空格会被 strip 掉,正常生成"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate(" test@example.com ", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
assert code.recipient == "test@example.com"
def test_invalid_code_type(self, service):
"""无效验证码类型"""
code, err = service.generate("test@example.com", "invalid_type")
assert code is None
assert "无效的验证码类型" in err
# ============================================================
# generate - 正常生成
# ============================================================
class TestGenerateNormal:
"""generate 正常生成场景"""
def test_generate_success(self, service, mock_repo):
"""正常生成验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
assert code.recipient == "test@example.com"
assert code.code_type == CODE_TYPE_EMAIL_BIND
assert len(code.code) == 6
assert code.code.isdigit()
assert not code.is_used
assert not code.is_expired
mock_repo.save.assert_called_once()
def test_custom_code(self, service, mock_repo):
"""自定义验证码"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, custom_code="888888")
assert err is None
assert code.code == "888888"
def test_custom_ttl(self, service, mock_repo):
"""自定义有效期"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND, ttl_seconds=60)
assert err is None
# 过期时间 - 创建时间 ≈ 60 秒
delta = (code.expires_at - code.created_at).total_seconds()
assert delta == 60
def test_default_ttl_used_when_not_specified(self, service, mock_repo):
"""未指定 ttl 时使用默认值"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
delta = (code.expires_at - code.created_at).total_seconds()
assert delta == DEFAULT_TTL_SECONDS
def test_phone_bind_type(self, service, mock_repo):
"""手机号绑定类型也支持"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("13800138000", CODE_TYPE_PHONE_BIND)
assert err is None
assert code.code_type == CODE_TYPE_PHONE_BIND
# ============================================================
# generate - 频控
# ============================================================
class TestGenerateRateLimit:
"""generate 频控逻辑"""
def test_resend_cooldown_blocked(self, service, mock_repo):
"""冷却期内发送被拒绝"""
# 10 秒前刚发过一条
recent = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=10))
mock_repo.find_latest.return_value = recent
mock_repo.count_today.return_value = 1
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in err
assert "秒后再试" in err
# 等待时间应接近 50 秒(60-10)
# 提取数字验证范围
import re
match = re.search(r"(\d+)\s*秒", err)
assert match
wait = int(match.group(1))
assert 45 <= wait <= 55
def test_resend_after_cooldown_ok(self, service, mock_repo):
"""超过冷却期可以重发"""
# 2 分钟前发的,已过冷却
old = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=120))
mock_repo.find_latest.return_value = old
mock_repo.count_today.return_value = 1
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
def test_daily_limit_reached(self, service, mock_repo):
"""达到每日上限"""
# 没有最近的(过了冷却),但今日已达上限
old = make_code(created_at=datetime.now(timezone.utc) - timedelta(hours=2))
mock_repo.find_latest.return_value = old
mock_repo.count_today.return_value = DAILY_LIMIT
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in err
def test_daily_limit_not_reached(self, service, mock_repo):
"""未达每日上限可以发"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = DAILY_LIMIT - 1
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
def test_no_history_first_time_ok(self, service, mock_repo):
"""首次发送,无历史记录"""
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 0
code, err = service.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert err is None
assert code is not None
mock_repo.save.assert_called_once()
# ============================================================
# generate - 自定义频控参数
# ============================================================
class TestGenerateCustomRateLimitParams:
"""自定义频控参数"""
def test_custom_cooldown(self, mock_repo):
"""自定义冷却时间"""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=300, daily_limit=5)
# 60 秒前发的,默认冷却 60 秒就够了,但这里设了 300 秒
recent = make_code(created_at=datetime.now(timezone.utc) - timedelta(seconds=60))
mock_repo.find_latest.return_value = recent
mock_repo.count_today.return_value = 1
code, err = svc.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "发送太频繁" in err
def test_custom_daily_limit(self, mock_repo):
"""自定义每日上限"""
svc = VerificationCodeService(repo=mock_repo, resend_cooldown=60, daily_limit=3)
mock_repo.find_latest.return_value = None
mock_repo.count_today.return_value = 3
code, err = svc.generate("test@example.com", CODE_TYPE_EMAIL_BIND)
assert code is None
assert "今日发送次数已达上限" in err
# ============================================================
# verify - 参数校验
# ============================================================
class TestVerifyParamValidation:
"""verify 参数校验"""
def test_empty_recipient(self, service):
"""空接收方"""
ok, err = service.verify("", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "参数不完整" in err
def test_empty_code(self, service):
"""空验证码"""
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "")
assert not ok
assert "参数不完整" in err
def test_whitespace_stripped(self, service, mock_repo):
"""前后空格会被 strip"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
mock_repo.count_today.return_value = 0
ok, err = service.verify(" test@example.com ", CODE_TYPE_EMAIL_BIND, " 123456 ")
assert ok
assert err is None
# ============================================================
# verify - 正常验证
# ============================================================
class TestVerifyNormal:
"""verify 正常验证场景"""
def test_verify_success_consume(self, service, mock_repo):
"""验证成功并消耗"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456", consume=True)
assert ok
assert err is None
assert code.is_used # 被标记为已使用
# save 被调用了两次:一次 increment_attempts 后,一次 mark_used 后
assert mock_repo.save.call_count >= 2
def test_verify_success_no_consume(self, service, mock_repo):
"""验证成功但不消耗"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456", consume=False)
assert ok
assert err is None
assert not code.is_used # 未被标记
def test_verify_code_not_found(self, service, mock_repo):
"""验证码不存在"""
mock_repo.find_latest.return_value = None
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "不存在或已过期" in err
def test_verify_wrong_code(self, service, mock_repo):
"""验证码错误"""
code = make_code(code="123456")
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "999999")
assert not ok
assert "验证码错误" in err
# 尝试次数增加了
assert code.attempts == 1
def test_verify_already_used(self, service, mock_repo):
"""验证码已使用"""
code = make_code(code="123456", used=True)
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "已使用" in err
def test_verify_expired(self, service, mock_repo):
"""验证码已过期"""
code = make_code(code="123456", ttl=-60) # 已过期 60 秒
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "已过期" in err
def test_verify_attempts_exceeded(self, service, mock_repo):
"""超过最大尝试次数"""
code = make_code(code="123456", attempts=MAX_ATTEMPTS)
mock_repo.find_latest.return_value = code
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "验证次数过多" in err
# verify 里先 increment_attempts 再判断,所以这里 attempts 应该是 MAX_ATTEMPTS + 1
assert code.attempts == MAX_ATTEMPTS + 1
def test_attempts_increment_on_wrong_code(self, service, mock_repo):
"""错误验证码会增加尝试次数"""
code = make_code(code="123456", attempts=0)
mock_repo.find_latest.return_value = code
service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "000000")
assert code.attempts == 1
service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "000001")
assert code.attempts == 2
# ============================================================
# verify - 不同 code_type 互不干扰
# ============================================================
class TestVerifyCodeTypeIsolation:
"""不同验证码类型互不干扰"""
def test_email_bind_vs_email_login(self, service, mock_repo):
"""用 email_login 类型的验证码去验证 email_bind 应该失败"""
code = make_code(code_type=CODE_TYPE_EMAIL_LOGIN, code="123456")
mock_repo.find_latest.return_value = None # 按 email_bind 查不到
# find_latest 按 code_type 查询,传 email_bind 返回 None
def side_effect(recipient, ct):
if ct == CODE_TYPE_EMAIL_LOGIN:
return code
return None
mock_repo.find_latest.side_effect = side_effect
ok, err = service.verify("test@example.com", CODE_TYPE_EMAIL_BIND, "123456")
assert not ok
assert "不存在或已过期" in err
# ============================================================
# 常量值检查
# ============================================================
class TestConstants:
"""常量默认值校验"""
def test_default_cooldown_60(self):
assert RESEND_COOLDOWN_SECONDS == 60
def test_default_daily_limit_10(self):
assert DAILY_LIMIT == 10
def test_default_max_attempts_5(self):
assert MAX_ATTEMPTS == 5
def test_default_ttl_300(self):
assert DEFAULT_TTL_SECONDS == 300
def test_valid_code_types_count(self):
"""5 种验证码类型"""
from packages.application.auth.verification_code_service import VALID_CODE_TYPES
assert len(VALID_CODE_TYPES) == 5
+603
View File
@@ -0,0 +1,603 @@
"""视频分享 - 领域实体 + Use cases 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from packages.application.video_share.commands import (
CreateShareCommand,
UpdateShareCommand,
)
from packages.application.video_share.use_cases import (
AccessShareUseCase,
CreateShareUseCase,
GetShareByTokenUseCase,
InvalidPasswordError,
ListSharesByUserUseCase,
ListSharesByVideoUseCase,
NotFoundError,
PasswordRequiredError,
RecordShareDownloadUseCase,
RevokeShareUseCase,
ShareAccessResult,
ShareExpiredError,
UpdateShareUseCase,
VideoNotFoundError,
)
from packages.domain.generated_video import GeneratedVideo
from packages.domain.video_share import (
VideoShare,
_hash_password,
generate_share_token,
)
def _make_share(
share_id: str = "share_001",
video_id: str = "vid_001",
user_id: str = "user_001",
token: str = "abc123xyz",
password: str | None = None,
expires_at: datetime | None = None,
is_active: bool = True,
) -> VideoShare:
return VideoShare(
id=share_id,
video_id=video_id,
user_id=user_id,
share_token=token,
password_hash=_hash_password(password) if password else None,
expires_at=expires_at,
view_count=0,
download_count=0,
is_active=is_active,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
def _make_video(video_id: str = "vid_001", user_id: str = "user_001") -> GeneratedVideo:
return GeneratedVideo(
id=video_id,
project_id="proj_001",
generation_task_id="task_001",
name="测试视频",
file_url="oss://bucket/video.mp4",
file_size=1024000,
duration=30.5,
width=1080,
height=1920,
fps=30.0,
user_id=user_id,
)
class TestVideoShareDomain:
def test_generate_token_length(self) -> None:
token = generate_share_token(12)
assert len(token) == 12
def test_generate_token_url_safe(self) -> None:
token = generate_share_token(16)
# 只包含字母数字,没有特殊字符
assert token.isalnum()
def test_hash_password_consistent(self) -> None:
h1 = _hash_password("mypassword")
h2 = _hash_password("mypassword")
assert h1 == h2
assert len(h1) == 64 # sha256 hex
def test_hash_password_different_for_different_passwords(self) -> None:
h1 = _hash_password("password1")
h2 = _hash_password("password2")
assert h1 != h2
def test_hash_empty_password(self) -> None:
assert _hash_password("") == ""
def test_create_share_success(self) -> None:
share = VideoShare.create(
video_id="vid_001",
user_id="user_001",
)
assert share.video_id == "vid_001"
assert share.user_id == "user_001"
assert len(share.id) == 32
assert len(share.share_token) == 12
assert share.password_hash is None
assert share.expires_at is None
assert share.is_active is True
assert share.view_count == 0
assert share.download_count == 0
def test_create_share_with_password(self) -> None:
share = VideoShare.create(
video_id="vid_001",
user_id="user_001",
password="secret123",
)
assert share.has_password is True
assert share.verify_password("secret123") is True
assert share.verify_password("wrong") is False
def test_create_share_with_expiry(self) -> None:
future = datetime.now(timezone.utc) + timedelta(days=7)
share = VideoShare.create(
video_id="vid_001",
user_id="user_001",
expires_at=future,
)
assert share.expires_at == future
assert share.is_expired is False
def test_create_share_past_expiry_raises(self) -> None:
past = datetime.now(timezone.utc) - timedelta(days=1)
with pytest.raises(ValueError, match="past"):
VideoShare.create(
video_id="vid_001",
user_id="user_001",
expires_at=past,
)
def test_create_share_empty_video_id_raises(self) -> None:
with pytest.raises(ValueError, match="video_id"):
VideoShare.create(video_id="", user_id="user_001")
def test_create_share_empty_user_id_raises(self) -> None:
with pytest.raises(ValueError, match="user_id"):
VideoShare.create(video_id="vid_001", user_id=" ")
def test_is_expired_false_when_no_expiry(self) -> None:
share = _make_share()
assert share.is_expired is False
def test_is_expired_true_when_past(self) -> None:
past = datetime.now(timezone.utc) - timedelta(hours=1)
share = _make_share(expires_at=past)
assert share.is_expired is True
def test_is_accessible_active_not_expired(self) -> None:
share = _make_share()
assert share.is_accessible is True
def test_is_accessible_inactive(self) -> None:
share = _make_share(is_active=False)
assert share.is_accessible is False
def test_is_accessible_expired(self) -> None:
past = datetime.now(timezone.utc) - timedelta(hours=1)
share = _make_share(expires_at=past)
assert share.is_accessible is False
def test_has_password_false_when_no_password(self) -> None:
share = _make_share()
assert share.has_password is False
def test_has_password_true_when_password_set(self) -> None:
share = _make_share(password="pass123")
assert share.has_password is True
def test_verify_no_password_always_true(self) -> None:
share = _make_share() # 没有密码
assert share.verify_password("") is True
assert share.verify_password("anything") is True
def test_verify_correct_password(self) -> None:
share = _make_share(password="mysecret")
assert share.verify_password("mysecret") is True
def test_verify_wrong_password(self) -> None:
share = _make_share(password="mysecret")
assert share.verify_password("wrong") is False
def test_verify_empty_password_with_password_set(self) -> None:
share = _make_share(password="mysecret")
assert share.verify_password("") is False
def test_increment_view_count(self) -> None:
share = _make_share()
share.increment_view_count()
assert share.view_count == 1
share.increment_view_count()
assert share.view_count == 2
def test_increment_download_count(self) -> None:
share = _make_share()
share.increment_download_count()
assert share.download_count == 1
def test_revoke_sets_inactive(self) -> None:
share = _make_share()
share.revoke()
assert share.is_active is False
assert share.is_accessible is False
class TestCreateShareUseCase:
def test_create_success(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video()
share_repo.create.side_effect = lambda s: s
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
result = use_case.execute(cmd)
assert result.video_id == "vid_001"
assert result.user_id == "user_001"
share_repo.create.assert_called_once()
def test_create_with_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video()
share_repo.create.side_effect = lambda s: s
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(
video_id="vid_001",
user_id="user_001",
password="secret",
)
result = use_case.execute(cmd)
assert result.has_password is True
def test_create_with_expiry(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video()
share_repo.create.side_effect = lambda s: s
use_case = CreateShareUseCase(share_repo, video_repo)
future = datetime.now(timezone.utc) + timedelta(days=1)
cmd = CreateShareCommand(
video_id="vid_001",
user_id="user_001",
expires_at=future,
)
result = use_case.execute(cmd)
assert result.expires_at == future
def test_video_not_found_raises(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = None
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(video_id="nonexistent", user_id="user_001")
with pytest.raises(VideoNotFoundError):
use_case.execute(cmd)
def test_wrong_user_cannot_share(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video(user_id="other_user")
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
with pytest.raises(VideoNotFoundError):
use_case.execute(cmd)
class TestGetShareByTokenUseCase:
def test_found_active_share(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share()
use_case = GetShareByTokenUseCase(repo)
result = use_case.execute("abc123xyz")
assert result.share_token == "abc123xyz"
def test_not_found_raises(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = None
use_case = GetShareByTokenUseCase(repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent")
def test_inactive_share_raises_expired(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share(is_active=False)
use_case = GetShareByTokenUseCase(repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
def test_expired_share_raises(self) -> None:
repo = MagicMock()
past = datetime.now(timezone.utc) - timedelta(days=1)
repo.get_by_token.return_value = _make_share(expires_at=past)
use_case = GetShareByTokenUseCase(repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
class TestAccessShareUseCase:
def test_access_no_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share()
video_repo.get.return_value = _make_video()
share_repo.increment_view.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
result = use_case.execute("abc123xyz")
assert isinstance(result, ShareAccessResult)
assert result.video.id == "vid_001"
assert result.password_verified is True
assert result.share.view_count == 1 # 浏览量+1
share_repo.increment_view.assert_called_once()
def test_access_with_correct_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share(password="mypass")
video_repo.get.return_value = _make_video()
share_repo.increment_view.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
result = use_case.execute("token", password="mypass")
assert result.password_verified is True
def test_access_password_required_but_not_provided(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share(password="secret")
video_repo.get.return_value = _make_video()
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(PasswordRequiredError):
use_case.execute("token", password=None)
def test_access_wrong_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share(password="correct")
video_repo.get.return_value = _make_video()
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(InvalidPasswordError):
use_case.execute("token", password="wrong")
def test_access_share_not_found(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent")
def test_access_share_expired(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
past = datetime.now(timezone.utc) - timedelta(days=1)
share_repo.get_by_token.return_value = _make_share(expires_at=past)
video_repo.get.return_value = _make_video()
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
def test_access_video_not_found(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share()
video_repo.get.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(VideoNotFoundError):
use_case.execute("token")
class TestListSharesByVideoUseCase:
def test_lists_shares(self) -> None:
repo = MagicMock()
expected = [_make_share(), _make_share(share_id="share_002", token="tok2")]
repo.list_by_video.return_value = expected
use_case = ListSharesByVideoUseCase(repo)
result = use_case.execute("vid_001", "user_001")
assert len(result) == 2
repo.list_by_video.assert_called_once_with("vid_001", "user_001")
class TestListSharesByUserUseCase:
def test_lists_with_total(self) -> None:
repo = MagicMock()
items = [_make_share(), _make_share(share_id="s2", token="t2")]
repo.list_by_user.return_value = items
repo.count_by_user.return_value = 10
use_case = ListSharesByUserUseCase(repo)
result_items, total = use_case.execute("user_001", skip=0, limit=2)
assert len(result_items) == 2
assert total == 10
repo.list_by_user.assert_called_once_with("user_001", skip=0, limit=2)
class TestUpdateShareUseCase:
def test_update_password(self) -> None:
repo = MagicMock()
share = _make_share()
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
password="newpass",
)
result = use_case.execute(cmd)
assert result.has_password is True
assert result.verify_password("newpass") is True
repo.update.assert_called_once()
def test_clear_password(self) -> None:
repo = MagicMock()
share = _make_share(password="oldpass")
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
password="", # 空字符串=清除密码
)
result = use_case.execute(cmd)
assert result.has_password is False
assert result.password_hash is None
def test_password_none_does_not_change(self) -> None:
repo = MagicMock()
share = _make_share(password="existing")
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
password=None, # None=不修改
)
result = use_case.execute(cmd)
assert result.verify_password("existing") is True
def test_update_expires_at(self) -> None:
repo = MagicMock()
share = _make_share()
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
future = datetime.now(timezone.utc) + timedelta(days=3)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
expires_at=future,
)
result = use_case.execute(cmd)
assert result.expires_at == future
def test_not_found_raises(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = None
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(share_id="no", user_id="u1", password="x")
with pytest.raises(NotFoundError):
use_case.execute(cmd)
def test_past_expiry_raises(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = _make_share()
use_case = UpdateShareUseCase(repo)
past = datetime.now(timezone.utc) - timedelta(days=1)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
expires_at=past,
)
with pytest.raises(ValueError, match="past"):
use_case.execute(cmd)
class TestRevokeShareUseCase:
def test_revoke_success(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = _make_share()
repo.delete.return_value = True
use_case = RevokeShareUseCase(repo)
result = use_case.execute("share_001", "user_001")
assert result is True
repo.delete.assert_called_once_with("share_001", "user_001")
def test_revoke_not_found_raises(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = None
use_case = RevokeShareUseCase(repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent", "user_001")
class TestRecordShareDownloadUseCase:
def test_record_success(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share()
repo.increment_download.return_value = None
use_case = RecordShareDownloadUseCase(repo)
use_case.execute("token")
repo.increment_download.assert_called_once()
def test_record_with_password(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share(password="pass")
repo.increment_download.return_value = None
use_case = RecordShareDownloadUseCase(repo)
use_case.execute("token", password="pass")
repo.increment_download.assert_called_once()
def test_record_wrong_password_raises(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share(password="correct")
use_case = RecordShareDownloadUseCase(repo)
with pytest.raises(InvalidPasswordError):
use_case.execute("token", password="wrong")
def test_record_share_not_found(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = None
use_case = RecordShareDownloadUseCase(repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent")
def test_record_expired_share(self) -> None:
repo = MagicMock()
past = datetime.now(timezone.utc) - timedelta(days=1)
repo.get_by_token.return_value = _make_share(expires_at=past)
use_case = RecordShareDownloadUseCase(repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
+430 -415
View File
@@ -1,466 +1,481 @@
"""VoiceCloneWorkflowService 单元测试。"""
"""Voice clone workflow service unit tests.
Covers VoiceCloneWorkflowService - start_clone, poll_and_process_clone,
process_clone_result, process_clone_failure, retry_clone.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
CosyVoiceError,
CosyVoiceService,
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowError,
VoiceCloneWorkflowService,
)
from packages.application.voice_clone.use_cases import (
VoiceCloneNotFoundError,
VoiceCloneNotRetryableError,
)
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
from packages.shared.url_security import UrlSecurityError
# ── Helpers ─────────────────────────────────────────────
def _make_profile(
*,
status: VoiceCloneStatus = VoiceCloneStatus.PENDING,
source_audio_url: str = "https://example.com/audio.wav",
retry_count: int = 0,
max_retries: int = 3,
metadata: dict | None = None,
) -> VoiceCloneProfile:
"""创建测试用 VoiceCloneProfile。"""
profile = VoiceCloneProfile.create(
user_id="user-123",
name="测试音色",
source_audio_url=source_audio_url,
max_retries=max_retries,
metadata=metadata,
def make_profile(**kwargs):
defaults = dict(
id="profile-123",
user_id="user-1",
name="我的音色",
description="",
source_audio_url="",
voice_model="",
language="zh-CN",
gender="unknown",
max_retries=3,
)
profile.status = status
profile.retry_count = retry_count
return profile
defaults.update(kwargs)
return VoiceCloneProfile(**defaults)
def _make_service(
*,
repo: MagicMock | None = None,
cosyvoice: MagicMock | None = None,
) -> VoiceCloneWorkflowService:
"""创建测试用 VoiceCloneWorkflowService。"""
mock_repo = repo or MagicMock()
mock_cosyvoice = cosyvoice or MagicMock(spec=CosyVoiceService)
return VoiceCloneWorkflowService(repository=mock_repo, cosyvoice_service=mock_cosyvoice)
class FakeProfileRepository:
def __init__(self, profile=None):
self._profile = profile
self.updated = []
self.created = []
self.get_called = 0
def create(self, profile):
self.created.append(profile)
self._profile = profile
return profile
def get(self, profile_id):
self.get_called += 1
if self._profile and self._profile.id == profile_id:
return self._profile
return None
def update(self, profile):
self.updated.append(profile)
self._profile = profile
return profile
def find_by_user(self, user_id):
if self._profile and self._profile.user_id == user_id:
return [self._profile]
return []
# ── start_clone ──────────────────────────────────────────
class FakeCosyVoiceService:
def __init__(self, submit_result=None, submit_error=None, poll_result=None, poll_error=None):
self._submit_result = submit_result or {
"voice_id": "voice_abc123",
"status": "DEPLOYING",
"request_id": "req-abc",
}
self._submit_error = submit_error
self._poll_result = poll_result or {"voice_id": "voice_abc123"}
self._poll_error = poll_error
self.submit_calls = []
self.poll_calls = []
def submit_clone_task(self, **kwargs):
self.submit_calls.append(kwargs)
if self._submit_error:
raise self._submit_error
return self._submit_result
def poll_clone_task(self, voice_id, timeout=300.0):
self.poll_calls.append({"voice_id": voice_id, "timeout": timeout})
if self._poll_error:
raise self._poll_error
return self._poll_result
# ── start_clone tests ───────────────────────────────────
class TestStartClone:
"""测试 start_clone 方法。"""
def test_start_clone_with_deploying(self) -> None:
"""提交克隆后返回 DEPLOYING 状态,profile 保持 processing。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
# CosyVoice 返回 voice_id + DEPLOYING 状态(需轮询)
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-abc",
"status": "DEPLOYING",
"request_id": "req-123",
}
# repo.create 和 repo.update 返回传入的 profile
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
def test_with_audio_url_submits_and_returns_processing(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService(
submit_result={
"voice_id": "voice_new123",
"status": "DEPLOYING",
"request_id": "req-xyz",
}
)
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
assert profile.status == VoiceCloneStatus.PROCESSING
assert profile.metadata["cosyvoice_task_id"] == "voice-abc"
assert profile.metadata["cosyvoice_request_id"] == "req-123"
mock_cosyvoice.submit_clone_task.assert_called_once()
assert mock_repo.create.call_count == 1
# update 至少调用 2 次:mark_processing + 保存 voice_id
assert mock_repo.update.call_count >= 2
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/audio.mp3",
):
result = svc.start_clone(
user_id="user-1",
name="测试音色",
source_audio_url="https://example.com/audio.mp3",
)
def test_start_clone_with_ok_status(self) -> None:
"""CosyVoice 直接返回 OK 状态,profile 变为 ready。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
assert result.user_id == "user-1"
assert result.name == "测试音色"
assert result.status == VoiceCloneStatus.PROCESSING.value
assert result.metadata["cosyvoice_task_id"] == "voice_new123"
assert result.metadata["cosyvoice_request_id"] == "req-xyz"
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-sync-123",
"status": "OK",
"request_id": "req-456",
}
# CosyVoice was called
assert len(cosy.submit_calls) == 1
assert cosy.submit_calls[0]["audio_url"] == "https://safe.example.com/audio.mp3"
assert cosy.submit_calls[0]["voice_name"] == "测试音色"
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
def test_without_audio_url_returns_pending(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.READY
assert profile.voice_id == "voice-sync-123"
def test_start_clone_cosyvoice_error(self) -> None:
"""CosyVoice 提交失败,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("API 调用失败")
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.FAILED
assert "API 调用失败" in profile.error_message
def test_start_clone_auth_error(self) -> None:
"""CosyVoice 认证失败,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceAuthError("认证失败")
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
assert profile.status == VoiceCloneStatus.FAILED
assert "认证失败" in profile.error_message
def test_start_clone_without_audio_url(self) -> None:
"""没有音频 URL 时,profile 保持 pending 状态(P2-1 修复后)。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
result = svc.start_clone(
user_id="user-1",
name="待上传音色",
source_audio_url="",
)
# P2-1: 没有音频 URL 时不标记 processing,保持 pending
assert profile.status == VoiceCloneStatus.PENDING
mock_cosyvoice.submit_clone_task.assert_not_called()
assert result.status == VoiceCloneStatus.PENDING.value
# No CosyVoice call
assert len(cosy.submit_calls) == 0
def test_start_clone_ssrf_internal_url_rejected(self) -> None:
"""SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="http://127.0.0.1/audio.wav",
def test_sync_completion_status_ok(self):
"""CosyVoice returns OK status directly → mark ready."""
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService(
submit_result={
"voice_id": "voice_ready",
"status": "OK",
"request_id": "req-ok",
}
)
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
# 内网 IP 应该被拒绝,标记为 failed
assert profile.status == VoiceCloneStatus.FAILED
assert "安全校验失败" in profile.error_message
mock_cosyvoice.submit_clone_task.assert_not_called()
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/a.mp3",
):
result = svc.start_clone(
user_id="user-1",
name="秒成音色",
source_audio_url="https://example.com/a.mp3",
)
def test_start_clone_ssrf_private_ip_rejected(self) -> None:
"""SSRF 防护:私有网段 IP 应该被拒绝,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
assert result.status == VoiceCloneStatus.READY.value
assert result.voice_id == "voice_ready"
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
def test_url_security_failure_marks_failed(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="http://192.168.1.100/audio.wav",
)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
side_effect=UrlSecurityError("SSRF detected: internal IP"),
):
result = svc.start_clone(
user_id="user-1",
name="危险音色",
source_audio_url="https://10.0.0.1/internal.mp3",
)
assert profile.status == VoiceCloneStatus.FAILED
assert "安全校验失败" in profile.error_message
mock_cosyvoice.submit_clone_task.assert_not_called()
def test_start_clone_ssrf_public_url_passes(self) -> None:
"""SSRF 防护:正常公网 URL 应该通过校验。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-ssrf-test",
"status": "DEPLOYING",
"request_id": "req-ssrf",
}
mock_repo.create.side_effect = lambda p: p
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
profile = service.start_clone(
user_id="user-123",
name="测试音色",
source_audio_url="https://example.com/audio.wav",
)
# 公网 URL 应该正常通过
assert profile.status == VoiceCloneStatus.PROCESSING
mock_cosyvoice.submit_clone_task.assert_called_once()
# ── process_clone_result ─────────────────────────────────
class TestProcessCloneResult:
"""测试 process_clone_result 方法。"""
def test_process_clone_result_success(self) -> None:
"""克隆成功,profile 标记为 ready。"""
mock_repo = MagicMock()
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo)
result = service.process_clone_result(profile.id, "voice-xyz")
assert result.status == VoiceCloneStatus.READY
assert result.voice_id == "voice-xyz"
def test_process_clone_result_not_found(self) -> None:
"""profile 不存在时抛出异常。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.process_clone_result("nonexistent", "voice-xyz")
# ── process_clone_failure ────────────────────────────────
class TestProcessCloneFailure:
"""测试 process_clone_failure 方法。"""
def test_process_clone_failure(self) -> None:
"""克隆失败,profile 标记为 failed。"""
mock_repo = MagicMock()
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo)
result = service.process_clone_failure(profile.id, "超时错误")
assert result.status == VoiceCloneStatus.FAILED
assert result.error_message == "超时错误"
def test_process_clone_failure_not_found(self) -> None:
"""profile 不存在时抛出异常。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.process_clone_failure("nonexistent", "错误")
# ── retry_clone ──────────────────────────────────────────
class TestRetryClone:
"""测试 retry_clone 方法。"""
def test_retry_clone_with_async_task(self) -> None:
"""重试成功,异步模式。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=1, max_retries=3)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-retry",
"status": "DEPLOYING",
"request_id": "req-retry",
}
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.PROCESSING
assert result.metadata["cosyvoice_task_id"] == "voice-retry"
assert result.retry_count == 2 # prepare_retry 增加了一次
def test_retry_clone_with_ok_status(self) -> None:
"""重试成功,直接返回 OK 状态。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.return_value = {
"voice_id": "voice-retry-sync",
"status": "OK",
"request_id": "req-retry",
}
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.READY
assert result.voice_id == "voice-retry-sync"
def test_retry_clone_not_found(self) -> None:
"""profile 不存在时抛出异常。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.retry_clone("nonexistent", "user-123")
def test_retry_clone_not_retryable(self) -> None:
"""不可重试时抛出异常。"""
mock_repo = MagicMock()
profile = _make_profile(status=VoiceCloneStatus.PROCESSING)
mock_repo.get.return_value = profile
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotRetryableError):
service.retry_clone(profile.id, "user-123")
def test_retry_clone_cosyvoice_error(self) -> None:
"""重试时 CosyVoice 失败,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
mock_cosyvoice.submit_clone_task.side_effect = CosyVoiceError("重试失败")
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.FAILED
assert "重试失败" in result.error_message
def test_retry_clone_ssrf_internal_url_rejected(self) -> None:
"""重试时 SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(
status=VoiceCloneStatus.FAILED,
source_audio_url="http://10.0.0.1/secret.wav",
retry_count=0,
max_retries=3,
)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.retry_clone(profile.id, "user-123")
assert result.status == VoiceCloneStatus.FAILED
assert result.status == VoiceCloneStatus.FAILED.value
assert "安全校验失败" in result.error_message
mock_cosyvoice.submit_clone_task.assert_not_called()
# No CosyVoice call
assert len(cosy.submit_calls) == 0
def test_cosyvoice_error_marks_failed(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("API rate limit exceeded"))
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/a.mp3",
):
result = svc.start_clone(
user_id="user-1",
name="失败音色",
source_audio_url="https://example.com/a.mp3",
)
assert result.status == VoiceCloneStatus.FAILED.value
assert "API rate limit" in result.error_message
def test_cosyvoice_auth_error_marks_failed(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService(submit_error=CosyVoiceAuthError("Invalid API key"))
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/a.mp3",
):
result = svc.start_clone(
user_id="user-1",
name="鉴权失败",
source_audio_url="https://example.com/a.mp3",
)
assert result.status == VoiceCloneStatus.FAILED.value
assert "Invalid API key" in result.error_message
def test_value_error_marks_failed(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService(submit_error=ValueError("audio_url is empty"))
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/a.mp3",
):
result = svc.start_clone(
user_id="user-1",
name="参数错误",
source_audio_url="https://example.com/a.mp3",
)
assert result.status == VoiceCloneStatus.FAILED.value
assert "audio_url is empty" in result.error_message
def test_custom_language_and_model(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"
):
result = svc.start_clone(
user_id="user-1",
name="English Voice",
source_audio_url="https://example.com/a.mp3",
language="en-US",
voice_model="cosyvoice-v3",
)
assert cosy.submit_calls[0]["language"] == "en-US"
assert result.language == "en-US"
assert result.voice_model == "cosyvoice-v3"
def test_metadata_passed_through(self):
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety", return_value="https://s.example.com/a.mp3"
):
result = svc.start_clone(
user_id="user-1",
name="带元数据",
source_audio_url="https://example.com/a.mp3",
metadata={"source": "upload", "format": "wav"},
)
assert result.metadata.get("source") == "upload"
assert result.metadata.get("format") == "wav"
# cosyvoice keys also present
assert "cosyvoice_task_id" in result.metadata
# ── poll_and_process_clone ───────────────────────────────
# ── poll_and_process_clone tests ────────────────────────
class TestPollAndProcessClone:
"""测试 poll_and_process_clone 方法(P2-2 修复)。"""
def test_poll_success_returns_ready(self):
profile = make_profile()
profile.mark_processing()
profile.metadata = {"cosyvoice_task_id": "voice_pending"}
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService(poll_result={"voice_id": "voice_done"})
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
def test_poll_and_process_clone_success(self) -> None:
"""轮询成功:调用 poll_clone_task → process_clone_result。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
result = svc.poll_and_process_clone("profile-123", timeout=60.0)
profile = _make_profile(
status=VoiceCloneStatus.PROCESSING,
metadata={"cosyvoice_task_id": "task-abc"},
)
mock_repo.get.return_value = profile
mock_repo.update.side_effect = lambda p: p
assert result.status == VoiceCloneStatus.READY.value
assert result.voice_id == "voice_done"
assert len(cosy.poll_calls) == 1
assert cosy.poll_calls[0]["voice_id"] == "voice_pending"
assert cosy.poll_calls[0]["timeout"] == 60.0
mock_cosyvoice.poll_clone_task.return_value = {"voice_id": "voice-poll-xyz"}
def test_profile_not_found_raises(self):
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
result = service.poll_and_process_clone(profile.id)
repo = FakeProfileRepository()
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
assert result.status == VoiceCloneStatus.READY
assert result.voice_id == "voice-poll-xyz"
mock_cosyvoice.poll_clone_task.assert_called_once_with("task-abc", timeout=300)
def test_poll_and_process_clone_no_task_id(self) -> None:
"""metadata 中没有 task_id 时抛出 ValueError。"""
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = _make_profile(status=VoiceCloneStatus.PROCESSING, metadata={})
mock_repo.get.return_value = profile
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
with pytest.raises(ValueError, match="cosyvoice_task_id"):
service.poll_and_process_clone(profile.id)
def test_poll_and_process_clone_not_found(self) -> None:
"""profile 不存在时抛出 VoiceCloneNotFoundError。"""
mock_repo = MagicMock()
mock_repo.get.return_value = None
service = _make_service(repo=mock_repo)
with pytest.raises(VoiceCloneNotFoundError):
service.poll_and_process_clone("nonexistent")
svc.poll_and_process_clone("nonexistent")
def test_poll_and_process_clone_timeout(self) -> None:
"""超时时透传 CosyVoiceTimeoutError(由 Celery task 捕获重试)。"""
def test_no_task_id_raises_value_error(self):
profile = make_profile()
profile.mark_processing()
profile.metadata = {} # no task_id
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with pytest.raises(ValueError, match="no cosyvoice_task_id"):
svc.poll_and_process_clone("profile-123")
def test_poll_timeout_propagates(self):
from packages.application.cosyvoice_service import CosyVoiceTimeoutError
mock_repo = MagicMock()
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
profile = make_profile()
profile.mark_processing()
profile.metadata = {"cosyvoice_task_id": "voice_slow"}
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService(poll_error=CosyVoiceTimeoutError("timed out"))
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
profile = _make_profile(
status=VoiceCloneStatus.PROCESSING,
metadata={"cosyvoice_task_id": "task-abc"},
)
mock_repo.get.return_value = profile
mock_cosyvoice.poll_clone_task.side_effect = CosyVoiceTimeoutError("超时")
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
with pytest.raises(CosyVoiceTimeoutError):
service.poll_and_process_clone(profile.id)
svc.poll_and_process_clone("profile-123")
# ── process_clone_result tests ──────────────────────────
class TestProcessCloneResult:
def test_marks_profile_ready(self):
profile = make_profile()
profile.mark_processing()
repo = FakeProfileRepository(profile=profile)
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
result = svc.process_clone_result("profile-123", "voice_final123")
assert result.status == VoiceCloneStatus.READY.value
assert result.voice_id == "voice_final123"
def test_profile_not_found_raises(self):
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
repo = FakeProfileRepository()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
with pytest.raises(VoiceCloneNotFoundError):
svc.process_clone_result("nonexistent", "voice_x")
# ── process_clone_failure tests ─────────────────────────
class TestProcessCloneFailure:
def test_marks_profile_failed(self):
profile = make_profile()
profile.mark_processing()
repo = FakeProfileRepository(profile=profile)
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
result = svc.process_clone_failure("profile-123", "审核未通过")
assert result.status == VoiceCloneStatus.FAILED.value
assert "审核未通过" in result.error_message
def test_profile_not_found_raises(self):
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
repo = FakeProfileRepository()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=FakeCosyVoiceService())
with pytest.raises(VoiceCloneNotFoundError):
svc.process_clone_failure("nonexistent", "error")
# ── retry_clone tests ───────────────────────────────────
class TestRetryClone:
def test_retry_success_submits_again(self):
profile = make_profile(source_audio_url="https://example.com/orig.mp3")
profile.mark_failed("previous error")
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService(
submit_result={
"voice_id": "voice_retry",
"status": "DEPLOYING",
"request_id": "req-retry",
}
)
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/orig.mp3",
):
result = svc.retry_clone("profile-123", "user-1")
# Should be processing again
assert result.status == VoiceCloneStatus.PROCESSING.value
assert result.metadata["cosyvoice_task_id"] == "voice_retry"
assert len(cosy.submit_calls) == 1
def test_retry_url_security_failure(self):
profile = make_profile(source_audio_url="https://10.0.0.1/audio.mp3")
profile.mark_failed("old error")
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService()
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety", side_effect=UrlSecurityError("internal IP")
):
result = svc.retry_clone("profile-123", "user-1")
assert result.status == VoiceCloneStatus.FAILED.value
assert "安全校验失败" in result.error_message
assert len(cosy.submit_calls) == 0
def test_retry_cosyvoice_error(self):
profile = make_profile(source_audio_url="https://example.com/a.mp3")
profile.mark_failed("old error")
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService(submit_error=CosyVoiceError("still failing"))
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/a.mp3",
):
result = svc.retry_clone("profile-123", "user-1")
assert result.status == VoiceCloneStatus.FAILED.value
assert "still failing" in result.error_message
def test_retry_sync_ok(self):
profile = make_profile(source_audio_url="https://example.com/a.mp3")
profile.mark_failed("old error")
repo = FakeProfileRepository(profile=profile)
cosy = FakeCosyVoiceService(
submit_result={
"voice_id": "voice_instant",
"status": "OK",
"request_id": "req-instant",
}
)
svc = VoiceCloneWorkflowService(repository=repo, cosyvoice_service=cosy)
with patch(
"packages.application.voice_clone.workflow.validate_url_safety",
return_value="https://safe.example.com/a.mp3",
):
result = svc.retry_clone("profile-123", "user-1")
assert result.status == VoiceCloneStatus.READY.value
assert result.voice_id == "voice_instant"
# ── Error class tests ───────────────────────────────────
class TestErrorClasses:
def test_workflow_error_inherits_exception(self):
err = VoiceCloneWorkflowError("test error")
assert isinstance(err, Exception)
assert str(err) == "test error"
+130 -53
View File
@@ -28,12 +28,14 @@ class TestWatermarkConfig(unittest.TestCase):
def test_from_dict_text_mode(self):
"""文字水印模式."""
cfg = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
"text": "hello world",
"position": "top_left",
})
cfg = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
"text": "hello world",
"position": "top_left",
}
)
self.assertIsNotNone(cfg)
self.assertEqual(cfg.mode, "text")
self.assertEqual(cfg.text, "hello world")
@@ -41,18 +43,22 @@ class TestWatermarkConfig(unittest.TestCase):
def test_from_dict_image_missing_path(self):
"""图片水印缺路径 → None."""
cfg = WatermarkConfig.from_dict({
"enabled": True,
"mode": "image",
})
cfg = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "image",
}
)
self.assertIsNone(cfg)
def test_from_dict_text_missing_text(self):
"""文字水印缺文字 → None."""
cfg = WatermarkConfig.from_dict({
"enabled": True,
"mode": "text",
})
cfg = WatermarkConfig.from_dict(
{
"enabled": True,
"mode": "text",
}
)
self.assertIsNone(cfg)
def test_validate_text_valid(self):
@@ -98,9 +104,7 @@ class TestWatermarkEnginePosition(unittest.TestCase):
self.my = 20
def test_top_left(self):
x, y = WatermarkEngine.calc_position(
"top_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
x, y = WatermarkEngine.calc_position("top_left", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my)
self.assertEqual(x, 20)
self.assertEqual(y, 20)
@@ -126,9 +130,7 @@ class TestWatermarkEnginePosition(unittest.TestCase):
self.assertEqual(y, (1080 - 100) // 2)
def test_center(self):
x, y = WatermarkEngine.calc_position(
"center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
x, y = WatermarkEngine.calc_position("center", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my)
self.assertEqual(x, (1920 - 200) // 2)
self.assertEqual(y, (1080 - 100) // 2)
@@ -162,9 +164,7 @@ class TestWatermarkEnginePosition(unittest.TestCase):
def test_default_fallback(self):
"""非法位置默认右下角."""
x, y = WatermarkEngine.calc_position(
"unknown", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my
)
x, y = WatermarkEngine.calc_position("unknown", self.out_w, self.out_h, self.wm_w, self.wm_h, self.mx, self.my)
self.assertEqual(x, 1920 - 200 - 20)
self.assertEqual(y, 1080 - 100 - 20)
@@ -188,9 +188,7 @@ class TestWatermarkEngineFilters(unittest.TestCase):
margin_x=10,
margin_y=10,
)
result = WatermarkEngine.build_text_watermark_filter(
"[in]", "[out]", cfg, 1920, 1080
)
result = WatermarkEngine.build_text_watermark_filter("[in]", "[out]", cfg, 1920, 1080)
self.assertTrue(result.startswith("[in]drawtext="))
self.assertIn("text='hello'", result)
self.assertIn("fontsize=24", result)
@@ -206,9 +204,7 @@ class TestWatermarkEngineFilters(unittest.TestCase):
scroll=True,
scroll_speed=60,
)
result = WatermarkEngine.build_text_watermark_filter(
"[in]", "[out]", cfg, 1920, 1080
)
result = WatermarkEngine.build_text_watermark_filter("[in]", "[out]", cfg, 1920, 1080)
self.assertIn("mod(60*t", result)
@@ -224,16 +220,18 @@ class TestIntroOutroConfig(unittest.TestCase):
def test_from_dict_intro_text(self):
"""文字片头配置."""
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"intro": {
"type": "text",
"title": "欢迎观看",
"subtitle": "精彩内容马上开始",
"duration": 3.0,
"background": "#1a1a2e",
},
})
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"intro": {
"type": "text",
"title": "欢迎观看",
"subtitle": "精彩内容马上开始",
"duration": 3.0,
"background": "#1a1a2e",
},
}
)
self.assertTrue(cfg.enabled)
self.assertTrue(cfg.has_intro)
self.assertFalse(cfg.has_outro)
@@ -243,14 +241,16 @@ class TestIntroOutroConfig(unittest.TestCase):
def test_from_dict_outro_video(self):
"""视频片尾配置."""
cfg = IntroOutroConfig.from_dict({
"enabled": True,
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 5.0,
},
})
cfg = IntroOutroConfig.from_dict(
{
"enabled": True,
"outro": {
"type": "video",
"video_path": "/tmp/outro.mp4",
"duration": 5.0,
},
}
)
self.assertTrue(cfg.has_outro)
self.assertEqual(cfg.outro_type, "video")
self.assertEqual(cfg.outro_video_path, "/tmp/outro.mp4")
@@ -314,9 +314,7 @@ class TestIntroOutroEngineConcat(unittest.TestCase):
# 创建空文件模拟
main_video.write_bytes(b"fake video data")
result = IntroOutroEngine.concat_with_intro_outro(
main_video, None, None, output
)
result = IntroOutroEngine.concat_with_intro_outro(main_video, None, None, output)
self.assertTrue(result)
self.assertTrue(output.exists())
self.assertEqual(main_video.read_bytes(), output.read_bytes())
@@ -333,12 +331,91 @@ class TestIntroOutroEngineConcat(unittest.TestCase):
# intro 路径不存在
intro = Path(tmpdir) / "nonexistent.mp4"
result = IntroOutroEngine.concat_with_intro_outro(
main_video, intro, None, output
)
result = IntroOutroEngine.concat_with_intro_outro(main_video, intro, None, output)
self.assertTrue(result)
self.assertTrue(output.exists())
class TestResolveWatermarkConfig(unittest.TestCase):
"""UnifiedRenderService._resolve_watermark_config 兼容性测试."""
def _resolve(self, plan_config):
from video_processing.unified_render_service import UnifiedRenderService
return UnifiedRenderService._resolve_watermark_config(plan_config)
def test_none_or_empty_config(self):
"""空配置 → None."""
self.assertIsNone(self._resolve(None))
self.assertIsNone(self._resolve({}))
self.assertIsNone(self._resolve([])) # 非dict安全处理
def test_nested_format_enabled(self):
"""嵌套格式 config.watermark 正常解析."""
cfg = {"watermark": {"enabled": True, "mode": "text", "text": "测试水印"}}
result = self._resolve(cfg)
self.assertIsNotNone(result)
self.assertEqual(result.mode, "text")
self.assertEqual(result.text, "测试水印")
def test_nested_format_disabled(self):
"""嵌套格式未启用 → None."""
cfg = {"watermark": {"enabled": False, "mode": "text", "text": "测试"}}
self.assertIsNone(self._resolve(cfg))
def test_flat_export_format_enabled(self):
"""扁平格式 config.export.watermark_enabled + text 正常解析."""
cfg = {"export": {"watermark_enabled": True, "watermark_text": "我的水印"}}
result = self._resolve(cfg)
self.assertIsNotNone(result)
self.assertEqual(result.mode, "text")
self.assertEqual(result.text, "我的水印")
self.assertEqual(result.position, "bottom_right")
def test_flat_export_format_disabled(self):
"""扁平格式未启用 → None."""
cfg = {"export": {"watermark_enabled": False, "watermark_text": "测试"}}
self.assertIsNone(self._resolve(cfg))
def test_flat_export_format_no_text(self):
"""扁平格式启用但无文字 → None."""
cfg = {"export": {"watermark_enabled": True, "watermark_text": ""}}
self.assertIsNone(self._resolve(cfg))
def test_nested_takes_priority(self):
"""嵌套格式存在时优先使用嵌套格式(忽略扁平格式)."""
cfg = {
"watermark": {"enabled": True, "mode": "text", "text": "嵌套水印"},
"export": {"watermark_enabled": True, "watermark_text": "扁平水印"},
}
result = self._resolve(cfg)
self.assertIsNotNone(result)
self.assertEqual(result.text, "嵌套水印")
def test_flat_with_custom_position(self):
"""扁平格式支持自定义位置、透明度等参数."""
cfg = {
"export": {
"watermark_enabled": True,
"watermark_text": "自定义水印",
"watermark_position": "top_left",
"watermark_opacity": 0.5,
"watermark_font_size": 32,
"watermark_font_color": "red",
}
}
result = self._resolve(cfg)
self.assertIsNotNone(result)
self.assertEqual(result.position, "top_left")
self.assertAlmostEqual(result.opacity, 0.5)
self.assertEqual(result.font_size, 32)
self.assertEqual(result.font_color, "red")
def test_no_export_key(self):
"""没有 export 字段时不报错."""
cfg = {"other": "value"}
self.assertIsNone(self._resolve(cfg))
if __name__ == "__main__":
unittest.main()
+354
View File
@@ -0,0 +1,354 @@
"""
微信 OAuth 服务单元测试(第二十波)
覆盖:
- MemoryStateStore (put / verify_and_consume / 过期清理)
- WechatOAuthService.is_configured
- WechatOAuthService.generate_auth_url (正常模式 + mock模式)
- WechatOAuthService.handle_callback (正常 / 缺code / state无效 / mock模式 / access_token失败 / userinfo失败 / 网络异常)
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from packages.application.auth.wechat_oauth_service import (
STATE_TTL_SECONDS,
MemoryStateStore,
WechatOAuthService,
WechatUserInfo,
get_wechat_oauth_service,
)
# ============================================================
# MemoryStateStore
# ============================================================
class TestMemoryStateStore:
"""MemoryStateStore 内存 state 存储"""
def test_put_and_verify(self):
"""放入并验证成功"""
store = MemoryStateStore()
store.put("state-1")
assert store.verify_and_consume("state-1") is True
def test_verify_consumes_once(self):
"""state 是一次性的,验证后即消费"""
store = MemoryStateStore()
store.put("state-1")
assert store.verify_and_consume("state-1") is True
assert store.verify_and_consume("state-1") is False
def test_verify_nonexistent(self):
"""验证不存在的 state"""
store = MemoryStateStore()
assert store.verify_and_consume("nonexistent") is False
def test_expired_state_is_cleaned(self):
"""过期的 state 会被清理"""
store = MemoryStateStore(ttl_seconds=1) # 1秒过期
store.put("state-1")
time.sleep(1.1)
assert store.verify_and_consume("state-1") is False
def test_put_cleans_expired(self):
"""put 时会清理过期的"""
store = MemoryStateStore(ttl_seconds=1)
store.put("state-1")
time.sleep(1.1)
store.put("state-2")
# state-1 应该被清理掉了
assert len(store._states) == 1
assert "state-2" in store._states
def test_default_ttl(self):
"""默认 TTL 是 10 分钟"""
store = MemoryStateStore()
assert store._ttl == STATE_TTL_SECONDS
# ============================================================
# WechatOAuthService - is_configured
# ============================================================
class TestIsConfigured:
"""is_configured 配置检查"""
def test_fully_configured(self):
"""三项都配置了"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
assert svc.is_configured() is True
def test_missing_app_id(self):
"""缺 app_id"""
svc = WechatOAuthService(app_id="", app_secret="secret", redirect_uri="https://example.com/cb")
assert svc.is_configured() is False
def test_missing_app_secret(self):
"""缺 app_secret"""
svc = WechatOAuthService(app_id="wx123", app_secret="", redirect_uri="https://example.com/cb")
assert svc.is_configured() is False
def test_missing_redirect_uri(self):
"""缺 redirect_uri"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="")
assert svc.is_configured() is False
def test_none_configured(self):
"""全没配置"""
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
assert svc.is_configured() is False
# ============================================================
# WechatOAuthService - generate_auth_url
# ============================================================
class TestGenerateAuthUrl:
"""generate_auth_url 生成授权链接"""
def test_configured_mode(self):
"""配置完整时生成正式微信授权链接"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
url, state = svc.generate_auth_url()
assert "open.weixin.qq.com" in url
assert "appid=wx123" in url
assert "redirect_uri=" in url
assert "response_type=code" in url
assert "scope=snsapi_login" in url
assert f"state={state}" in url
assert "#wechat_redirect" in url
assert state # state 非空
def test_mock_mode(self):
"""未配置时返回 mock URL"""
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
url, state = svc.generate_auth_url()
assert "/mock/wechat/auth" in url
assert "app_id=mock" in url
assert f"state={state}" in url
assert state
def test_custom_scope(self):
"""自定义 scope"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
url, _ = svc.generate_auth_url(scope="snsapi_userinfo")
assert "scope=snsapi_userinfo" in url
def test_state_is_unique(self):
"""每次生成的 state 不同"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
_, state1 = svc.generate_auth_url()
_, state2 = svc.generate_auth_url()
assert state1 != state2
def test_state_stored_in_store(self):
"""生成的 state 会存入 store,可被 callback 验证"""
store = MemoryStateStore()
svc = WechatOAuthService(
app_id="wx123",
app_secret="secret",
redirect_uri="https://example.com/cb",
state_store=store,
)
_, state = svc.generate_auth_url()
assert store.verify_and_consume(state) is True
# ============================================================
# WechatOAuthService - handle_callback
# ============================================================
class TestHandleCallback:
"""handle_callback 处理微信回调"""
def test_missing_code(self):
"""缺少授权码"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
user_info, err = svc.handle_callback("", "some-state")
assert user_info is None
assert "缺少授权码" in err
def test_invalid_state(self):
"""state 无效或已过期"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
user_info, err = svc.handle_callback("code123", "invalid-state")
assert user_info is None
assert "state" in err
def test_empty_state(self):
"""空 state"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
user_info, err = svc.handle_callback("code123", "")
assert user_info is None
assert "state" in err
def test_mock_mode_success(self):
"""mock 模式下返回模拟用户信息"""
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
# 先生成一个有效的 state
_, state = svc.generate_auth_url()
user_info, err = svc.handle_callback("mock_code_123456", state)
assert err is None
assert user_info is not None
assert user_info.openid.startswith("mock_")
assert user_info.unionid.startswith("mock_union_")
assert user_info.nickname == "微信测试用户"
def test_configured_mode_success(self):
"""配置完整时正常调用微信 API"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
_, state = svc.generate_auth_url()
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
# access_token 响应
token_resp = MagicMock()
token_resp.json.return_value = {
"access_token": "at_123",
"openid": "openid_abc",
"unionid": "unionid_xyz",
"expires_in": 7200,
}
# userinfo 响应
user_resp = MagicMock()
user_resp.json.return_value = {
"openid": "openid_abc",
"nickname": "测试用户",
"headimgurl": "https://wx.qq.com/avatar.jpg",
"sex": 1,
}
mock_get.side_effect = [token_resp, user_resp]
user_info, err = svc.handle_callback("code_abc", state)
assert err is None
assert user_info is not None
assert user_info.openid == "openid_abc"
assert user_info.unionid == "unionid_xyz"
assert user_info.nickname == "测试用户"
assert user_info.avatar_url == "https://wx.qq.com/avatar.jpg"
# 应该调用了两次 get
assert mock_get.call_count == 2
def test_access_token_failed(self):
"""access_token 接口返回错误"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
_, state = svc.generate_auth_url()
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
err_resp = MagicMock()
err_resp.json.return_value = {
"errcode": 40029,
"errmsg": "invalid code",
}
mock_get.return_value = err_resp
user_info, err = svc.handle_callback("bad_code", state)
assert user_info is None
assert "微信授权失败" in err
assert "invalid code" in err
def test_userinfo_failed(self):
"""userinfo 接口返回错误"""
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
_, state = svc.generate_auth_url()
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
token_resp = MagicMock()
token_resp.json.return_value = {
"access_token": "at_123",
"openid": "openid_abc",
}
err_resp = MagicMock()
err_resp.json.return_value = {
"errcode": 40001,
"errmsg": "invalid credential",
}
mock_get.side_effect = [token_resp, err_resp]
user_info, err = svc.handle_callback("code_abc", state)
assert user_info is None
assert "获取用户信息失败" in err
def test_network_error(self):
"""网络异常"""
import requests
svc = WechatOAuthService(app_id="wx123", app_secret="secret", redirect_uri="https://example.com/cb")
_, state = svc.generate_auth_url()
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
mock_get.side_effect = requests.ConnectionError("timeout")
user_info, err = svc.handle_callback("code_abc", state)
assert user_info is None
assert "微信服务暂不可用" in err
def test_state_one_time_use(self):
"""state 一次性使用,重复使用会失败"""
svc = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
_, state = svc.generate_auth_url()
# 第一次成功
user_info1, err1 = svc.handle_callback("code1", state)
assert err1 is None
assert user_info1 is not None
# 第二次用同一个 state 失败
user_info2, err2 = svc.handle_callback("code2", state)
assert user_info2 is None
assert "state" in err2
# ============================================================
# WechatUserInfo
# ============================================================
class TestWechatUserInfo:
"""WechatUserInfo 数据类"""
def test_minimal_fields(self):
info = WechatUserInfo(openid="abc")
assert info.openid == "abc"
assert info.unionid == ""
assert info.nickname == ""
assert info.avatar_url == ""
def test_full_fields(self):
info = WechatUserInfo(
openid="abc",
unionid="def",
nickname="测试",
avatar_url="https://example.com/avatar.jpg",
)
assert info.openid == "abc"
assert info.unionid == "def"
assert info.nickname == "测试"
assert info.avatar_url == "https://example.com/avatar.jpg"
# ============================================================
# get_wechat_oauth_service
# ============================================================
class TestGetWechatOAuthService:
"""工厂函数"""
def test_returns_service_instance(self):
svc = get_wechat_oauth_service()
assert isinstance(svc, WechatOAuthService)