Compare commits

...

15 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
16 changed files with 1383 additions and 367 deletions
+65 -1
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import List, Literal, Optional
from app.services.ai_service import TITLE_STYLES, generate_smart_titles
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
from fastapi import APIRouter
from pydantic import BaseModel, Field
@@ -45,6 +45,42 @@ class TitleStyleInfo(BaseModel):
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="输入素材总数")
# ── 路由 ────────────────────────────────────────────────────────────────────
@@ -70,3 +106,31 @@ def list_title_styles():
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"],
)
+13 -4
View File
@@ -124,10 +124,19 @@ def _select_assets_from_library(
return [a.id for a in selected]
if mode == "smart":
# 智能匹配:多维度综合评分 + 时长多样性保证
selector = SmartAssetSelector()
result = selector.select(ready_video_assets, count=count, ensure_diversity=True)
return result.selected_ids
# 智能匹配:按质量分降序 + 时长降序作为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),
-(getattr(a, "duration", 0.0) or 0.0),
),
)
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]
-8
View File
@@ -109,14 +109,6 @@ class Settings(BaseSettings):
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
RENDER_ENGINE: str = "legacy"
# 豆包大模型配置(火山引擎方舟平台)
# 未配置 API Key 时自动降级为本地模拟生成
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
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
+273 -85
View File
@@ -14,12 +14,11 @@ from __future__ import annotations
import json
import logging
import math
import random
import time
from typing import Any, Dict, List, Optional
import httpx
from app.config import get_settings
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
@@ -57,85 +56,6 @@ TITLE_STYLES = {
}
# ── 豆包 AI 客户端 ──────────────────────────────────────────────────────────
class DoubaoAIClient:
"""豆包大模型 API 客户端.
使用火山引擎方舟平台的 OpenAI 兼容接口。
未配置 API Key 时,is_available 返回 False,调用方应降级处理。
"""
def __init__(self) -> None:
settings = get_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 接口.
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 = {
"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调用失败,%s秒后重试 (第%d/%d次): %s",
wait,
attempt + 1,
self.max_retries + 1,
e,
)
time.sleep(wait)
logger.error("豆包API调用最终失败: %s", last_error)
return None
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
@@ -251,7 +171,7 @@ def generate_smart_titles(
count = max(3, min(10, count)) # 3-10 个
description = (description or "").strip()
client = DoubaoAIClient()
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级生成标题")
titles = _generate_titles_fallback(description, style, count)
@@ -280,7 +200,7 @@ def generate_smart_titles(
{"role": "user", "content": user_prompt},
]
result = client._chat_completion(
result = client.chat_completion(
messages=messages,
temperature=0.8,
max_tokens=512,
@@ -314,6 +234,266 @@ def generate_smart_titles(
}
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
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),
}
# ── 单例入口 ─────────────────────────────────────────────────────────────────
@@ -332,7 +512,7 @@ class AIService:
"""AI 服务统一入口,便于后续扩展更多能力."""
def __init__(self) -> None:
self._client = DoubaoAIClient()
self._client = get_doubao_client()
@property
def is_available(self) -> bool:
@@ -345,3 +525,11 @@ class AIService:
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)
@@ -18,7 +18,6 @@
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@@ -278,9 +277,7 @@ class SmartAssetSelector:
# 分桶
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
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]
@@ -295,7 +292,7 @@ class SmartAssetSelector:
selected_ids: set[str] = set()
# 先按配额从每个桶取
for bucket, name in zip(buckets, bucket_names):
for bucket, _name in zip(buckets, bucket_names, strict=False):
quota = min(base_quota, len(bucket))
if quota <= 0:
continue
+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()
})
})
+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 封面生成 ──────────────────────────────────────────────────────────────
@@ -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),
)
+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
+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 -106
View File
@@ -1,117 +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 "缓存命中,验证依赖完整性..."
# 验证关键依赖是否存在(防止缓存损坏或版本漂移)
DEPS_OK=1
if ! docker run --rm $VOLUME_ARGS -v "$PWD:/workspace" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc "npx --yes vitest --version > /dev/null 2>&1 && npx --yes vite --version > /dev/null 2>&1" 2>/dev/null; then
echo "⚠️ 缓存依赖不完整(vitest/vite缺失),废弃缓存重新安装"
DEPS_OK=0
docker volume rm "$VOLUME_NAME" > /dev/null 2>&1 || true
docker volume create "$VOLUME_NAME" > /dev/null 2>&1 || true
fi
if [ "$DEPS_OK" -eq 1 ]; then
echo "✅ 依赖完整性校验通过,跳过 npm ci"
else
# 重新安装
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
fi
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"
+257 -124
View File
@@ -18,56 +18,44 @@ from unittest.mock import MagicMock, patch
sys.path.insert(0, "apps/api")
from app.services.ai_service import ( # noqa: E402
DoubaoAIClient,
TITLE_STYLES,
_generate_titles_fallback,
_parse_semantic_match_response,
_parse_titles_from_response,
_semantic_match_fallback,
generate_smart_titles,
semantic_match_assets,
)
class TestDoubaoAIClient(unittest.TestCase):
"""豆包客户端基础测试."""
class TestAIClientAvailability(unittest.TestCase):
"""AI客户端可用性检测(通过mock get_doubao_client."""
def test_client_availability_without_key(self):
"""未配置 API Key 时不可用."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=2,
)
client = DoubaoAIClient()
self.assertFalse(client.is_available)
def test_generate_fallback_when_client_unavailable(self):
"""客户端不可用时走降级."""
mock_client = MagicMock()
mock_client.is_available = False
mock_client.chat_completion = MagicMock(return_value=None)
def test_client_availability_with_key(self):
"""配置了 API Key 时可用."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="sk-test-123",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=2,
)
client = DoubaoAIClient()
self.assertTrue(client.is_available)
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_chat_completion_not_available_returns_none(self):
"""可用时调用返回 None."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=2,
)
client = DoubaoAIClient()
result = client._chat_completion([{"role": "user", "content": "hi"}])
self.assertIsNone(result)
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):
@@ -88,7 +76,7 @@ class TestTitleParsing(unittest.TestCase):
def test_parse_markdown_code_block_json(self):
"""解析 markdown 代码块包裹的 JSON."""
content = "```json\n[\"标题1\", \"标题2\"]\n```"
content = '```json\n["标题1", "标题2"]\n```'
result = _parse_titles_from_response(content)
self.assertEqual(len(result), 2)
@@ -165,14 +153,9 @@ class TestGenerateSmartTitles(unittest.TestCase):
def test_generate_without_api_key_fallback(self):
"""无 API Key 时走降级路径."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=2,
)
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")
@@ -180,27 +163,17 @@ class TestGenerateSmartTitles(unittest.TestCase):
def test_generate_invalid_style_defaults_to_viral(self):
"""无效风格默认 viral."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=2,
)
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):
"""数量边界处理."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="",
DOUBAO_MODEL="test",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=2,
)
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)
@@ -210,70 +183,37 @@ class TestGenerateSmartTitles(unittest.TestCase):
def test_generate_with_api_success(self):
"""API 调用成功路径."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="sk-test-123",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=0,
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"choices": [
{
"message": {
"content": json.dumps(["AI标题1", "AI标题2", "AI标题3", "AI标题4", "AI标题5"])
}
}
]
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.post", return_value=mock_response):
result = generate_smart_titles("测试视频", "viral", 5)
self.assertEqual(result["source"], "doubao")
self.assertEqual(len(result["titles"]), 5)
self.assertIn("AI标题1", result["titles"])
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 调用失败时降级."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="sk-test-123",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=1,
DOUBAO_MAX_RETRIES=0,
)
with patch("httpx.post", side_effect=Exception("API Error")):
result = generate_smart_titles("测试视频", "viral", 5)
self.assertEqual(result["source"], "fallback")
self.assertEqual(len(result["titles"]), 5)
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 返回无法解析时降级."""
with patch("app.services.ai_service.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
DOUBAO_API_KEY="sk-test-123",
DOUBAO_MODEL="test-model",
DOUBAO_BASE_URL="https://test.com",
DOUBAO_TIMEOUT=30,
DOUBAO_MAX_RETRIES=0,
)
mock_response = MagicMock()
mock_response.status_code = 200
# 返回无法解析的内容(只有一个标题且格式异常)
mock_response.json.return_value = {
"choices": [{"message": {"content": "一段文字说明,不是标题列表"}}]
}
mock_response.raise_for_status = MagicMock()
with patch("httpx.post", return_value=mock_response):
result = generate_smart_titles("测试视频", "viral", 5)
# 只有1个有效标题,不足2个触发降级
self.assertEqual(result["source"], "fallback")
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):
@@ -281,7 +221,7 @@ class TestTitleStyles(unittest.TestCase):
def test_all_styles_have_required_fields(self):
"""所有风格都有必要字段."""
for key, info in TITLE_STYLES.items():
for _key, info in TITLE_STYLES.items():
self.assertIn("name", info)
self.assertIn("description", info)
self.assertIn("examples", info)
@@ -295,5 +235,198 @@ class TestTitleStyles(unittest.TestCase):
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()