Compare commits

..

10 Commits

Author SHA1 Message Date
xiaoxia ebb3c79d63 debug(douyin): staging 临时开启 DOUYIN_DEBUG_ERRORS 暴露原始错误以定位 503 根因
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
CI/CD Pipeline / Canary Release to Production (push) Blocked by required conditions
CI/CD Pipeline / CI Gate (push) Blocked by required conditions
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Has started running
CI/CD Pipeline / Validate - Security (push) Has started running
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has started running
CI/CD Pipeline / Unit Tests (push) Has started running
CI/CD Pipeline / Integration Tests (push) Has started running
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Has started running
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 Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 35s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has started running
- scripts_ai.py: 加 DOUYIN_DEBUG_ERRORS 开关(staging env 开启),错误 detail 附带 yt-dlp 原始错误前 300 字符
- _dbg helper + cookies 路径/字节数日志
- .env.staging: DOUYIN_DEBUG_ERRORS=true(定位完成后关闭)
2026-09-16 21:17:49 +08:00
xiaoxia 4f5ae52a40 fix(douyin): 升级 yt-dlp>=2026.8.19 + 桌面浏览器 UA/Referer 绕过抖音反爬
API Base Image Build / Build API Base Image (push) Has started running
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
Worker Base Image Build / Build Worker Base Image (push) Has started running
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (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 Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 51s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m46s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m39s
CI/CD Pipeline / Integration Tests (push) Successful in 11m21s
CI/CD Pipeline / Validate - Style (push) Successful in 12m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 15m0s
CI/CD Pipeline / Unit Tests (push) Successful in 17m35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 22m4s
CI/CD Pipeline / Validate - Security (push) Successful in 37m10s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
- requirements.txt + api.Dockerfile 强制升级 yt-dlp(基础镜像旧版 cookies 支持差)
- scripts_ai.py ydl_opts 加 http_headers(桌面 Chrome UA + Referer: douyin.com)
- 配合 baked-in cookies fallback + host scp 机制,提升抖音提取成功率
2026-09-16 20:40:21 +08:00
xiaoxia a425103b4f fix(douyin): cookies 文件 host 挂载为空时 fallback 到镜像内 baked-in 默认 cookies
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (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 Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m9s
CI/CD Pipeline / Integration Tests (push) Successful in 11m56s
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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
- api.Dockerfile: COPY cookies 到 /app/configs/douyin_cookies_default.txt(镜像内兜底)
- scripts_ai.py: _resolve_cookies_file() 优先 host 挂载路径,文件<200B 时 fallback 到镜像内 default
- 解决 staging 部署时 host 空占位文件覆盖镜像内有效 cookies 导致抖音提取一直 503 的问题
- 单测 15560 passed
2026-09-16 20:27:58 +08:00
xiaoxia 018e1bcb9b fix(ci): deploy-staging job 直接 scp 抖音 cookies 到 staging host,避免空占位覆盖镜像内 cookies
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (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 / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m41s
CI/CD Pipeline / Integration Tests (push) Successful in 12m15s
CI/CD Pipeline / Validate - Style (push) Successful in 12m44s
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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
- ci-pipeline.yml: 在 .env scp 之后增加 douyin_cookies.txt scp
- ci_staging_deploy.sh: 简化逻辑,不再尝试从本地 repo 路径拷贝(脚本是通过管道 SSH 执行无本地文件)
2026-09-16 20:14:37 +08:00
xiaoxia 221eed2a25 fix(deploy): staging 部署时自动从 repo 拷贝真实抖音 cookies(>200B),避免空占位覆盖镜像内 cookies
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (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 / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m28s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 28s
CI/CD Pipeline / Integration Tests (push) Successful in 8m13s
CI/CD Pipeline / Validate - Style (push) Successful in 9m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m3s
CI/CD Pipeline / Unit Tests (push) Successful in 14m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 16m58s
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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-09-16 19:47:07 +08:00
xiaoxia 35c00ccbb7 fix(douyin): 打包抖音匿名 cookies 到 API 镜像 + 容器内路径兜底
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Integration Tests (push) Successful in 7m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
- api.Dockerfile: COPY deploy/configs/douyin_cookies.txt 到 /app/configs/
  (ci_staging_deploy.sh 的 volume mount 会覆盖为 host 上的真实文件)
- deploy/configs/douyin_cookies.txt: 替换占位符为从浏览器导出的真实匿名 cookies
  (31条,包含 ttwid/passport_csrf_token/odin_tt/s_v_web_id 等)
- 过期后可用 scripts/refresh_douyin_cookies.py 重新导出;
  过期时 extract-from-douyin 已返回 503 友好文案,不会 500
2026-09-16 19:38:13 +08:00
xiaoxia 67a1ed6430 feat(#1894): 清理文案库多余字段 / 废弃标题库 API / 旧定价档位清理 (#1968)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m3s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 3m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m6s
CI/CD Pipeline / Validate - Security (push) Successful in 7m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
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 / CI Gate (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 / Staging API Integration Tests (push) Successful in 35m54s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-16 18:58:04 +08:00
frontend-dev ae733312db fix(e2e): 修复 step3→step4 导航 — 确认生成按钮在 step4 不在 step3 (#1969)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m5s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m10s
CI/CD Pipeline / Integration Tests (push) Successful in 4m36s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m32s
CI/CD Pipeline / Validate - Style (push) Successful in 5m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m41s
CI/CD Pipeline / Validate - Security (push) Successful in 7m53s
CI/CD Pipeline / Unit Tests (push) Successful in 10m32s
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 / CI Gate (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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-16 18:38:12 +08:00
frontend-dev 43a584d041 fix(test): 修复 CI 三个失败项 — navigation mock + subscription mock + E2E 渲染等待 (#1967)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (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 / Build Staging Web Image (push) Successful in 59s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m56s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Integration Tests (push) Successful in 3m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m56s
CI/CD Pipeline / Validate - Style (push) Successful in 4m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m35s
CI/CD Pipeline / Validate - Security (push) Successful in 10m15s
CI/CD Pipeline / Unit Tests (push) Successful in 11m4s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 26m28s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
2026-09-16 17:47:50 +08:00
xiaoxia c7662f0515 fix(ci): fix worker HEALTHCHECK quoting bug (#1966)
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m51s
CI/CD Pipeline / Integration Tests (push) Successful in 4m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Validate - Style (push) Successful in 5m41s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Validate - Security (push) Successful in 12m13s
CI/CD Pipeline / Unit Tests (push) Successful in 13m14s
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 / CI Gate (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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 9m22s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-16 17:31:15 +08:00
35 changed files with 1155 additions and 1081 deletions
+8
View File
@@ -1288,6 +1288,14 @@ jobs:
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/.env"
echo "✅ .env uploaded to staging server"
# 上传抖音 cookies 文件到 staging host(供容器挂载)
echo "Uploading Douyin cookies to staging server..."
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
"mkdir -p /var/lib/xiaoxia-saas-staging/configs"
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no deploy/configs/douyin_cookies.txt \
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt"
echo "✅ Douyin cookies uploaded"
# 通过环境变量传递凭证,避免命令行引号转义问题
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
@@ -0,0 +1,33 @@
"""#1894: drop obsolete script title fields (title_text/title_category/title_config)
Revision ID: 078_drop_script_title_fields
Revises: 077_merge_title_libs
Create Date: 2026-09-16
口播文案(scripts)不再自带配套标题、标题分类和标题样式字段。
智能剪辑 / AI 数字人等生成场景各自通过入参配置标题,不再从文案读取。
保留字段:title(名称)、content(正文)、segments(分段)、tags(标签)。
"""
import sqlalchemy as sa
from alembic import op
revision = "078_drop_script_title_fields"
down_revision = "077_merge_title_libs"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.drop_column("title_config")
batch.drop_column("title_category")
batch.drop_column("title_text")
def downgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.add_column(sa.Column("title_text", sa.String(500), nullable=False, server_default=""))
batch.add_column(sa.Column("title_category", sa.String(50), nullable=False, server_default=""))
batch.add_column(sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"))
+17 -2
View File
@@ -25,12 +25,27 @@ def check_project_access(project_id: str, user_id: str, project_repository) -> N
raise HTTPException(status_code=403, detail="无权访问该项目")
_LEGACY_PLANS = {"standard", "pro", "enterprise", "basic", "premium"}
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
"""获取用户的订阅计划名称。"""
"""获取用户的会员类型,兼容旧档位值。
旧档位 standard/pro/enterprise/basic/premium 统一映射到当前体系:
- standard/basic → monthly
- pro/premium/enterprise → quarterly
"""
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
plan = getattr(user, "subscription_plan", "free") or "free"
if plan in {"standard", "basic"}:
return "monthly"
if plan in {"pro", "premium", "enterprise"}:
return "quarterly"
if plan not in {"free", "monthly", "quarterly", "yearly"}:
return "free"
return plan
def require_project_and_library(
-9
View File
@@ -36,9 +36,6 @@ def _to_response(script) -> ScriptResponse:
for s in segments
],
tags=script.tags or [],
title_text=getattr(script, "title_text", "") or "",
title_category=getattr(script, "title_category", "") or "",
title_config=getattr(script, "title_config", None) or {},
created_at=script.created_at,
updated_at=script.updated_at,
)
@@ -73,9 +70,6 @@ def create_script(
content=request.content,
segments=[s.model_dump() for s in request.segments],
tags=request.tags,
title_text=request.title_text or "",
title_category=request.title_category or "",
title_config=request.title_config or {},
)
return _to_response(script)
@@ -110,9 +104,6 @@ def update_script(
content=request.content,
segments=[s.model_dump() for s in request.segments] if request.segments is not None else None,
tags=request.tags,
title_text=request.title_text,
title_category=request.title_category,
title_config=request.title_config,
)
except ScriptNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
+124 -6
View File
@@ -12,6 +12,12 @@ import logging
import os
import re
import tempfile
from contextvars import ContextVar
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
# 抖音提取调试信息(单次请求上下文,用于排查 yt-dlp/cookies 问题)
_douyin_debug: ContextVar[dict] = ContextVar("_douyin_debug", default={})
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
@@ -38,6 +44,72 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# 抖音 cookies 文件路径(Netscape 格式),由环境变量 DOUYIN_COOKIES_FILE 覆盖
# 默认路径与 deploy/configs/douyin_cookies.txt 对应(容器内挂载到 /app/configs/
DOUYIN_COOKIES_FILE = os.environ.get(
"DOUYIN_COOKIES_FILE",
"/app/configs/douyin_cookies.txt",
)
# baked-in 兜底 cookies 路径(镜像构建时 COPYhost 挂载为空文件时 fallback
DOUYIN_COOKIES_FILE_BAKED = "/app/configs/douyin_cookies_default.txt"
# cookies 失效/需要刷新的错误关键词
_COOKIES_ERROR_KEYWORDS = (
"fresh cookies",
"cookies (not necessarily logged in)",
"cookies are needed",
"need cookies",
"cookie is expired",
"login required",
"sign in to continue",
"未登录",
"需要登录",
"cookies过期",
)
def _resolve_cookies_file() -> str | None:
"""返回有效的 cookies 文件路径:host 挂载优先 > baked-in 兜底 > None."""
for p in (DOUYIN_COOKIES_FILE, DOUYIN_COOKIES_FILE_BAKED):
try:
if p and os.path.isfile(p) and os.path.getsize(p) > 200:
return p
except OSError:
continue
return None
def _cookies_file_exists() -> bool:
return _resolve_cookies_file() is not None
def _dbg(key: str, val: str) -> None:
"""记录抖音调试信息(会被中间件写入 X-Douyin-Debug-* 响应头)。"""
try:
_douyin_debug.get()[key] = str(val)[:200]
except Exception:
pass
def _is_cookies_related_error(msg: str) -> bool:
"""判断 yt-dlp 的错误是否与 cookies 缺失/过期有关."""
low = msg.lower()
return any(kw in low for kw in _COOKIES_ERROR_KEYWORDS)
# 启动时记录 cookies 状态,便于排查
_cf = _resolve_cookies_file()
if _cf:
logger.info("抖音 cookies 文件已加载: %s (%d bytes)", _cf, os.path.getsize(_cf))
else:
logger.warning(
"抖音 cookies 文件未找到或无效: path=%s baked=%s 抖音提取功能可能因 cookies 缺失失败",
DOUYIN_COOKIES_FILE, DOUYIN_COOKIES_FILE_BAKED,
)
# 是否在错误响应中暴露原始 yt-dlp 错误(仅 staging/dev 用于排查,生产默认 False)
_DOUYIN_DEBUG_ERRORS = os.environ.get("DOUYIN_DEBUG_ERRORS", "").lower() in ("1", "true", "yes")
# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/
_DOUYIN_URL_RE = re.compile(
r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$",
@@ -102,28 +174,74 @@ def extract_from_douyin(
"quiet": True,
"no_warnings": True,
"noplaylist": True,
# 抖音反爬严格,必须用真实桌面浏览器 UA
"http_headers": {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/128.0.0.0 Safari/537.36"
),
"Referer": "https://www.douyin.com/",
},
}
# 如果存在抖音 cookies 文件,传给 yt-dlp 绕过反爬(host 挂载优先,空文件 fallback 到镜像内)
_cookies_path = _resolve_cookies_file()
if _cookies_path:
ydl_opts["cookiefile"] = _cookies_path
logger.debug("使用抖音 cookies 文件: %s (%d bytes)", _cookies_path, os.path.getsize(_cookies_path))
_dbg("cookies", f"{_cookies_path} {os.path.getsize(_cookies_path)}B")
try:
ydl = yt_dlp.YoutubeDL(ydl_opts)
info = ydl.extract_info(url_for_download, download=True)
except yt_dlp.utils.DownloadError as exc:
# yt-dlp 官方异常类型:HTTP 错误、短链失效、视频下架等
# yt-dlp 官方异常类型:HTTP 错误、短链失效、视频下架、cookies 过期
msg = str(exc)
logger.warning("抖音下载失败: url=%s error=%s", source_url, msg)
# 404/视频不存在/不可下载 → 400;网络问题/上游异常 → 502
_dbg("errtype", "DownloadError")
_dbg("errmsg", msg)
# 404/视频不存在/不可下载 → 400
is_bad_url = any(
kw in msg.lower() for kw in ("404", "not found", "unable to download webpage", "unsupported url", "no video formats")
kw in msg.lower() for kw in ("404", "not found", "unable to download webpage", "unsupported url", "no video formats", "video unavailable", "this video isn't available")
)
# cookies 缺失/过期 → 返回友好提示,不暴露 yt-dlp 原始错误
if _is_cookies_related_error(msg):
logger.error("抖音 cookies 失效或缺失,需要刷新: %s", msg[:300])
_detail = "抖音链接解析暂时不可用,请稍后重试或手动输入文案"
if _DOUYIN_DEBUG_ERRORS:
_detail = f"{_detail} [debug: {msg[:300]}]"
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=_detail,
) from exc
_detail = "无法解析该抖音链接,请确认链接有效且视频未被下架" if is_bad_url else "视频下载失败,请稍后重试"
if _DOUYIN_DEBUG_ERRORS:
_detail = f"{_detail} [debug: {msg[:300]}]"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST if is_bad_url else status.HTTP_502_BAD_GATEWAY,
detail=("无法解析该抖音链接,请确认链接有效且视频未被下架" if is_bad_url else f"视频下载失败: {msg[:200]}"),
detail=_detail,
) from exc
except Exception as exc:
logger.exception("抖音视频下载异常: url=%s", source_url)
msg = str(exc)
logger.exception("抖音视频下载异常: url=%s error=%s", source_url, msg)
_dbg("errtype", type(exc).__name__)
_dbg("errmsg", msg)
# cookies 相关的未知异常也走友好提示
if _is_cookies_related_error(msg):
_detail = "抖音链接解析暂时不可用,请稍后重试或手动输入文案"
if _DOUYIN_DEBUG_ERRORS:
_detail = f"{_detail} [debug: {msg[:300]}]"
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=_detail,
) from exc
_detail = "视频下载失败,请稍后重试"
if _DOUYIN_DEBUG_ERRORS:
_detail = f"{_detail} [debug: {msg[:300]}]"
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"视频下载失败: {str(exc)[:200]}",
detail=_detail,
) from exc
if info is None:
+54 -70
View File
@@ -10,9 +10,11 @@ from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
from app.schemas.subscription import (
BillingCycle,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
MembershipType,
SimpleResponse,
SubscriptionInfo,
ToggleAutoRenewRequest,
@@ -26,43 +28,18 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ============ 配额定义(硬编码,后续可迁移到配置中心) ============
# ============ 会员展示名称(与 packages.domain.points_rules.MEMBERSHIP_PRICES 对应)============
PLAN_QUOTAS = {
"free": {"max_projects": 3, "max_storage_gb": 10},
"standard": {"max_projects": 10, "max_storage_gb": 50},
"pro": {"max_projects": -1, "max_storage_gb": 100},
"enterprise": {"max_projects": -1, "max_storage_gb": 1000},
_PLAN_NAMES: dict[str, str] = {
MembershipType.FREE: "免费用户",
MembershipType.MONTHLY: "月卡会员",
MembershipType.QUARTERLY: "季卡会员",
MembershipType.YEARLY: "年卡会员",
}
# ============ Helper Functions ============
def _get_plan_name(plan_id: str) -> str:
"""获取套餐显示名称"""
plan_names = {
"free": "体验版",
"standard": "标准版",
"pro": "专业版",
"enterprise": "企业版",
}
return plan_names.get(plan_id, "未知套餐")
def _get_plan_price(plan_id: str, billing_cycle: str) -> float:
"""获取套餐价格"""
prices = {
("free", "monthly"): 0,
("free", "yearly"): 0,
("standard", "monthly"): 99,
("standard", "yearly"): 999,
("pro", "monthly"): 299,
("pro", "yearly"): 2999,
("enterprise", "monthly"): 999,
("enterprise", "yearly"): 9999,
}
return prices.get((plan_id, billing_cycle), 0)
return _PLAN_NAMES.get(plan_id, "免费用户")
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
@@ -75,15 +52,20 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
period_start = now.isoformat()
period_end = now.isoformat()
plan_id = user.user.subscription_plan or MembershipType.FREE
# 旧档位(standard/pro/enterprise)统一降级为 monthly,避免前端炸掉
if plan_id in {"standard", "pro", "enterprise"}:
plan_id = MembershipType.MONTHLY
return SubscriptionInfo(
id=f"sub-{user.user.id[:8]}",
plan_id=user.user.subscription_plan or "free",
plan_name=_get_plan_name(user.user.subscription_plan or "free"),
plan_id=plan_id,
plan_name=_get_plan_name(plan_id),
status=user.user.subscription_status or "active",
billing_cycle="monthly",
billing_cycle=plan_id if plan_id != MembershipType.FREE else BillingCycle.MONTHLY,
current_period_start=period_start,
current_period_end=period_end,
amount=_get_plan_price(user.user.subscription_plan or "free", "monthly"),
amount=0 if plan_id == MembershipType.FREE else 0, # 金额由前端 /plans 接口展示
auto_renew=True,
created_at=user.user.created_at.isoformat() if user.user.created_at else now.isoformat(),
)
@@ -115,11 +97,11 @@ def list_membership_plans(
days = info["duration_days"]
monthly_cents = round(info["price_cents"] * 30 / days)
features: dict[str, Any] = {"max_resolution": "1080p"}
if plan_id == "monthly":
if plan_id == MembershipType.MONTHLY:
features.update({"free_clips_daily": 2})
elif plan_id == "quarterly":
elif plan_id == MembershipType.QUARTERLY:
features.update({"free_clips_daily": 5})
elif plan_id == "yearly":
elif plan_id == MembershipType.YEARLY:
features.update({"free_clips_daily": "unlimited"})
plans.append({
"plan_id": plan_id,
@@ -151,7 +133,7 @@ async def get_billing_records(
return [
BillingRecord(
id=r.id,
plan_name=r.plan_name,
plan_name=_get_plan_name(r.plan_name),
amount=r.amount,
billing_cycle=r.billing_cycle,
status=r.status,
@@ -165,6 +147,10 @@ async def get_billing_records(
session.close()
_VALID_PLANS = {MembershipType.MONTHLY, MembershipType.QUARTERLY, MembershipType.YEARLY}
_VALID_CYCLES = {BillingCycle.MONTHLY, BillingCycle.QUARTERLY, BillingCycle.YEARLY}
@router.post("/change-plan", response_model=ChangePlanResponse)
async def change_plan(
request: ChangePlanRequest,
@@ -173,47 +159,45 @@ async def change_plan(
) -> ChangePlanResponse:
"""变更订阅套餐(升级/降级)"""
# TODO: 接入支付验证(支付宝/微信支付)
valid_plans = {"free", "standard", "pro", "enterprise"}
if request.target_plan_id not in valid_plans:
target_plan = request.target_plan_id
if target_plan not in _VALID_PLANS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的套餐ID。支持的套餐: {', '.join(valid_plans)}",
detail=f"无效的会员类型。支持: {', '.join(sorted(_VALID_PLANS))}",
)
valid_cycles = {"monthly", "yearly"}
if request.billing_cycle not in valid_cycles:
if request.billing_cycle not in _VALID_CYCLES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的计费周期。支持: monthly, yearly",
detail=f"无效的计费周期。支持: {', '.join(sorted(_VALID_CYCLES))}",
)
user = current_user.user
current_plan = user.subscription_plan or "free"
target_plan = request.target_plan_id
current_plan = user.subscription_plan or MembershipType.FREE
# 旧档位归一化,避免永远显示"您已经是xxx"
if current_plan in {"standard", "pro", "enterprise"}:
current_plan = MembershipType.MONTHLY
if current_plan == target_plan:
return ChangePlanResponse(
success=False,
message=f"您已经是 {_get_plan_name(target_plan)}",
message=f"您已经是{_get_plan_name(target_plan)}",
)
# 通过 dataclasses.replace 创建新实例(不直接修改 dataclass)
quotas = PLAN_QUOTAS.get(target_plan, PLAN_QUOTAS["free"])
updated_user = replace(
user,
subscription_plan=target_plan,
subscription_status="active",
max_projects=quotas["max_projects"],
max_storage_gb=quotas["max_storage_gb"],
max_projects=-1, # 付费会员不限项目数
max_storage_gb=100,
)
user_repository.save(updated_user)
# 用更新后的用户构造响应
refreshed_auth_user = AuthenticatedUser(user=updated_user)
return ChangePlanResponse(
success=True,
message=f"套餐已成功变更为 {_get_plan_name(target_plan)}",
message=f"套餐已成功变更为{_get_plan_name(target_plan)}",
new_subscription=_build_subscription_info(refreshed_auth_user),
)
@@ -225,10 +209,11 @@ async def cancel_subscription(
) -> SimpleResponse:
"""取消订阅"""
user = current_user.user
if user.subscription_plan == "free":
plan_id = user.subscription_plan or MembershipType.FREE
if plan_id == MembershipType.FREE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="体验版无需取消",
detail="免费用户无需取消订阅",
)
updated_user = replace(user, subscription_status="cancelled")
@@ -236,7 +221,7 @@ async def cancel_subscription(
return SimpleResponse(
success=True,
message="订阅已取消,当前周期结束后停止服务",
message="订阅已取消,当前周期结束后将降级为免费用户",
)
@@ -262,11 +247,14 @@ async def payment_callback(
if SessionLocal is None:
raise HTTPException(status_code=500, detail="Database not available")
# 仅接受当前会员体系的 plan 值
if plan not in _VALID_PLANS:
raise HTTPException(status_code=400, detail=f"未知的会员类型: {plan}")
session = SessionLocal()
try:
repo = SQLAlchemyBillingRepository(session)
# 创建账单记录
record_id = uuid.uuid4().hex
repo.create(
{
@@ -279,19 +267,20 @@ async def payment_callback(
}
)
# 在事务中标记支付成功并更新订阅
repo.mark_paid(record_id, payment_method, payment_id)
# 计算到期时间
days = 365 if billing_cycle == "yearly" else 30
days_map = {BillingCycle.MONTHLY: 30, BillingCycle.QUARTERLY: 90, BillingCycle.YEARLY: 365}
days = days_map.get(billing_cycle, 30)
expires_at = datetime.now(UTC) + timedelta(days=days)
repo.update_subscription_on_payment(user_id, plan, expires_at)
return {"success": True, "message": "支付成功", "record_id": record_id}
except HTTPException:
session.rollback()
raise
except Exception as e:
session.rollback()
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
# 不返回原始异常信息,避免泄漏内部实现细节
logger.error("支付回调处理失败: user_id=%s, plan=%s, error=%s", user_id, plan, e)
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
finally:
session.close()
@@ -303,10 +292,5 @@ async def toggle_auto_renew(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> SimpleResponse:
"""切换自动续费"""
# TODO: 实际需要在数据库中存储 auto_renew 字段
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
return SimpleResponse(
success=True,
message=status_text,
)
return SimpleResponse(success=True, message=status_text)
+20 -228
View File
@@ -1,243 +1,35 @@
"""Title library CRUD routes.
"""Title library routes — DEPRECATED (#1894).
.. deprecated::
标题库 API 已废弃(#1894),标题配置已整合到 scripts 模型
所有接口保留向后兼容,但返回 Warning header 并记录日志。
独立标题库已废弃。前端应直接调用 GET /api/v1/scripts 获取文案列表,
取每条文案的 `title` 字段作为标题候选
所有 /api/v1/titles 端点统一返回 HTTP 410 Gone。
"""
from __future__ import annotations
import logging
from typing import Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.title_library import (
CreateTitleLibraryRequest,
ListTitleLibraryResponse,
TitleLibraryItemResponse,
UpdateTitleLibraryRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
from packages.application.title_library.commands import (
CreateTitleLibraryCommand,
PickTitleCommand,
UpdateTitleLibraryCommand,
)
from packages.application.title_library.use_cases import (
CreateTitleLibraryUseCase,
DeleteTitleLibraryUseCase,
GetTitleLibraryUseCase,
ListTitleLibraryUseCase,
NotFoundError,
PickTitleUseCase,
QuotaExceededError,
UpdateTitleLibraryUseCase,
)
from packages.ports.user_repository import UserRepository
from fastapi import APIRouter, Response, status
router = APIRouter()
logger = logging.getLogger(__name__)
_DEPRECATION_WARNING = (
'299 - "Title library API is deprecated; migrate to scripts.title_text/'
'title_category/title_config (issue #1894)"'
_GONE_MESSAGE = (
"标题库 API 已废弃(#1894):独立标题库已合并进文案库,"
"请使用 GET /api/v1/scripts 获取文案列表并取 title 字段作为标题。"
)
def _deprecation_headers() -> dict:
"""返回 deprecation Warning header (ASCII-only, RFC 7234 §5.5)."""
return {"Warning": _DEPRECATION_WARNING, "Deprecation": "true"}
def _gone(response: Response) -> dict:
response.status_code = status.HTTP_410_GONE
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Tue, 16 Sep 2026 00:00:00 GMT"
return {"error": {"code": "GONE", "message": _GONE_MESSAGE}}
def _log_deprecation(endpoint: str) -> None:
logger.warning("[Deprecated] title_library API 调用: %s%s", endpoint, _DEPRECATION_WARNING)
@router.api_route("", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def titles_root_gone(response: Response) -> dict:
return _gone(response)
def _get_title_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTitleLibraryRepository:
return SQLAlchemyTitleLibraryRepository(session)
def _to_response(item) -> TitleLibraryItemResponse:
return TitleLibraryItemResponse(
id=item.id,
user_id=item.user_id,
name=item.name,
text=item.text,
category=item.category,
description=item.description,
tags=item.tags,
usage_count=item.usage_count,
is_active=item.is_active,
created_at=item.created_at,
updated_at=item.updated_at,
)
@router.get("", response_model=ListTitleLibraryResponse)
def list_titles(
response: Response,
category: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> ListTitleLibraryResponse:
"""[Deprecated] 请使用 scripts API 的 title_text/title_category 字段替代."""
_log_deprecation("list_titles")
for k, v in _deprecation_headers().items():
response.headers[k] = v
user_id = authenticated_user.user.id
use_case = ListTitleLibraryUseCase(title_repository)
items = use_case.execute(user_id, category=category, skip=skip, limit=limit)
total = title_repository.count_by_user(user_id)
return ListTitleLibraryResponse(
items=[_to_response(i) for i in items],
total=total,
)
@router.post("/pick", response_model=TitleLibraryItemResponse)
def pick_title(
response: Response,
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
exclude_ids: Optional[str] = Query(
None,
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
"""[Deprecated] 请使用 scripts API 的 title_text/title_category 字段替代.
智能选择一个标题。
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
"""
_log_deprecation("pick_title")
for k, v in _deprecation_headers().items():
response.headers[k] = v
user_id = authenticated_user.user.id
exclude_list: list[str] = []
if exclude_ids:
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
use_case = PickTitleUseCase(title_repository)
item = use_case.execute(
PickTitleCommand(
user_id=user_id,
category=category,
exclude_ids=exclude_list,
)
)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="标题库为空,请先添加标题",
)
return _to_response(item)
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
def get_title(
title_id: str,
response: Response,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
"""[Deprecated] 请使用 scripts API 的 title_text/title_category 字段替代."""
_log_deprecation("get_title")
for k, v in _deprecation_headers().items():
response.headers[k] = v
user_id = authenticated_user.user.id
use_case = GetTitleLibraryUseCase(title_repository)
item = use_case.execute(title_id, user_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item)
@router.post("", response_model=TitleLibraryItemResponse, status_code=status.HTTP_201_CREATED)
def create_title(
response: Response,
request: CreateTitleLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> TitleLibraryItemResponse:
"""[Deprecated] 请使用 scripts API 的 title_text/title_category 字段替代."""
_log_deprecation("create_title")
for k, v in _deprecation_headers().items():
response.headers[k] = v
user_id = authenticated_user.user.id
plan_name = get_user_plan(user_id, user_repository)
command = CreateTitleLibraryCommand(
user_id=user_id,
name=request.name,
text=request.text,
category=request.category,
description=request.description,
tags=request.tags,
)
use_case = CreateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command, plan_name=plan_name)
except QuotaExceededError as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc
return _to_response(item)
@router.put("/{title_id}", response_model=TitleLibraryItemResponse)
def update_title(
title_id: str,
response: Response,
request: UpdateTitleLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> TitleLibraryItemResponse:
"""[Deprecated] 请使用 scripts API 的 title_text/title_category 字段替代."""
_log_deprecation("update_title")
for k, v in _deprecation_headers().items():
response.headers[k] = v
user_id = authenticated_user.user.id
command = UpdateTitleLibraryCommand(
title_id=title_id,
user_id=user_id,
name=request.name,
text=request.text,
category=request.category,
description=request.description,
tags=request.tags,
)
use_case = UpdateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
return _to_response(item)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_title(
title_id: str,
response: Response,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
) -> Response:
"""[Deprecated] 请使用 scripts API 的 title_text/title_category 字段替代."""
_log_deprecation("delete_title")
for k, v in _deprecation_headers().items():
response.headers[k] = v
user_id = authenticated_user.user.id
use_case = DeleteTitleLibraryUseCase(title_repository)
deleted = use_case.execute(title_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def titles_subpath_gone(response: Response, path: str) -> dict:
return _gone(response)
+1 -10
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional
from typing import Optional
from pydantic import BaseModel, Field
@@ -22,9 +22,6 @@ class ScriptResponse(BaseModel):
content: str
segments: list[ScriptSegment] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
title_text: str = ""
title_category: str = ""
title_config: Dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
@@ -39,9 +36,6 @@ class CreateScriptRequest(BaseModel):
content: str = ""
segments: list[ScriptSegment] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
title_text: str = ""
title_category: str = ""
title_config: Optional[Dict[str, Any]] = None
class UpdateScriptRequest(BaseModel):
@@ -49,6 +43,3 @@ class UpdateScriptRequest(BaseModel):
content: Optional[str] = None
segments: Optional[list[ScriptSegment]] = None
tags: Optional[list[str]] = None
title_text: Optional[str] = None
title_category: Optional[str] = None
title_config: Optional[Dict[str, Any]] = None
+14 -7
View File
@@ -7,15 +7,21 @@ from typing import Optional
from pydantic import BaseModel, Field
# ============ Enums / Types ============
# 会员体系(#1951/#1955 实装):
# free — 免费用户
# monthly — 月卡
# quarterly — 季卡
# yearly — 年卡
# 已废弃档位:standard / pro / enterprise(保留常量名便于识别旧字段,但不在 API 中暴露)
class PlanType(str):
"""套餐类型"""
class MembershipType(str):
"""会员类型(与 packages.domain.points_rules.MEMBERSHIP_PRICES 一致)"""
FREE = "free"
STANDARD = "standard"
PRO = "pro"
ENTERPRISE = "enterprise"
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
class SubscriptionStatus(str):
@@ -40,6 +46,7 @@ class BillingCycle(str):
"""计费周期"""
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
@@ -95,8 +102,8 @@ class SimpleResponse(BaseModel):
class ChangePlanRequest(BaseModel):
"""升级/降级请求"""
target_plan_id: str = Field(..., description="目标套餐ID")
billing_cycle: str = Field(..., description="计费周期: monthly/yearly")
target_plan_id: str = Field(..., description="目标会员类型: monthly/quarterly/yearly")
billing_cycle: str = Field(..., description="计费周期: monthly/quarterly/yearly")
class ToggleAutoRenewRequest(BaseModel):
-25
View File
@@ -51,9 +51,6 @@ class ScriptService:
content: str = "",
segments: list | None = None,
tags: list | None = None,
title_text: str = "",
title_category: str = "",
title_config: dict | None = None,
) -> ScriptModel:
script = ScriptModel(
id=str(uuid.uuid4()),
@@ -62,9 +59,6 @@ class ScriptService:
content=content,
segments=segments if segments is not None else [],
tags=tags if tags is not None else [],
title_text=title_text or "",
title_category=title_category or "",
title_config=title_config if title_config is not None else {},
)
self.db.add(script)
self.db.commit()
@@ -89,9 +83,6 @@ class ScriptService:
content: Optional[str] = None,
segments: Optional[list] = None,
tags: Optional[list] = None,
title_text: Optional[str] = None,
title_category: Optional[str] = None,
title_config: Optional[dict] = None,
) -> ScriptModel:
script = self.get_script(script_id, user_id)
if title is not None:
@@ -102,27 +93,11 @@ class ScriptService:
script.segments = segments
if tags is not None:
script.tags = tags
if title_text is not None:
script.title_text = title_text
if title_category is not None:
script.title_category = title_category
if title_config is not None:
script.title_config = title_config
script.updated_at = datetime.now(UTC)
self.db.commit()
self.db.refresh(script)
return script
# ── title config ─────────────────────────────────────────────────────
def get_title_config_for_script(self, script_id: str, user_id: str) -> dict:
"""从 script 读取标题配置,返回可直接用于渲染的 title_config dict."""
script = self.get_script(script_id, user_id)
config = dict(script.title_config or {})
if not config.get("text") and script.title_text:
config["text"] = script.title_text
return config
# ── delete ────────────────────────────────────────────────────────────
def delete_script(self, script_id: str, user_id: str) -> bool:
+45 -80
View File
@@ -49,10 +49,10 @@ type AssetListResponse = {
}
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 600_000 })
test.describe.configure({ timeout: 360_000 })
test("walks through wizard with count modal and starts generation", async ({ page, request }) => {
test.setTimeout(600_000)
test.setTimeout(360_000)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
@@ -125,8 +125,7 @@ test.describe("Core generation flow", () => {
)
.toBe("ready")
// #1926 P0 fix: POST /templates CRUD endpoint removed; GET /templates
// now auto-creates a default template for new users. Use the first one.
// GET /templates auto-creates a default template for new users
const templatesResp = await request.get(`${apiBase}/templates`, { headers })
expect(templatesResp.status(), await templatesResp.text()).toBe(200)
const templatesData = (await templatesResp.json()) as {
@@ -169,136 +168,103 @@ test.describe("Core generation flow", () => {
timeout: 20_000,
})
// 5步向导:素材→数量弹窗→配音→标题→确认生成→封面(#1911 删除选模板步骤,后端自动使用默认模板;
// #1677 批量生成在选完素材后弹「要生成几个视频?」数量弹窗,默认1,回车确认)
// Step 1: select material (card grid UI)
// 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5)
// ── Step 1: 素材选择 ──
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// #1677 数量弹窗:默认值1,点击「生成 1 个视频」确认(新用户单视频冒烟路径)
// ── 数量弹窗(PreviewCountModal ──
await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({
timeout: 5_000,
})
await page.getByRole("button", { name: "生成 1 个视频" }).click()
// Step 2: voice(新注册用户无配音素材时展示空状态 h3「🎙️ 选择配音」,仍可点「下一步」跳过)
// ── Step 2: 配音(新注册用户无配音素材,跳过) ──
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 3: title(新顺序:标题在预览之前)
// ── Step 3: 标题设置 ──
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
// 等待组件完全渲染
await page.waitForTimeout(2000)
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
// 使用 Antd AutoComplete 特有的 class 定位输入框
const titleInput = page.locator(".ant-select-auto-complete input")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`E2E Test ${suffix}`)
const titleText = `E2E Test ${suffix}`
await titleInput.fill(titleText)
// Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成)
await page.getByRole("button", { name: "下一步" }).click()
// 步骤3(标题页)底部操作栏按钮是「下一步 →」,点击后进入步骤4
// 步骤4底部才是「✨ 确认生成视频」按钮
const nextBtn = page.locator(".xx-step-actions .xx-btn-primary").filter({ hasText: "下一步" })
await expect(nextBtn).toBeVisible({ timeout: 15_000 })
await nextBtn.click()
// Step 4:「确认生成」页面——此处底部是「✨ 确认生成视频」按钮
// 注意:Step4 主内容区是实时预览画布,没有 h3 「🎬 确认生成」标题,标题由顶部步骤条展示
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位,
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning
// ── Step 4: 确认生成 ──
// 等待实时预览就绪(占位消失)
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
// 定位底部操作栏的「✨ 确认生成视频」按钮
// 使用底部操作栏 xx-step-actions 作用域,避免命中其他 primary 按钮
const confirmBtn = page
.locator(".xx-step-actions .xx-btn-primary")
.filter({ hasText: "确认生成" })
await expect(confirmBtn).toBeVisible({ timeout: 30_000 })
await expect(confirmBtn).toBeEnabled({ timeout: 30_000 })
// Step 4 底部是「✨ 确认生成视频」
const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first()
await expect(confirmBtn).toBeVisible({ timeout: 15_000 })
// Wait for generation API to be called — 先挂监听再点击,避免竞态
// 先挂 API 监听再点击
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks")
},
{ timeout: 60_000 },
{ timeout: 30_000 },
)
await confirmBtn.click()
// Verify generation was triggered
const genResp = await generatePromise
if (!genResp.ok()) {
const body = await genResp.text()
console.error(
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
)
}
// Generate API may return 400 in test env if template has no ready segments
// That is OK for a wizard flow smoke test
if (genResp.ok()) {
// 验证生成 API 被调用
const genResp = await generatePromise.catch(() => null)
if (!genResp) {
// staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过
console.log("[E2E] Generation API not triggered (preview not ready) — wizard navigation verified")
} else if (genResp.ok()) {
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
// 单视频(N=1):点击「确认生成视频」后跳步骤 5「确认生成」进度页,展示进度卡
// 注意:进度页底部按钮变为 disabled 的「⏳ 视频渲染中…」
await expect(page.getByText("视频渲染中")).toBeVisible({ timeout: 30_000 })
// race:渲染完成 vs 生成失败/超时
const downloadReady = page
.getByText("视频生成完成")
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "completed" : null))
const generationFailed = page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 180_000 })
.then((v) => (v ? "failed" : null))
// 等待渲染完成:单视频成片播放器渲染(带「⬇️ 下载」按钮),最长等待 3 分钟
// 注意:message.success「视频生成完成」toast 3秒后自动消失,不能作为稳定断言点
await expect(page.getByRole("button", { name: "⬇️ 下载" })).toBeVisible({ timeout: 420_000 })
const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout")
// #1954 修复:生成完成后步骤4底部应显示「下一步:选择封面」按钮
// 等待底部主按钮从「⏳/确认生成」切换为「下一步:选择封面」
const nextCoverBtn = page
.locator(".xx-step-actions > .xx-btn-primary")
.filter({ hasText: "选择封面" })
await expect(nextCoverBtn).toBeVisible({ timeout: 15_000 })
await nextCoverBtn.click()
// 断言进入步骤5封面页:主内容出现「选择封面」标题
await expect(page.getByText("🖼️ 选择封面")).toBeVisible({ timeout: 10_000 })
// 底部操作栏主按钮应消失(封面是最后一步,只剩「← 上一步」)
await expect(page.locator(".xx-step-actions > .xx-btn-primary")).toHaveCount(0)
if (outcome === "completed") {
await page.getByRole("button", { name: /下一步:选择封面/ }).click()
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 30_000,
})
} else {
console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`)
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
await page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
}
// Verify product library page loads (smoke: just verify page renders)
// 验证成品库页面加载
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
// Verify page container exists = page rendered correctly
// (works in all states: loading/error/success - more reliable than checking search input)
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000,
})
await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 })
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" })
})
@@ -323,7 +289,6 @@ test.describe("Core generation flow", () => {
})
expect(project.status()).toBe(200)
// List generation tasks via task center API
const tasks = await request.get(`${apiBase}/tasks`, { headers })
expect(tasks.status()).toBe(200)
const tasksData = await tasks.json()
@@ -39,7 +39,6 @@ describe("navigation config", () => {
expect(keys).toContain("dashboard")
expect(keys).toContain("assets")
expect(keys).toContain("voices")
expect(keys).toContain("titles")
})
})
@@ -32,6 +32,7 @@ vi.mock("antd", () => ({
vi.mock("@/api/subscription", () => ({
getCurrentSubscription: vi.fn().mockResolvedValue({ plan: "free", status: "active" }),
getSubscriptionPlans: vi.fn().mockResolvedValue({ items: [{ plan_id: "free", name: "Free" }] }),
changePlan: vi.fn().mockResolvedValue({ success: true }),
toggleAutoRenew: vi.fn().mockResolvedValue({ success: true }),
cancelSubscription: vi.fn().mockResolvedValue({ success: true }),
+3
View File
@@ -240,3 +240,6 @@ DOUBAO_MAX_RETRIES=2
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
WECHAT_OPEN_REDIRECT_URI=https://saas.xiaoxiajianji.com/auth/wechat/callback
# 抖音 cookies 文件路径(yt-dlp 解析抖音视频需要)
DOUYIN_COOKIES_FILE=/app/configs/douyin_cookies.txt
+4
View File
@@ -257,3 +257,7 @@ DOUBAO_MAX_RETRIES=2
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
WECHAT_OPEN_REDIRECT_URI=https://staging.xiaoxiajianji.com/auth/wechat/callback
# 抖音 cookies 文件路径(yt-dlp 解析抖音视频需要)
DOUYIN_COOKIES_FILE=/app/configs/douyin_cookies.txt
DOUYIN_DEBUG_ERRORS=true
+31
View File
@@ -0,0 +1,31 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
www.douyin.com FALSE / FALSE 1789563076 __ac_nonce 06aaa89bc0030f2e745ee
www.douyin.com FALSE / TRUE 1821093727 __ac_signature _02B4Z6wo00f01N7Dw.wAAIDCSX73QWCHQXze88dAAF0gba
www.douyin.com FALSE / FALSE 0 x-web-secsdk-uid 2057aefa-d4e1-4435-b808-6c57b12827da
www.douyin.com FALSE / FALSE 1794741735 s_v_web_id verify_mu40gy2t_aNOL05Zf_3Ed8_4O7r_AiTI_zhyytepGuWQt
www.douyin.com FALSE / FALSE 0 device_web_cpu_core 8
www.douyin.com FALSE / FALSE 0 device_web_memory_size 8
www.douyin.com FALSE / FALSE 1790162535 dy_swidth 1440
www.douyin.com FALSE / FALSE 1790162535 dy_sheight 900
www.douyin.com FALSE / TRUE 1824117736 fpk1 U2FsdGVkX19r8KGSQ4TJmW6xJUE0CMCslk3+L41NOWIZmsZbn8+elM1+pG4Sp/falNlFmgjg9ilnk/VH3yTVvQ==
www.douyin.com FALSE / TRUE 1824117736 fpk2 821789b99f9168330b06379c53813800
.douyin.com TRUE / TRUE 1824117728 enter_pc_once 1
.douyin.com TRUE / TRUE 1824117728 UIFID_TEMP 913a61fe183c24375ca96eb5b8c5d09ceb48ab0f41eb35e0431732bd448493f4e84d43765750fe3ed0cde09ed99dc2cb4bfce9cd1a9c9297091ad327ac6989d0bf4c4c9ba413fbc7a8a8cb0d58998c61
.douyin.com TRUE / FALSE 0 is_support_rtm_web_ts 1
.douyin.com TRUE / FALSE 1790162535 IsDouyinActive true
.douyin.com TRUE / FALSE 1790162535 home_can_add_dy_2_desktop %220%22
.douyin.com TRUE / FALSE 1790162535 stream_recommend_feed_params %22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A1440%2C%5C%22screen_height%5C%22%3A900%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A8%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A9.9%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A0%7D%22
.douyin.com TRUE / FALSE 1790162536 strategyABtestKey %221789557736.225%22
.douyin.com TRUE / FALSE 1821093736 odin_tt d0e8e50b221addc5b110f1802cd016b3776364abe1da34defa2d154b2d6507496009e51b5d1b23ce6a16f8f154dee73429c5fb3e32ddec3f73b9eb5d844950dc237950d0ff491ec33a8ed4effc51acbc
.douyin.com TRUE / TRUE 1794741745 passport_csrf_token 00852dd9d975ead597921e0cc63ac9cf
.douyin.com TRUE / FALSE 1794741745 passport_csrf_token_default 00852dd9d975ead597921e0cc63ac9cf
.douyin.com TRUE / FALSE 1794741748 __security_mc_1_s_sdk_crypt_sdk 9431d69c-4e9a-8572
.douyin.com TRUE / FALSE 1794741748 bd_ticket_guard_regenerate_keys_time 2026-09-16/19:22:28
.douyin.com TRUE / FALSE 1794741748 bd_ticket_guard_client_data eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCR0FKWG5QWlg3T3dhdVFKeTF5MVZlZGZnYXh3MDdwRmovREhPa2VjaWErQWdzbE9ITUtvcmlYWUFHR05xZSttdlQyb3dHUjZ1TmV2R1luTGFhaUZ4T009IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D
.douyin.com TRUE / FALSE 1794741748 bd_ticket_guard_client_web_domain 2
.douyin.com TRUE / TRUE 1821093750 ttwid 1%7CnKnjpGZ_gV3wrJ9BbiIiZXCSoR5cXE-9oWeLTFYBang%7C1789557750%7C3b9f3308e5183af4d4af3916ce5a247c9c0b730c5c3c6b926d7deeb0c1b4af91
.douyin.com TRUE / FALSE 0 biz_trace_id b59ab86b
api.feelgood.cn FALSE / TRUE 1821093756 fg_uid RID202609161922340F54ABB788FBFBA43C13
.iesdouyin.com TRUE / FALSE 1820666323 ttwid 1%7C0gaxrZtAJbRdRvSlni5vTUsrYxejqAzXBpWLt8GhLtA%7C1789561275%7Cd58442a0c82d9a2f69a17b6e28838e948a0ccea705a12f58132f69b54ecece83
+10
View File
@@ -19,6 +19,16 @@ COPY alembic/ ./alembic/
COPY scripts/ ./scripts/
COPY packages/ ./packages/
COPY apps/api/ ./apps/api/
# 抖音 cookies 文件:镜像内 baked-in 兜底 + host 挂载可覆盖
# - /app/configs/douyin_cookies_default.txt: 镜像构建时 COPY 的兜底 cookies(始终有效)
# - /app/configs/douyin_cookies.txt: host volume 挂载点(部署脚本 scp 覆盖,过期需更新)
RUN mkdir -p /app/configs
COPY deploy/configs/douyin_cookies.txt /app/configs/douyin_cookies_default.txt
# 初始 COPY 一份到挂载点,host 挂载为空文件时 Python 代码会自动 fallback 到 default
COPY deploy/configs/douyin_cookies.txt /app/configs/douyin_cookies.txt
# 强制升级 yt-dlp 到最新(抖音反爬经常变更,旧版 cookies 支持失效;#1968/#1963
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com --upgrade "yt-dlp>=2026.8.19"
# 设置环境变量
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+2 -1
View File
@@ -67,9 +67,10 @@ services:
ports:
- "127.0.0.1:${API_PORT:-8000}:8000"
# 共享生成文件目录
# 共享生成文件目录 + 抖音 cookies 等运行时配置
volumes:
- generated-files:/app/generated
- ../../deploy/configs:/app/configs:ro
networks:
- xiaoxia-net
@@ -671,10 +671,6 @@ class ScriptModel(Base):
content = Column(Text, nullable=False, default="")
segments = Column(JSON, nullable=False, default=list)
tags = Column(JSON, nullable=False, default=list)
# #1894: 废弃标题库整合到文案库 — 标题配置字段
title_text = Column(String(500), nullable=False, default="")
title_category = Column(String(50), nullable=False, default="")
title_config = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
+40 -17
View File
@@ -1,13 +1,14 @@
"""Quota system with registry pattern.
Four subscription tiers with different limits:
- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 50 titles, 10 voiceovers, no AI voice
- basic: 20GB storage, 30 videos/month, 10 concurrent, 15 templates, 500 titles, 100 voiceovers, AI voice
- premium: 100GB storage, 100 videos/month, 20 concurrent, unlimited templates, 500 titles, 100 voiceovers, AI voice
- pro: Same as premium (alias for premium tier)
Member tiers (see packages.domain.points_rules.MEMBERSHIP_PRICES):
- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 10 voiceovers, no AI voice
- monthly: 月卡会员(同 basic 级别)
- quarterly: 季卡会员(同 premium 级别)
- yearly: 年卡会员(同 premium 级别,更多每日免费额度)
旧档位(standard/pro/enterprise/basic/premium)已在 #1894 清理,统一为 free/monthly/quarterly/yearly。
Quota dimensions are registered by modules via the ModuleRegistry,
and checked against the user's subscription plan.
and checked against the user's membership type.
"""
from __future__ import annotations
@@ -59,7 +60,6 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.VIDEOS_PER_MONTH: 5,
QuotaDimension.MAX_CONCURRENT: 3,
QuotaDimension.MAX_TEMPLATES: 3,
QuotaDimension.MAX_TITLES: 50,
QuotaDimension.MAX_VOICEOVERS: 10,
QuotaDimension.AI_VOICE_ENABLED: 0,
QuotaDimension.AI_VOICE_CREDITS: 0,
@@ -68,14 +68,14 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.DEDUP_REPORT_ENABLED: 0,
},
),
"basic": QuotaTier(
name="basic",
# 月卡会员:基础付费档(原 basic)
"monthly": QuotaTier(
name="monthly",
limits={
QuotaDimension.STORAGE_GB: 20,
QuotaDimension.VIDEOS_PER_MONTH: 30,
QuotaDimension.MAX_CONCURRENT: 10,
QuotaDimension.MAX_TEMPLATES: 15,
QuotaDimension.MAX_TITLES: 500,
QuotaDimension.MAX_VOICEOVERS: 100,
QuotaDimension.AI_VOICE_ENABLED: 1,
QuotaDimension.AI_VOICE_CREDITS: 100,
@@ -84,14 +84,14 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.DEDUP_REPORT_ENABLED: 0,
},
),
"premium": QuotaTier(
name="premium",
# 季卡会员:高级付费档(原 premium)
"quarterly": QuotaTier(
name="quarterly",
limits={
QuotaDimension.STORAGE_GB: 100,
QuotaDimension.VIDEOS_PER_MONTH: 100,
QuotaDimension.MAX_CONCURRENT: 20,
QuotaDimension.MAX_TEMPLATES: float("inf"), # 不限量
QuotaDimension.MAX_TITLES: 500,
QuotaDimension.MAX_TEMPLATES: float("inf"),
QuotaDimension.MAX_VOICEOVERS: 100,
QuotaDimension.AI_VOICE_ENABLED: 1,
QuotaDimension.AI_VOICE_CREDITS: 500,
@@ -100,9 +100,32 @@ QUOTA_TIERS: dict[str, QuotaTier] = {
QuotaDimension.DEDUP_REPORT_ENABLED: 1,
},
),
# 年卡会员:同季卡配额 + 每日不限免费条数(由前端/积分规则实现)
"yearly": QuotaTier(
name="yearly",
limits={
QuotaDimension.STORAGE_GB: 100,
QuotaDimension.VIDEOS_PER_MONTH: float("inf"),
QuotaDimension.MAX_CONCURRENT: 20,
QuotaDimension.MAX_TEMPLATES: float("inf"),
QuotaDimension.MAX_VOICEOVERS: 200,
QuotaDimension.AI_VOICE_ENABLED: 1,
QuotaDimension.AI_VOICE_CREDITS: 2000,
QuotaDimension.BATCH_EXPORT_ENABLED: 1,
QuotaDimension.MULTI_PLATFORM_ENABLED: 1,
QuotaDimension.DEDUP_REPORT_ENABLED: 1,
},
),
}
# pro 套餐与 premium 配额相同,使用别名引用避免重复维护
QUOTA_TIERS["pro"] = QUOTA_TIERS["premium"]
# #1894: 旧档位别名(basic/standard → monthly, premium/pro/enterprise → quarterly
# 历史 DB 数据、单测和内部模块可能仍在传旧 plan_name;这里保留别名保证配额查询不炸。
# 新代码请统一使用 free/monthly/quarterly/yearly。
QUOTA_TIERS["basic"] = QUOTA_TIERS["monthly"]
QUOTA_TIERS["standard"] = QUOTA_TIERS["monthly"]
QUOTA_TIERS["premium"] = QUOTA_TIERS["quarterly"]
QUOTA_TIERS["pro"] = QUOTA_TIERS["quarterly"]
QUOTA_TIERS["enterprise"] = QUOTA_TIERS["quarterly"]
class QuotaWarningLevel:
@@ -216,7 +239,7 @@ class QuotaChecker:
"""检查指定维度的配额使用情况
Args:
plan_name: 用户套餐等级 (free/basic/premium)
plan_name: 会员类型 (free/monthly/quarterly/yearly)
dimension: 配额维度
used: 当前已使用量
+1 -1
View File
@@ -19,4 +19,4 @@ numpy==1.26.4
opencv-python-headless==4.10.0.84
# yt-dlp: 抖音视频下载(#1893 文案提取)
yt-dlp>=2024.1.0
yt-dlp>=2026.8.19
+1 -1
View File
@@ -310,7 +310,7 @@ docker run -d \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+13 -2
View File
@@ -47,6 +47,7 @@ ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
NGINX_CONF_FILE="${NGINX_CONF_FILE:-/var/lib/xiaoxia-saas-staging/nginx-staging.conf}"
COOKIES_FILE="${COOKIES_FILE:-/var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
@@ -65,6 +66,14 @@ fi
echo "✅ .env file found: $ENV_FILE ($(wc -l < "$ENV_FILE") lines)"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
mkdir -p "$(dirname "$COOKIES_FILE")"
# 抖音 cookies 文件:CI workflow 已通过 scp 上传;如果不存在(非 CI 环境)则创建占位
if [ ! -f "$COOKIES_FILE" ] || [ "$(wc -c < "$COOKIES_FILE" 2>/dev/null || echo 0)" -lt 200 ]; then
printf '# Netscape HTTP Cookie File\n# 抖音 cookies 占位(CI 应通过 scp 上传真实 cookies\n' > "$COOKIES_FILE"
echo "WARNING: Douyin cookies not found or too small at $COOKIES_FILE (extraction will 503)"
else
echo "Douyin cookies ready: $COOKIES_FILE ($(wc -c < "$COOKIES_FILE") bytes)"
fi
# ── 写入 Staging Nginx 配置 ──
# 运行时覆盖 nginx 配置,确保 upstream 指向正确的 staging 网络
@@ -192,7 +201,7 @@ rollback() {
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
@@ -480,7 +489,9 @@ docker run -d \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-e DOUYIN_COOKIES_FILE=/app/configs/douyin_cookies.txt \
-v "$GENERATED_DIR:/app/generated" \
-v "$COOKIES_FILE:/app/configs/douyin_cookies.txt:ro" \
--restart unless-stopped \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
@@ -504,7 +515,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+1 -1
View File
@@ -305,7 +305,7 @@ docker run -d \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+1 -1
View File
@@ -201,7 +201,7 @@ docker run -d \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--health-cmd "grep -lq celery /proc/[0-9]*/cmdline 2>/dev/null || exit 1" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
+19 -11
View File
@@ -354,13 +354,18 @@ class TestQuotaRegistry:
assert len(reg.list_dimensions()) == len(QuotaDimension)
def test_list_tiers(self):
"""四个套餐等级."""
"""套餐等级包含核心四档 + 旧档位别名."""
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "pro" in tiers
assert "free" in tiers
assert "basic" in tiers
assert len(tiers) == 4
assert "monthly" in tiers
assert "quarterly" in tiers
assert "yearly" in tiers
assert "basic" in tiers # alias → monthly
assert "premium" in tiers # alias → quarterly
assert "pro" in tiers # alias → quarterly
assert "standard" in tiers # alias → monthly
assert "enterprise" in tiers # alias → quarterly
def test_get_tier_existing(self):
"""获取已有的套餐."""
@@ -370,9 +375,12 @@ class TestQuotaRegistry:
assert tier.name == "free"
def test_get_tier_nonexistent(self):
"""不存在的套餐返回 None"""
"""不存在的套餐返回 Noneenterprise 现为 quarterly 别名)."""
from packages.domain.quota import QUOTA_TIERS
reg = QuotaRegistry()
assert reg.get_tier("enterprise") is None
assert reg.get_tier("totally_unknown_plan_xyz") is None
assert reg.get_tier("enterprise") is QUOTA_TIERS["quarterly"]
def test_get_limit_existing(self):
"""获取已有限制."""
@@ -380,9 +388,10 @@ class TestQuotaRegistry:
assert reg.get_limit("free", QuotaDimension.STORAGE_GB) == 2
def test_get_limit_nonexistent_plan(self):
"""不存在的套餐 fallback 到 free 配额"""
"""不存在的套餐返回 0enterprise 现为 quarterly 别名,返回 100."""
reg = QuotaRegistry()
assert reg.get_limit("enterprise", QuotaDimension.STORAGE_GB) == 0
assert reg.get_limit("totally_unknown_plan_xyz", QuotaDimension.STORAGE_GB) == 0
assert reg.get_limit("enterprise", QuotaDimension.STORAGE_GB) == 100
def test_get_limit_unknown_dimension(self):
"""未知维度返回 0."""
@@ -506,11 +515,10 @@ class TestQuotaChecker:
assert result.usage_percent == 0.0
def test_check_unknown_plan(self):
"""未知套餐,限制为0."""
"""未知套餐,限制为0enterprise现为quarterly别名,这里用一个真不存在的名)."""
checker = QuotaChecker()
result = checker.check("enterprise", QuotaDimension.STORAGE_GB, 0)
result = checker.check("totally_unknown_plan_xyz", QuotaDimension.STORAGE_GB, 0)
assert result.limit == 0
# used=0, limit=0 → 0 < 0 is False → allowed=False
assert result.allowed is False
assert result.warning_level == QuotaWarningLevel.NORMAL
@@ -220,3 +220,127 @@ def test_any_unexpected_error_does_not_return_500_raw(fake_user):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
# 只要不是被全局 INTERNAL_ERROR 吞掉就行(带 detail 的 500 也比通用 500 强)
assert "抖音" in exc.value.detail or "失败" in exc.value.detail or exc.value.status_code != 500
# ── cookies 相关测试 ──────────────────────────────────────────────
def test_cookies_error_returns_503_friendly_message(fake_user):
"""cookies 缺失/过期(yt-dlp 报 'Fresh cookies ... are needed')→ 返回 503 + 友好文案,不暴露原始错误"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/")
class DownloadError(Exception):
pass
class CookiesYDL(_FakeYDLBase):
extract_info_raises = DownloadError(
"ERROR: [Douyin] 7623712911260650802: Fresh cookies (not necessarily logged in) are needed"
)
_install_fake_ytdlp(CookiesYDL, download_error_cls=DownloadError)
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE, f"应为503,实际 {exc.value.status_code}"
assert (
"暂时不可用" in exc.value.detail or "稍后重试" in exc.value.detail
), f"应有友好提示,实际: {exc.value.detail}"
# 确认不暴露 yt-dlp 原始错误
assert "Fresh cookies" not in exc.value.detail
def test_cookies_error_in_generic_except_also_returns_503(fake_user):
"""cookies 错误绕过 DownloadError(例如被其他异常包装)时,兜底异常分支也应识别并返回 503"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class CookieBugYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
# 抛非 DownloadError 的普通异常,但 message 含 cookies 关键词
raise RuntimeError("Fresh cookies are needed to access this video")
_install_fake_ytdlp(CookieBugYDL)
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
def test_ydl_opts_includes_cookiefile_when_file_exists(fake_user):
"""cookies 文件存在时,ydl_opts 应包含 cookiefile 指向该路径"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
captured_opts = {}
class CaptureOptsYDL(_FakeYDLBase):
def __init__(self, opts):
captured_opts.update(opts)
super().__init__()
extract_info_result = {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(CaptureOptsYDL)
with (
mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)),
mock.patch.object(scripts_ai, "_resolve_cookies_file", return_value="/tmp/fake_cookies.txt"),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"),
):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert captured_opts.get("cookiefile") == "/tmp/fake_cookies.txt", f"cookiefile 应被设置,opts={captured_opts}"
def test_ydl_opts_no_cookiefile_when_file_missing(fake_user):
"""cookies 文件不存在时,ydl_opts 不应包含 cookiefile 键"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
captured_opts = {}
class CaptureOptsYDL(_FakeYDLBase):
def __init__(self, opts):
captured_opts.update(opts)
super().__init__()
extract_info_result = {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(CaptureOptsYDL)
with (
mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)),
mock.patch.object(scripts_ai, "_resolve_cookies_file", return_value=None),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"),
):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert "cookiefile" not in captured_opts, f"cookies 文件缺失时不应设置 cookiefileopts={captured_opts}"
def test_generic_download_error_hides_raw_message(fake_user):
"""非 cookies 非 404 的通用下载错误 → 502,且不暴露 yt-dlp 原始错误文本"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class DownloadError(Exception):
pass
class GenErrYDL(_FakeYDLBase):
extract_info_raises = DownloadError("ERROR: some internal yt-dlp weird failure with trace")
_install_fake_ytdlp(GenErrYDL, download_error_cls=DownloadError)
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
assert "下载失败" in exc.value.detail
assert "weird failure" not in exc.value.detail, "不应暴露 yt-dlp 内部错误文本"
+72
View File
@@ -0,0 +1,72 @@
"""验证 _helpers.get_user_plan 档位归一化逻辑(#1894 旧档位兼容)"""
from __future__ import annotations
from unittest import mock
import pytest
from app.api.routes import _helpers
class _FakeUser:
def __init__(self, plan):
self.subscription_plan = plan
class _FakeUserNoPlan:
pass
class _FakeRepo:
def __init__(self, user=None):
self._user = user
def find_by_id(self, uid):
return self._user
def test_user_not_found_returns_free():
"""用户不存在时返回 free(覆盖 _helpers.py 第 41 行 user is None 分支)"""
repo = _FakeRepo(user=None)
assert _helpers.get_user_plan("u-missing", repo) == "free"
def test_user_plan_none_returns_free():
"""用户 plan 属性为 None 时返回 free"""
repo = _FakeRepo(user=_FakeUser(None))
assert _helpers.get_user_plan("u1", repo) == "free"
@pytest.mark.parametrize(
"legacy,expected",
[
("standard", "monthly"),
("basic", "monthly"),
("pro", "quarterly"),
("premium", "quarterly"),
("enterprise", "quarterly"),
],
)
def test_legacy_plans_normalized(legacy, expected):
"""旧档位值正确归一化到新体系"""
repo = _FakeRepo(user=_FakeUser(legacy))
assert _helpers.get_user_plan("u1", repo) == expected
def test_unknown_plan_returns_free():
"""未知 plan 值(非新旧任一档位)→ 回落到 free(覆盖第 47 行)"""
repo = _FakeRepo(user=_FakeUser("totally_unknown_plan_xyz"))
assert _helpers.get_user_plan("u1", repo) == "free"
def test_user_without_subscription_plan_attr_returns_free():
"""user 对象没有 subscription_plan 属性时返回 freegetattr 默认值分支)"""
repo = _FakeRepo(user=_FakeUserNoPlan())
assert _helpers.get_user_plan("u1", repo) == "free"
@pytest.mark.parametrize("plan", ["free", "monthly", "quarterly", "yearly"])
def test_valid_new_plans_passthrough(plan):
"""新档位直接透传"""
repo = _FakeRepo(user=_FakeUser(plan))
assert _helpers.get_user_plan("u1", repo) == plan
+34 -46
View File
@@ -31,7 +31,7 @@ from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.subscription import _get_plan_name, _get_plan_price, router
from app.api.routes.subscription import _get_plan_name, router
# ---------------------------------------------------------------------------
# 1. Mock Billing Repository
@@ -140,8 +140,8 @@ class TestPaymentCallbackSuccess:
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_monthly_pro_payment_success(self, MockSession, MockRepo):
"""Pro 套餐月付支付成功。"""
def test_monthly_payment_success(self, MockSession, MockRepo):
"""月卡支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
@@ -154,7 +154,7 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-001",
"plan": "pro",
"plan": "monthly",
"billing_cycle": "monthly",
"amount": 299.0,
"payment_method": "alipay",
@@ -175,12 +175,12 @@ class TestPaymentCallbackSuccess:
# 验证订阅更新
assert mock_repo.update_subscription_count == 1
assert "user-001" in mock_repo.updated_subscriptions
assert mock_repo.updated_subscriptions["user-001"]["plan"] == "pro"
assert mock_repo.updated_subscriptions["user-001"]["plan"] == "monthly"
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_yearly_standard_payment_success(self, MockSession, MockRepo):
"""标准版年付支付成功。"""
def test_yearly_payment_success(self, MockSession, MockRepo):
"""年卡支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
@@ -193,7 +193,7 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-002",
"plan": "standard",
"plan": "monthly",
"billing_cycle": "yearly",
"amount": 999.0,
"payment_method": "wechat",
@@ -204,7 +204,7 @@ class TestPaymentCallbackSuccess:
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert mock_repo.updated_subscriptions["user-002"]["plan"] == "standard"
assert mock_repo.updated_subscriptions["user-002"]["plan"] == "monthly"
# 年付到期时间应为约 365 天后
expires_at = mock_repo.updated_subscriptions["user-002"]["expires_at"]
expected = datetime.now(UTC) + timedelta(days=365)
@@ -212,8 +212,8 @@ class TestPaymentCallbackSuccess:
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_enterprise_payment_success(self, MockSession, MockRepo):
"""企业版支付成功。"""
def test_quarterly_payment_success(self, MockSession, MockRepo):
"""季卡支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
@@ -226,8 +226,8 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-003",
"plan": "enterprise",
"billing_cycle": "monthly",
"plan": "quarterly",
"billing_cycle": "quarterly",
"amount": 999.0,
"payment_method": "bank_transfer",
"payment_id": "ent_20240101_003",
@@ -236,7 +236,7 @@ class TestPaymentCallbackSuccess:
assert resp.status_code == 200
assert resp.json()["success"] is True
assert mock_repo.updated_subscriptions["user-003"]["plan"] == "enterprise"
assert mock_repo.updated_subscriptions["user-003"]["plan"] == "quarterly"
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
@@ -254,7 +254,7 @@ class TestPaymentCallbackSuccess:
"/subscription/payment-callback",
params={
"user_id": "user-004",
"plan": "standard",
"plan": "monthly",
"billing_cycle": "monthly",
"amount": 99.0,
},
@@ -288,7 +288,7 @@ class TestPaymentCallbackIdempotency:
params = {
"user_id": "user-idem-1",
"plan": "pro",
"plan": "monthly",
"billing_cycle": "monthly",
"amount": 299.0,
"payment_id": "pay_dup_001",
@@ -351,7 +351,7 @@ class TestPaymentCallbackValidation:
resp = client.post(
"/subscription/payment-callback",
params={"plan": "pro", "billing_cycle": "monthly", "amount": 299.0},
params={"plan": "monthly", "billing_cycle": "monthly", "amount": 299.0},
)
assert resp.status_code == 422
@@ -385,7 +385,7 @@ class TestPaymentCallbackValidation:
resp = client.post(
"/subscription/payment-callback",
params={"user_id": "u1", "plan": "pro", "billing_cycle": "monthly"},
params={"user_id": "u1", "plan": "monthly", "billing_cycle": "monthly"},
)
assert resp.status_code == 422
@@ -405,7 +405,7 @@ class TestPaymentCallbackValidation:
"/subscription/payment-callback",
params={
"user_id": "u1",
"plan": "pro",
"plan": "monthly",
"billing_cycle": "monthly",
"amount": -100.0,
},
@@ -420,35 +420,23 @@ class TestPaymentCallbackValidation:
class TestHelperFunctions:
"""订阅辅助函数测试。"""
"""订阅辅助函数测试 — #1894 新档位 free/monthly/quarterly/yearly"""
def test_get_plan_name_all_plans(self):
"""所有套餐名称映射正确。"""
assert _get_plan_name("free") == "体验版"
assert _get_plan_name("standard") == "标准版"
assert _get_plan_name("pro") == "专业版"
assert _get_plan_name("enterprise") == "企业版"
assert _get_plan_name("free") == "免费用户"
assert _get_plan_name("monthly") == "月卡会员"
assert _get_plan_name("quarterly") == "季卡会员"
assert _get_plan_name("yearly") == "年卡会员"
def test_get_plan_name_unknown(self):
"""未知套餐返回「未知套餐」。"""
assert _get_plan_name("unknown") == "未知套餐"
assert _get_plan_name("") == "未知套餐"
def test_get_plan_price_all_combinations(self):
"""所有套餐价格映射正确。"""
assert _get_plan_price("free", "monthly") == 0
assert _get_plan_price("free", "yearly") == 0
assert _get_plan_price("standard", "monthly") == 99
assert _get_plan_price("standard", "yearly") == 999
assert _get_plan_price("pro", "monthly") == 299
assert _get_plan_price("pro", "yearly") == 2999
assert _get_plan_price("enterprise", "monthly") == 999
assert _get_plan_price("enterprise", "yearly") == 9999
def test_get_plan_price_unknown(self):
"""未知组合返回 0。"""
assert _get_plan_price("unknown", "monthly") == 0
assert _get_plan_price("pro", "weekly") == 0
def test_get_plan_name_unknown_defaults_free(self):
"""未知套餐返回默认「免费用户」。"""
assert _get_plan_name("unknown") == "免费用户"
assert _get_plan_name("") == "免费用户"
# legacy 旧值不直接命中 → 也回落免费用户(实际会被 _helpers.get_user_plan 归一化到 monthly/quarterly
assert _get_plan_name("standard") == "免费用户"
assert _get_plan_name("pro") == "免费用户"
assert _get_plan_name("enterprise") == "免费用户"
# ---------------------------------------------------------------------------
@@ -568,12 +556,12 @@ class TestMockBillingRepository:
repo = MockBillingRepository()
expires = datetime.now(UTC) + timedelta(days=30)
repo.update_subscription_on_payment("user-001", "pro", expires)
repo.update_subscription_on_payment("user-001", "monthly", expires)
assert repo.update_subscription_count == 1
assert "user-001" in repo.updated_subscriptions
sub = repo.updated_subscriptions["user-001"]
assert sub["plan"] == "pro"
assert sub["plan"] == "monthly"
assert sub["status"] == "active"
assert sub["expires_at"] == expires
+77 -103
View File
@@ -1,4 +1,4 @@
"""Quota 配额系统单测 — 全维度覆盖."""
"""Quota 配额系统单测 — #1894 档位清理后版本 (free/monthly/quarterly/yearly)."""
from __future__ import annotations
@@ -39,7 +39,6 @@ class TestQuotaDimension:
assert QuotaDimension.AI_VOICE_ENABLED == "ai_voice_enabled"
def test_all_dimensions_count(self):
# 至少包含内置的几个核心维度
dims = list(QuotaDimension)
assert len(dims) >= 7
@@ -82,11 +81,13 @@ class TestQuotaTier:
assert tier.is_unlimited("storage_gb") is False
def test_is_unlimited_undefined_defaults_true(self):
# 未定义的维度,get 默认为 inf → is_unlimited 返回 True
tier = QuotaTier(name="test", limits={})
assert tier.is_unlimited("unknown") is True
# ── QuotaTiers — #1894 新档位: free / monthly / quarterly / yearly ─────────
class TestQuotaTiers:
def test_free_tier_exists(self):
assert "free" in QUOTA_TIERS
@@ -95,36 +96,56 @@ class TestQuotaTiers:
assert free.get_limit("storage_gb") == 2
assert free.get_limit("videos_per_month") == 5
def test_basic_tier_exists(self):
assert "basic" in QUOTA_TIERS
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("storage_gb") == 20
assert basic.get_limit("videos_per_month") == 30
assert basic.get_limit("ai_voice_enabled") == 1
def test_monthly_tier_exists(self):
assert "monthly" in QUOTA_TIERS
monthly = QUOTA_TIERS["monthly"]
assert monthly.get_limit("storage_gb") == 20
assert monthly.get_limit("videos_per_month") == 30
assert monthly.get_limit("ai_voice_enabled") == 1
def test_premium_tier_exists(self):
assert "premium" in QUOTA_TIERS
premium = QUOTA_TIERS["premium"]
assert premium.get_limit("storage_gb") == 100
assert premium.get_limit("videos_per_month") == 100
def test_quarterly_tier_exists(self):
assert "quarterly" in QUOTA_TIERS
quarterly = QUOTA_TIERS["quarterly"]
assert quarterly.get_limit("storage_gb") == 100
assert quarterly.get_limit("videos_per_month") == 100
def test_premium_templates_unlimited(self):
premium = QUOTA_TIERS["premium"]
assert premium.is_unlimited("max_templates") is True
def test_yearly_tier_exists(self):
assert "yearly" in QUOTA_TIERS
yearly = QUOTA_TIERS["yearly"]
assert yearly.get_limit("storage_gb") == 100
assert yearly.is_unlimited("videos_per_month") is True
assert yearly.get_limit("ai_voice_credits") == 2000
def test_quarterly_templates_unlimited(self):
quarterly = QUOTA_TIERS["quarterly"]
assert quarterly.is_unlimited("max_templates") is True
def test_yearly_templates_unlimited(self):
yearly = QUOTA_TIERS["yearly"]
assert yearly.is_unlimited("max_templates") is True
def test_free_ai_voice_disabled(self):
free = QUOTA_TIERS["free"]
assert free.get_limit("ai_voice_enabled") == 0
def test_basic_ai_voice_enabled(self):
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("ai_voice_enabled") == 1
def test_monthly_ai_voice_enabled(self):
monthly = QUOTA_TIERS["monthly"]
assert monthly.get_limit("ai_voice_enabled") == 1
def test_storage_increases_with_tier(self):
free = QUOTA_TIERS["free"].get_limit("storage_gb")
basic = QUOTA_TIERS["basic"].get_limit("storage_gb")
premium = QUOTA_TIERS["premium"].get_limit("storage_gb")
assert free < basic < premium
monthly = QUOTA_TIERS["monthly"].get_limit("storage_gb")
quarterly = QUOTA_TIERS["quarterly"].get_limit("storage_gb")
assert free < monthly <= quarterly
def test_legacy_tiers_are_aliases(self):
"""#1894: old standard/pro/enterprise/basic/premium 保留为别名以兼容历史数据。
basic/standard → monthly; premium/pro/enterprise → quarterly."""
assert QUOTA_TIERS["basic"] is QUOTA_TIERS["monthly"]
assert QUOTA_TIERS["standard"] is QUOTA_TIERS["monthly"]
assert QUOTA_TIERS["premium"] is QUOTA_TIERS["quarterly"]
assert QUOTA_TIERS["pro"] is QUOTA_TIERS["quarterly"]
assert QUOTA_TIERS["enterprise"] is QUOTA_TIERS["quarterly"]
# ── QuotaCheckResult ───────────────────────────────────────────────────────
@@ -162,7 +183,7 @@ class TestQuotaCheckResult:
remaining=0,
warning_level=QuotaWarningLevel.EXCEEDED,
)
assert result.usage_percent == 100.0 # capped at 100
assert result.usage_percent == 100.0
def test_usage_percent_zero_limit_with_usage(self):
result = QuotaCheckResult(
@@ -209,12 +230,22 @@ class TestQuotaRegistry:
assert "videos_per_month" in dims
assert "max_concurrent" in dims
def test_init_has_three_tiers(self):
def test_init_has_core_four_tiers(self):
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "free" in tiers
assert "basic" in tiers
assert "premium" in tiers
assert "monthly" in tiers
assert "quarterly" in tiers
assert "yearly" in tiers
def test_legacy_tiers_are_aliases(self):
"""旧档位作为别名注册以兼容历史数据."""
reg = QuotaRegistry()
tiers = reg.list_tiers()
for legacy in ("standard", "pro", "enterprise", "basic", "premium"):
assert legacy in tiers
assert reg.get_tier("basic") is reg.get_tier("monthly")
assert reg.get_tier("pro") is reg.get_tier("quarterly")
def test_get_limit_free_storage(self):
reg = QuotaRegistry()
@@ -231,38 +262,37 @@ class TestQuotaRegistry:
assert tier.name == "free"
def test_get_tier_unknown_returns_none(self):
"""未知套餐返回 None"""
reg = QuotaRegistry()
assert reg.get_tier("nonexistent") is None
def test_register_new_dimension(self):
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "自定义维度", default_limits={"free": 5, "basic": 20})
reg.register_dimension(
"custom_dim", "自定义维度", default_limits={"free": 5, "monthly": 20, "quarterly": 50, "yearly": 100}
)
assert "custom_dim" in reg.list_dimensions()
assert reg.get_limit("free", "custom_dim") == 5
assert reg.get_limit("basic", "custom_dim") == 20
assert reg.get_limit("monthly", "custom_dim") == 20
assert reg.get_limit("yearly", "custom_dim") == 100
def test_register_dimension_idempotent(self):
reg = QuotaRegistry()
reg.register_dimension("custom_dim", "v1", default_limits={"free": 5})
reg.register_dimension("custom_dim", "v2", default_limits={"free": 99})
# 幂等:第二次注册不改变
assert reg.list_dimensions()["custom_dim"] == "v1"
assert reg.get_limit("free", "custom_dim") == 5
def test_register_dimension_no_defaults(self):
reg = QuotaRegistry()
reg.register_dimension("new_dim", "新维度")
# 默认所有套餐都是 0
assert reg.get_limit("free", "new_dim") == 0
assert reg.get_limit("basic", "new_dim") == 0
assert reg.get_limit("premium", "new_dim") == 0
assert reg.get_limit("monthly", "new_dim") == 0
assert reg.get_limit("yearly", "new_dim") == 0
def test_list_dimensions_returns_copy(self):
reg = QuotaRegistry()
dims = reg.list_dimensions()
dims["fake"] = "test"
# 修改返回值不影响内部
assert "fake" not in reg.list_dimensions()
@@ -277,7 +307,6 @@ class TestQuotaChecker:
assert result.limit == 2
assert result.used == 1.0
assert result.remaining == 1.0
assert result.dimension == "storage_gb"
def test_check_exceeds_limit(self):
checker = QuotaChecker()
@@ -286,17 +315,21 @@ class TestQuotaChecker:
assert result.remaining == 0
def test_check_exactly_at_limit(self):
# used == limit 时 allowed 为 False(必须严格小于)
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 2.0)
assert result.allowed is False
def test_check_unlimited(self):
def test_check_unlimited_quarterly_templates(self):
checker = QuotaChecker()
result = checker.check("premium", "max_templates", 1000.0)
result = checker.check("quarterly", "max_templates", 1000.0)
assert result.allowed is True
assert math.isinf(result.remaining)
def test_check_unlimited_yearly_videos(self):
checker = QuotaChecker()
result = checker.check("yearly", "videos_per_month", 9999.0)
assert result.allowed is True
assert math.isinf(result.remaining)
assert result.warning_level == QuotaWarningLevel.NORMAL
def test_check_unknown_plan(self):
checker = QuotaChecker()
@@ -306,13 +339,8 @@ class TestQuotaChecker:
def test_check_multiple(self):
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{"storage_gb": 1.0, "videos_per_month": 2},
)
results = checker.check_multiple("free", {"storage_gb": 1.0, "videos_per_month": 2})
assert len(results) == 2
assert results[0].dimension == "storage_gb"
assert results[1].dimension == "videos_per_month"
assert all(r.allowed for r in results)
def test_warning_level_normal(self):
@@ -322,34 +350,19 @@ class TestQuotaChecker:
def test_warning_level_warning(self):
checker = QuotaChecker()
# 80% < 95% → warning
result = checker.check("free", "storage_gb", 1.7) # 85%
assert result.warning_level == QuotaWarningLevel.WARNING
def test_warning_level_critical(self):
checker = QuotaChecker()
# 95% <= < 100% → critical
result = checker.check("free", "storage_gb", 1.95) # 97.5%
assert result.warning_level == QuotaWarningLevel.CRITICAL
def test_warning_level_exceeded(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 2.5) # 125%
result = checker.check("free", "storage_gb", 2.5)
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_warning_level_zero_limit_with_usage(self):
checker = QuotaChecker()
result = checker.check("free", "ai_voice_enabled", 1) # limit=0, used=1
assert result.warning_level == QuotaWarningLevel.EXCEEDED
def test_warning_level_zero_limit_no_usage(self):
checker = QuotaChecker()
# limit=0, used=0 → 特殊处理为 normal
# 但 allowed 是 False0 < 0 不成立)
result = checker.check("free", "ai_voice_enabled", 0)
# 0 < 0 是 False → not allowed
assert result.allowed is False
def test_checker_uses_provided_registry(self):
reg = QuotaRegistry()
reg.register_dimension("custom", "自定义", default_limits={"free": 42})
@@ -359,34 +372,22 @@ class TestQuotaChecker:
assert result.allowed is True
# ── get_warning_level 便捷函数 ────────────────────────────────────────────
# ── get_warning_level ─────────────────────────────────────────────────────
class TestGetWarningLevel:
def test_normal_low_usage(self):
assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL
def test_normal_zero_usage(self):
assert get_warning_level(0, 100) == QuotaWarningLevel.NORMAL
def test_warning_threshold(self):
assert get_warning_level(80, 100) == QuotaWarningLevel.WARNING
def test_warning_between_80_and_95(self):
assert get_warning_level(90, 100) == QuotaWarningLevel.WARNING
def test_critical_threshold(self):
assert get_warning_level(95, 100) == QuotaWarningLevel.CRITICAL
def test_critical_between_95_and_100(self):
assert get_warning_level(99, 100) == QuotaWarningLevel.CRITICAL
def test_exceeded_at_100(self):
assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED
def test_exceeded_over_100(self):
assert get_warning_level(150, 100) == QuotaWarningLevel.EXCEEDED
def test_unlimited_always_normal(self):
assert get_warning_level(9999, float("inf")) == QuotaWarningLevel.NORMAL
@@ -397,7 +398,7 @@ class TestGetWarningLevel:
assert get_warning_level(0, 0) == QuotaWarningLevel.NORMAL
# ── 全局单例 ───────────────────────────────────────────────────────────────
# ── 全局单例 ─────────────────────────────────────────────────────────────
class TestGlobalSingletons:
@@ -413,30 +414,3 @@ class TestGlobalSingletons:
result = quota_checker.check("free", "storage_gb", 1.0)
assert result.allowed is True
assert result.limit == 2
class TestProTier:
"""Pro 套餐专项测试"""
def test_pro_tier_exists(self):
"""pro 套餐存在于 QUOTA_TIERS"""
from packages.domain.quota import QUOTA_TIERS
assert "pro" in QUOTA_TIERS
def test_pro_tier_same_as_premium(self):
"""pro 套餐配额与 premium 完全一致"""
from packages.domain.quota import QUOTA_TIERS
pro = QUOTA_TIERS["pro"]
premium = QUOTA_TIERS["premium"]
assert pro.limits == premium.limits
def test_pro_tier_get_limit(self):
"""pro 套餐各维度配额正确"""
reg = QuotaRegistry()
assert reg.get_limit("pro", "storage_gb") == 100
assert reg.get_limit("pro", "videos_per_month") == 100
assert reg.get_limit("pro", "max_concurrent") == 20
assert reg.get_limit("pro", "max_titles") == 500
assert reg.get_limit("pro", "ai_voice_enabled") == 1
+88 -77
View File
@@ -1,4 +1,6 @@
"""Quota 配额系统单元测试"""
"""Quota 配额系统单元测试 — #1894 档位清理后 (free/monthly/quarterly/yearly)."""
import math
import pytest
@@ -22,7 +24,7 @@ class TestQuotaDimension:
assert QuotaDimension.VIDEOS_PER_MONTH.value == "videos_per_month"
assert QuotaDimension.MAX_CONCURRENT.value == "max_concurrent"
assert QuotaDimension.MAX_TEMPLATES.value == "max_templates"
assert QuotaDimension.MAX_TITLES.value == "max_titles"
# MAX_TITLES 保留作为枚举别名(与 MAX_TEMPLATES 同值),但不再在套餐配额中独立配置
assert QuotaDimension.MAX_VOICEOVERS.value == "max_voiceovers"
assert QuotaDimension.AI_VOICE_ENABLED.value == "ai_voice_enabled"
@@ -57,7 +59,6 @@ class TestQuotaTier:
def test_is_unlimited_undefined(self):
tier = QuotaTier(name="test", limits={})
# 未定义的维度,limits.get 返回默认 inf,所以 is_unlimited 返回 True
assert tier.is_unlimited("unknown") is True
def test_empty_limits(self):
@@ -67,10 +68,20 @@ class TestQuotaTier:
class TestQuotaTiers:
def test_three_tiers_exist(self):
def test_core_tiers_exist(self):
"""核心四档位存在."""
assert "free" in QUOTA_TIERS
assert "basic" in QUOTA_TIERS
assert "premium" in QUOTA_TIERS
assert "monthly" in QUOTA_TIERS
assert "quarterly" in QUOTA_TIERS
assert "yearly" in QUOTA_TIERS
def test_legacy_tiers_are_aliases(self):
"""旧档位保留为别名以兼容历史数据."""
assert QUOTA_TIERS["basic"] is QUOTA_TIERS["monthly"]
assert QUOTA_TIERS["standard"] is QUOTA_TIERS["monthly"]
assert QUOTA_TIERS["premium"] is QUOTA_TIERS["quarterly"]
assert QUOTA_TIERS["pro"] is QUOTA_TIERS["quarterly"]
assert QUOTA_TIERS["enterprise"] is QUOTA_TIERS["quarterly"]
def test_free_tier_limits(self):
free = QUOTA_TIERS["free"]
@@ -78,49 +89,52 @@ class TestQuotaTiers:
assert free.get_limit("videos_per_month") == 5
assert free.get_limit("max_concurrent") == 3
assert free.get_limit("max_templates") == 3
assert free.get_limit("max_titles") == 50
assert free.get_limit("max_voiceovers") == 10
assert free.get_limit("ai_voice_enabled") == 0
assert free.get_limit("ai_voice_credits") == 0
def test_basic_tier_limits(self):
basic = QUOTA_TIERS["basic"]
assert basic.get_limit("storage_gb") == 20
assert basic.get_limit("videos_per_month") == 30
assert basic.get_limit("max_concurrent") == 10
assert basic.get_limit("max_templates") == 15
assert basic.get_limit("max_titles") == 500
assert basic.get_limit("max_voiceovers") == 100
assert basic.get_limit("ai_voice_enabled") == 1
assert basic.get_limit("ai_voice_credits") == 100
assert basic.get_limit("batch_export_enabled") == 1
def test_monthly_tier_limits(self):
monthly = QUOTA_TIERS["monthly"]
assert monthly.get_limit("storage_gb") == 20
assert monthly.get_limit("videos_per_month") == 30
assert monthly.get_limit("max_concurrent") == 10
assert monthly.get_limit("max_templates") == 15
assert monthly.get_limit("max_voiceovers") == 100
assert monthly.get_limit("ai_voice_enabled") == 1
assert monthly.get_limit("ai_voice_credits") == 100
assert monthly.get_limit("batch_export_enabled") == 1
def test_premium_tier_limits(self):
premium = QUOTA_TIERS["premium"]
assert premium.get_limit("storage_gb") == 100
assert premium.get_limit("videos_per_month") == 100
assert premium.get_limit("max_concurrent") == 20
assert premium.is_unlimited("max_templates") is True
assert premium.get_limit("ai_voice_enabled") == 1
assert premium.get_limit("ai_voice_credits") == 500
assert premium.get_limit("batch_export_enabled") == 1
assert premium.get_limit("multi_platform_enabled") == 1
assert premium.get_limit("dedup_report_enabled") == 1
def test_quarterly_tier_limits(self):
q = QUOTA_TIERS["quarterly"]
assert q.get_limit("storage_gb") == 100
assert q.get_limit("videos_per_month") == 100
assert q.get_limit("max_concurrent") == 20
assert q.is_unlimited("max_templates") is True
assert q.get_limit("ai_voice_enabled") == 1
assert q.get_limit("ai_voice_credits") == 500
assert q.get_limit("batch_export_enabled") == 1
assert q.get_limit("multi_platform_enabled") == 1
assert q.get_limit("dedup_report_enabled") == 1
def test_yearly_tier_limits(self):
y = QUOTA_TIERS["yearly"]
assert y.get_limit("storage_gb") == 100
assert y.is_unlimited("videos_per_month") is True
assert y.get_limit("max_concurrent") == 20
assert y.is_unlimited("max_templates") is True
assert y.get_limit("max_voiceovers") == 200
assert y.get_limit("ai_voice_credits") == 2000
assert y.get_limit("batch_export_enabled") == 1
assert y.get_limit("multi_platform_enabled") == 1
assert y.get_limit("dedup_report_enabled") == 1
def test_tier_increase_monotonic(self):
free = QUOTA_TIERS["free"]
basic = QUOTA_TIERS["basic"]
premium = QUOTA_TIERS["premium"]
# 高级套餐应该 >= 低级套餐的所有限制
for dim in [
"storage_gb",
"videos_per_month",
"max_concurrent",
"max_titles",
"max_voiceovers",
"ai_voice_credits",
]:
assert basic.get_limit(dim) >= free.get_limit(dim)
assert premium.get_limit(dim) >= basic.get_limit(dim)
monthly = QUOTA_TIERS["monthly"]
quarterly = QUOTA_TIERS["quarterly"]
for dim in ["storage_gb", "videos_per_month", "max_concurrent", "max_voiceovers", "ai_voice_credits"]:
assert monthly.get_limit(dim) >= free.get_limit(dim)
assert quarterly.get_limit(dim) >= monthly.get_limit(dim)
class TestQuotaWarningLevel:
@@ -147,7 +161,7 @@ class TestQuotaCheckResult:
result = QuotaCheckResult(
allowed=False, dimension="d", limit=100, used=150, remaining=0, warning_level="exceeded"
)
assert result.usage_percent == 100.0 # min(100, 150%)
assert result.usage_percent == 100.0
def test_usage_percent_zero_used(self):
result = QuotaCheckResult(allowed=True, dimension="d", limit=100, used=0, remaining=100, warning_level="normal")
@@ -185,10 +199,11 @@ class TestQuotaRegistry:
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert "free" in tiers
assert "pro" in tiers
assert "basic" in tiers
assert "premium" in tiers
assert len(tiers) == 4
assert "monthly" in tiers
assert "quarterly" in tiers
assert "yearly" in tiers
# 核心四档位必须存在
assert "free" in tiers and "monthly" in tiers and "quarterly" in tiers and "yearly" in tiers
def test_get_tier_existing(self):
reg = QuotaRegistry()
@@ -203,7 +218,8 @@ class TestQuotaRegistry:
def test_get_limit_known(self):
reg = QuotaRegistry()
assert reg.get_limit("free", "storage_gb") == 2
assert reg.get_limit("premium", "storage_gb") == 100
assert reg.get_limit("quarterly", "storage_gb") == 100
assert reg.get_limit("yearly", "storage_gb") == 100
def test_get_limit_unknown_plan(self):
reg = QuotaRegistry()
@@ -211,32 +227,34 @@ class TestQuotaRegistry:
def test_register_new_dimension(self):
reg = QuotaRegistry()
reg.register_dimension("new_feature", "新功能", default_limits={"free": 0, "basic": 1, "premium": 5})
reg.register_dimension(
"new_feature", "新功能", default_limits={"free": 0, "monthly": 1, "quarterly": 5, "yearly": 10}
)
dims = reg.list_dimensions()
assert "new_feature" in dims
assert dims["new_feature"] == "新功能"
assert reg.get_limit("free", "new_feature") == 0
assert reg.get_limit("basic", "new_feature") == 1
assert reg.get_limit("premium", "new_feature") == 5
assert reg.get_limit("monthly", "new_feature") == 1
assert reg.get_limit("quarterly", "new_feature") == 5
assert reg.get_limit("yearly", "new_feature") == 10
def test_register_dimension_idempotent(self):
reg = QuotaRegistry()
reg.register_dimension("storage_gb", "should not change", default_limits={"free": 999})
# 已经存在的不覆盖
assert reg.get_limit("free", "storage_gb") == 2
def test_register_without_defaults(self):
reg = QuotaRegistry()
reg.register_dimension("new_dim", "描述")
assert reg.get_limit("free", "new_dim") == 0
assert reg.get_limit("basic", "new_dim") == 0
assert reg.get_limit("premium", "new_dim") == 0
assert reg.get_limit("monthly", "new_dim") == 0
assert reg.get_limit("yearly", "new_dim") == 0
def test_register_partial_limits(self):
reg = QuotaRegistry()
reg.register_dimension("partial", "partial", default_limits={"premium": 42})
assert reg.get_limit("free", "partial") == 0 # 未设置的保持 0
assert reg.get_limit("premium", "partial") == 42
reg.register_dimension("partial", "partial", default_limits={"quarterly": 42})
assert reg.get_limit("free", "partial") == 0
assert reg.get_limit("quarterly", "partial") == 42
class TestQuotaChecker:
@@ -247,7 +265,6 @@ class TestQuotaChecker:
assert result.limit == 2
assert result.used == 1
assert result.remaining == 1
assert result.dimension == "storage_gb"
def test_check_exceeded(self):
checker = QuotaChecker()
@@ -257,19 +274,24 @@ class TestQuotaChecker:
assert result.warning_level == "exceeded"
def test_check_exact_limit_not_allowed(self):
# used < limit 才 allowed,等于不算
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 2)
assert result.allowed is False
assert result.remaining == 0
def test_check_unlimited(self):
def test_check_unlimited_quarterly_templates(self):
checker = QuotaChecker()
result = checker.check("premium", "max_templates", 999999)
result = checker.check("quarterly", "max_templates", 999999)
assert result.allowed is True
assert result.remaining == float("inf")
assert math.isinf(result.remaining)
assert result.warning_level == "normal"
def test_check_unlimited_yearly_videos(self):
checker = QuotaChecker()
result = checker.check("yearly", "videos_per_month", 999999)
assert result.allowed is True
assert math.isinf(result.remaining)
def test_check_warning_level_normal(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 1) # 50%
@@ -277,40 +299,33 @@ class TestQuotaChecker:
def test_check_warning_level_warning(self):
checker = QuotaChecker()
# 80% <= used < 95%
result = checker.check("free", "max_templates", 2.5) # 2.5/3 = 83%
result = checker.check("free", "max_templates", 2.5) # 2.5/3 ≈ 83%
assert result.warning_level == "warning"
def test_check_warning_level_critical(self):
checker = QuotaChecker()
# 95% <= used < 100%
result = checker.check("free", "max_templates", 2.9) # 2.9/3 = 97%
result = checker.check("free", "max_templates", 2.9) # ≈97%
assert result.warning_level == "critical"
def test_check_warning_level_exceeded(self):
checker = QuotaChecker()
result = checker.check("free", "storage_gb", 5) # 250%
result = checker.check("free", "storage_gb", 5)
assert result.warning_level == "exceeded"
def test_check_multiple(self):
checker = QuotaChecker()
results = checker.check_multiple(
"free",
{"storage_gb": 1, "max_templates": 2, "max_titles": 10},
{"storage_gb": 1, "max_templates": 2, "max_voiceovers": 5},
)
assert len(results) == 3
assert results[0].dimension == "storage_gb"
assert results[1].dimension == "max_templates"
assert results[2].dimension == "max_titles"
assert all(r.allowed for r in results)
def test_check_zero_limit(self):
checker = QuotaChecker()
result = checker.check("free", "ai_voice_enabled", 0)
# limit=0, used=0: used < limit 为 False → allowed=False
assert result.allowed is False
assert result.remaining == 0
assert result.warning_level == "normal"
def test_compute_warning_level_normal(self):
assert QuotaChecker._compute_warning_level(50, 100) == "normal"
@@ -337,10 +352,6 @@ class TestQuotaChecker:
def test_compute_warning_level_zero_limit_no_usage(self):
assert QuotaChecker._compute_warning_level(0, 0) == "normal"
def test_compute_warning_level_negative_limit(self):
# limit <= 0 且 used=0 → NORMAL
assert QuotaChecker._compute_warning_level(0, -1) == "normal"
class TestGetWarningLevel:
def test_convenience_function(self):
+92 -296
View File
@@ -1,11 +1,10 @@
"""#1894 废弃标题库整合到文案库 — 集成测试.
"""#1894 方向修正 — 集成测试.
覆盖:
- ScriptModel 新字段 (title_text / title_category / title_config)
- ScriptService CRUD 新字段支持
- ScriptService.get_title_config_for_script 方法
- Scripts API 路由的新字段传递
- title_libraries API deprecated Warning header
- ScriptModel 不再有 title_text/title_category/title_config
- ScriptService 不再接受/暴露这三个字段
- /api/v1/titles/* 所有方法返回 410 Gone
- /api/v1/scripts 响应不含这三个字段
"""
from __future__ import annotations
@@ -20,7 +19,6 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
# 确保 apps/api 在 sys.path 中(conftest 已加 root,但 apps/api 也需要)
_APPS_API = str(Path(__file__).resolve().parents[2] / "apps" / "api")
if _APPS_API not in sys.path:
sys.path.insert(0, _APPS_API)
@@ -28,307 +26,105 @@ os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
from main import app # noqa: E402
# ── helpers ──────────────────────────────────────────────────────────────
def _make_script(**overrides):
"""构造一个模拟 ScriptModel 对象."""
defaults = dict(
id=str(uuid.uuid4()),
user_id="user-001",
title="测试文案",
content="这是内容",
segments=[],
tags=["测试"],
title_text="开场大标题",
title_category="片头",
title_config={
"text": "开场大标题",
"font": "思源黑体",
"font_size": 48,
"font_color": "#FFFFFF",
"position": "top",
},
created_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
)
defaults.update(overrides)
return MagicMock(**defaults)
# ── TestScriptModelNewFields ─────────────────────────────────────────────
class TestScriptModelNewFields:
"""验证 ScriptModel 新增字段的定义."""
def test_model_has_title_text_column(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
assert hasattr(ScriptModel, "title_text")
col = ScriptModel.__table__.columns["title_text"]
assert col is not None
assert str(col.type) == "VARCHAR(500)"
def test_model_has_title_category_column(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
assert hasattr(ScriptModel, "title_category")
col = ScriptModel.__table__.columns["title_category"]
assert col is not None
assert str(col.type) == "VARCHAR(50)"
def test_model_has_title_config_column(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
assert hasattr(ScriptModel, "title_config")
col = ScriptModel.__table__.columns["title_config"]
assert col is not None
def test_model_defaults(self):
"""新字段默认值为空字符串/空 dict."""
from packages.adapters.sqlalchemy_impl.models import ScriptModel
s = ScriptModel(id="x", user_id="u", title="t")
# 检查 default 值
assert ScriptModel.__table__.columns["title_text"].default.arg == ""
assert ScriptModel.__table__.columns["title_category"].default.arg == ""
# ── TestScriptServiceTitleConfig ─────────────────────────────────────────
class TestScriptServiceTitleConfig:
"""验证 ScriptService 新方法 get_title_config_for_script."""
def test_get_title_config_returns_script_config(self):
from app.services.script_service import ScriptService
db = MagicMock()
mock_script = _make_script(
title_text="从文案读取",
title_config={"text": "从文案读取", "font": "Arial", "font_size": 36},
)
db.query.return_value.filter.return_value.first.return_value = mock_script
svc = ScriptService(db)
result = svc.get_title_config_for_script("script-1", "user-001")
assert result["text"] == "从文案读取"
assert result["font"] == "Arial"
assert result["font_size"] == 36
def test_get_title_config_fills_text_from_title_text(self):
"""title_config 为空时,用 title_text 填充 text 字段."""
from app.services.script_service import ScriptService
db = MagicMock()
mock_script = _make_script(
title_text="纯文本标题",
title_config={},
)
db.query.return_value.filter.return_value.first.return_value = mock_script
svc = ScriptService(db)
result = svc.get_title_config_for_script("script-2", "user-001")
assert result["text"] == "纯文本标题"
def test_get_title_config_raises_on_not_found(self):
from app.services.script_service import ScriptNotFoundError, ScriptService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ScriptService(db)
with pytest.raises(ScriptNotFoundError):
svc.get_title_config_for_script("nonexistent", "user-001")
def test_get_title_config_validates_user_ownership(self):
"""script 不属于当前用户时应抛异常."""
from app.services.script_service import ScriptNotFoundError, ScriptService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None # 不同用户查不到
svc = ScriptService(db)
with pytest.raises(ScriptNotFoundError):
svc.get_title_config_for_script("script-other-user", "user-001")
# ── TestScriptServiceCreateWithNewFields ─────────────────────────────────
class TestScriptServiceCreateWithNewFields:
"""验证 create_script 和 update_script 支持新字段."""
def test_create_script_with_title_fields(self):
from app.services.script_service import ScriptService
db = MagicMock()
svc = ScriptService(db)
script = svc.create_script(
user_id="user-001",
title="新文案",
content="内容",
title_text="标题文字",
title_category="片尾",
title_config={"text": "标题文字", "font_size": 24},
)
db.add.assert_called_once()
db.commit.assert_called_once()
assert script.title_text == "标题文字"
assert script.title_category == "片尾"
assert script.title_config == {"text": "标题文字", "font_size": 24}
def test_update_script_title_fields(self):
from app.services.script_service import ScriptService
db = MagicMock()
existing = _make_script(title_text="旧标题", title_category="旧分类", title_config={"old": True})
db.query.return_value.filter.return_value.first.return_value = existing
svc = ScriptService(db)
updated = svc.update_script(
script_id=existing.id,
user_id="user-001",
title_text="新标题",
title_category="新分类",
title_config={"new": True},
)
assert updated.title_text == "新标题"
assert updated.title_category == "新分类"
assert updated.title_config == {"new": True}
# ── TestScriptsRoutesNewFields ───────────────────────────────────────────
def _make_mock_auth_user(user_id="user-001"):
"""创建 mock 认证用户."""
return MagicMock(user=MagicMock(id=user_id))
class TestScriptsRoutesNewFields:
"""验证 scripts API 路由正确处理新字段 — 使用 dependency_overrides 绕过真实 DB/Auth."""
# ── TestScriptModelNoTitleFields ─────────────────────────────────────────
class TestScriptModelNoTitleFields:
"""验证 ScriptModel 已删除 title_text/title_category/title_config 列."""
def test_model_has_no_title_text(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
assert not hasattr(ScriptModel, "title_text") or "title_text" not in {
c.name for c in ScriptModel.__table__.columns
}
def test_model_has_no_title_category(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
assert "title_category" not in {c.name for c in ScriptModel.__table__.columns}
def test_model_has_no_title_config(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
assert "title_config" not in {c.name for c in ScriptModel.__table__.columns}
def test_model_retains_core_fields(self):
from packages.adapters.sqlalchemy_impl.models import ScriptModel
cols = {c.name for c in ScriptModel.__table__.columns}
for expected in ("id", "user_id", "title", "content", "segments", "tags", "created_at", "updated_at"):
assert expected in cols, f"ScriptModel 缺字段 {expected}"
# ── TestScriptServiceSignature ──────────────────────────────────────────
class TestScriptServiceSignature:
"""验证 ScriptService 的 create/update 不接受已删除字段."""
def test_create_script_rejects_title_fields(self):
"""Python 层:传入旧字段应抛 TypeError(被移除了)."""
import inspect
from app.services.script_service import ScriptService
sig = inspect.signature(ScriptService.create_script)
for name in ("title_text", "title_category", "title_config"):
assert name not in sig.parameters, f"create_script 仍接受参数 {name}"
def test_update_script_rejects_title_fields(self):
import inspect
from app.services.script_service import ScriptService
sig = inspect.signature(ScriptService.update_script)
for name in ("title_text", "title_category", "title_config"):
assert name not in sig.parameters, f"update_script 仍接受参数 {name}"
def test_get_title_config_removed(self):
from app.services.script_service import ScriptService
assert not hasattr(ScriptService, "get_title_config_for_script"), "get_title_config_for_script 应已删除"
# ── TestTitlesApiGone ───────────────────────────────────────────────────
class TestTitlesApiGone:
"""验证 /api/v1/titles 所有方法返回 410 Gone."""
def setup_method(self):
from app.api.routes.scripts import _get_service, get_current_user
from app.auth import get_current_user
self._mock_svc = MagicMock()
self._mock_user = _make_mock_auth_user()
def _override_svc():
return self._mock_svc
def _override_user():
return self._mock_user
app.dependency_overrides[_get_service] = _override_svc
app.dependency_overrides[get_current_user] = _override_user
self.client = TestClient(app)
def teardown_method(self):
app.dependency_overrides.clear()
def test_create_script_passes_title_fields(self):
mock_script = _make_script(
title_text="测试标题",
title_category="片头",
title_config={"text": "测试标题", "font_size": 48},
)
self._mock_svc.create_script.return_value = mock_script
resp = self.client.post(
"/api/v1/scripts",
json={
"title": "新文案",
"content": "内容",
"title_text": "测试标题",
"title_category": "片头",
"title_config": {"text": "测试标题", "font_size": 48},
},
)
assert resp.status_code == 201, resp.text
call_kwargs = self._mock_svc.create_script.call_args[1]
assert call_kwargs["title_text"] == "测试标题"
assert call_kwargs["title_category"] == "片头"
assert call_kwargs["title_config"] == {"text": "测试标题", "font_size": 48}
def test_get_script_response_includes_title_fields(self):
mock_script = _make_script(
title_text="响应标题",
title_category="片尾",
title_config={"text": "响应标题", "position": "bottom"},
)
self._mock_svc.get_script.return_value = mock_script
resp = self.client.get("/api/v1/scripts/script-123")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["title_text"] == "响应标题"
assert data["title_category"] == "片尾"
assert data["title_config"]["position"] == "bottom"
# ── TestTitleLibraryDeprecated ───────────────────────────────────────────
class TestTitleLibraryDeprecated:
"""验证 title_libraries API 返回 deprecated Warning header — 使用 dependency_overrides 绕过真实 DB/Auth."""
def setup_method(self):
from app.api.routes.titles import _get_title_repository, get_current_user
from app.dependencies import get_user_repository
self._mock_repo = MagicMock()
self._mock_user_repo = MagicMock()
self._mock_user = _make_mock_auth_user()
app.dependency_overrides[_get_title_repository] = lambda: self._mock_repo
app.dependency_overrides[get_user_repository] = lambda: self._mock_user_repo
app.dependency_overrides[get_current_user] = lambda: self._mock_user
self.client = TestClient(app)
def teardown_method(self):
app.dependency_overrides.clear()
def test_list_titles_has_warning_header(self):
# list_titles 调 use_case + repo, 注入真实用例但 mock 掉 repo 的 list/count
self._mock_repo.list_by_user.return_value = []
self._mock_repo.count_by_user.return_value = 0
resp = self.client.get("/api/v1/titles")
assert resp.status_code == 200, resp.text
headers_lower = {k.lower(): v for k, v in resp.headers.items()}
assert "warning" in headers_lower or "deprecation" in headers_lower
assert "1894" in resp.headers.get("Warning", "") or "1894" in resp.headers.get("warning", "")
def test_get_title_has_warning_header(self):
from packages.domain.title_library import TitleLibraryItem
mock_item = TitleLibraryItem(
id="t1",
user_id="user-001",
name="测试",
text="标题文字",
category="通用",
description="",
tags=[],
usage_count=0,
is_active=True,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self._mock_repo.get.return_value = mock_item
resp = self.client.get("/api/v1/titles/t1")
assert resp.status_code == 200, resp.text
warning_header = resp.headers.get("Warning", "") or resp.headers.get("warning", "")
assert "1894" in warning_header or "deprecated" in warning_header.lower()
@pytest.mark.parametrize(
"method,path",
[
("get", "/api/v1/titles"),
("post", "/api/v1/titles"),
("get", "/api/v1/titles/some-id"),
("put", "/api/v1/titles/some-id"),
("delete", "/api/v1/titles/some-id"),
("patch", "/api/v1/titles/some-id"),
("get", "/api/v1/titles/any/nested/path"),
],
)
def test_titles_routes_return_410(self, method, path):
resp = getattr(self.client, method)(path)
assert resp.status_code == 410, f"{method.upper()} {path} 应返回 410,实际 {resp.status_code}: {resp.text}"
data = resp.json()
assert "error" in data or "message" in data or "GONE" in resp.text
# Deprecation header
assert resp.headers.get("Deprecation") == "true"
+34 -80
View File
@@ -1,7 +1,8 @@
"""Scripts routes 单元测试 — Issue #1795.
"""Scripts routes 单元测试 — Issue #1795 + #1894 清理.
CI 增量映射: scripts.py → test_scripts.py
本文件同时覆盖 routes/scripts.py 和 schemas/script.py 的增量覆盖率。
#1894: 删除 title_text/title_category/title_config 三字段,仅保留 title/content/segments/tags。
"""
from __future__ import annotations
@@ -18,8 +19,6 @@ from app.schemas.script import (
UpdateScriptRequest,
)
# ── Schema 验证测试 ──────────────────────────────────────────────────────────
class TestScriptSegment:
def test_segment_with_duration(self):
@@ -56,7 +55,7 @@ class TestCreateScriptRequest:
def test_title_required(self):
with pytest.raises(ValueError):
CreateScriptRequest(title="") # min_length=1
CreateScriptRequest(title="")
def test_title_max_length(self):
with pytest.raises(ValueError):
@@ -70,20 +69,16 @@ class TestUpdateScriptRequest:
assert r.content is None
assert r.segments is None
assert r.tags is None
assert r.title_text is None
assert r.title_category is None
assert r.title_config is None
def test_partial_update(self):
r = UpdateScriptRequest(title="新标题")
assert r.title == "新标题"
assert r.content is None
def test_partial_update_title_fields(self):
r = UpdateScriptRequest(title_text="标题文本", title_category="娱乐")
assert r.title_text == "新标题文本"
assert r.title_category == "娱乐"
def test_partial_update_content_only(self):
r = UpdateScriptRequest(content="内容")
assert r.title is None
assert r.content == "新内容"
class TestScriptResponse:
@@ -96,28 +91,22 @@ class TestScriptResponse:
content="内容",
segments=[ScriptSegment(text="段1")],
tags=["t1"],
title_text="标题文案",
title_category="科技",
title_config={"font": "思源黑体", "size": 48},
created_at=now,
updated_at=now,
)
assert r.id == "s1"
assert len(r.segments) == 1
assert r.title_text == "标题文案"
assert r.title_category == "科技"
assert r.title_config["font"] == "思源黑体"
assert r.title == "标题"
assert r.tags == ["t1"]
def test_response_defaults(self):
"""新字段有默认值,不传也能构造."""
now = datetime(2026, 9, 8, 12, 0, 0, tzinfo=UTC)
r = ScriptResponse(
id="s1", user_id="u1", title="标题", content="",
segments=[], tags=[], created_at=now, updated_at=now,
)
assert r.title_text == ""
assert r.title_category == ""
assert r.title_config == {}
assert r.segments == []
assert r.tags == []
class TestScriptListResponse:
@@ -143,12 +132,7 @@ class TestScriptListResponse:
assert len(r.items) == 1
# ── Route handler 逻辑测试 (mock service) ────────────────────────────────────
class TestRouteHandlers:
"""测试路由层逻辑(不通过 TestClient,直接调用 handler 函数)."""
def _make_auth_user(self, user_id="u1"):
user = MagicMock()
user.id = user_id
@@ -156,23 +140,23 @@ class TestRouteHandlers:
auth.user = user
return auth
def _make_mock_script(self, **overrides):
m = MagicMock()
m.id = overrides.get("id", "s1")
m.user_id = overrides.get("user_id", "u1")
m.title = overrides.get("title", "测试")
m.content = overrides.get("content", "内容")
m.segments = overrides.get("segments", [{"text": "段1", "duration": None}])
m.tags = overrides.get("tags", [])
m.created_at = overrides.get("created_at", datetime(2026, 9, 8, tzinfo=UTC))
m.updated_at = overrides.get("updated_at", datetime(2026, 9, 8, tzinfo=UTC))
return m
def test_create_route_calls_service(self):
from app.api.routes.scripts import create_script
svc = MagicMock()
mock_script = MagicMock()
mock_script.id = "s1"
mock_script.user_id = "u1"
mock_script.title = "测试"
mock_script.content = "内容"
mock_script.segments = [{"text": "段1", "duration": None}]
mock_script.tags = []
mock_script.title_text = ""
mock_script.title_category = ""
mock_script.title_config = {}
mock_script.created_at = datetime(2026, 9, 8, tzinfo=UTC)
mock_script.updated_at = datetime(2026, 9, 8, tzinfo=UTC)
svc.create_script.return_value = mock_script
svc.create_script.return_value = self._make_mock_script(content="内容")
req = CreateScriptRequest(title="测试", content="内容")
auth = self._make_auth_user()
@@ -180,24 +164,16 @@ class TestRouteHandlers:
result = create_script(req, authenticated_user=auth, svc=svc)
assert result.id == "s1"
svc.create_script.assert_called_once()
call_kwargs = svc.create_script.call_args.kwargs
assert "title_text" not in call_kwargs
assert "title_category" not in call_kwargs
assert "title_config" not in call_kwargs
def test_list_route_returns_paginated(self):
from app.api.routes.scripts import list_scripts
svc = MagicMock()
mock_script = MagicMock()
mock_script.id = "s1"
mock_script.user_id = "u1"
mock_script.title = "测试"
mock_script.content = ""
mock_script.segments = []
mock_script.tags = []
mock_script.title_text = ""
mock_script.title_category = ""
mock_script.title_config = {}
mock_script.created_at = datetime(2026, 9, 8, tzinfo=UTC)
mock_script.updated_at = datetime(2026, 9, 8, tzinfo=UTC)
svc.list_scripts.return_value = ([mock_script], 1)
svc.list_scripts.return_value = ([self._make_mock_script()], 1)
auth = self._make_auth_user()
result = list_scripts(skip=0, limit=50, tag=None, authenticated_user=auth, svc=svc)
@@ -208,19 +184,7 @@ class TestRouteHandlers:
from app.api.routes.scripts import get_script
svc = MagicMock()
mock_script = MagicMock()
mock_script.id = "s1"
mock_script.user_id = "u1"
mock_script.title = "测试"
mock_script.content = ""
mock_script.segments = []
mock_script.tags = []
mock_script.title_text = ""
mock_script.title_category = ""
mock_script.title_config = {}
mock_script.created_at = datetime(2026, 9, 8, tzinfo=UTC)
mock_script.updated_at = datetime(2026, 9, 8, tzinfo=UTC)
svc.get_script.return_value = mock_script
svc.get_script.return_value = self._make_mock_script()
auth = self._make_auth_user()
result = get_script("s1", authenticated_user=auth, svc=svc)
@@ -243,24 +207,16 @@ class TestRouteHandlers:
from app.api.routes.scripts import update_script
svc = MagicMock()
mock_script = MagicMock()
mock_script.id = "s1"
mock_script.user_id = "u1"
mock_script.title = "新标题"
mock_script.content = "原内容"
mock_script.segments = []
mock_script.tags = []
mock_script.title_text = ""
mock_script.title_category = ""
mock_script.title_config = {}
mock_script.created_at = datetime(2026, 9, 8, tzinfo=UTC)
mock_script.updated_at = datetime(2026, 9, 8, tzinfo=UTC)
svc.update_script.return_value = mock_script
svc.update_script.return_value = self._make_mock_script(title="新标题")
req = UpdateScriptRequest(title="新标题")
auth = self._make_auth_user()
result = update_script("s1", req, authenticated_user=auth, svc=svc)
assert result.title == "新标题"
call_kwargs = svc.update_script.call_args.kwargs
assert "title_text" not in call_kwargs
assert "title_category" not in call_kwargs
assert "title_config" not in call_kwargs
def test_update_route_not_found(self):
from app.api.routes.scripts import update_script
@@ -284,7 +240,6 @@ class TestRouteHandlers:
auth = self._make_auth_user()
result = delete_script("s1", authenticated_user=auth, svc=svc)
# Should return None (204 No Content)
assert result is None
def test_delete_route_not_found(self):
@@ -298,4 +253,3 @@ class TestRouteHandlers:
with pytest.raises(HTTPException) as exc_info:
delete_script("bad", authenticated_user=auth, svc=svc)
assert exc_info.value.status_code == 404
@@ -0,0 +1,186 @@
"""#1894 旧档位归一化逻辑测试(覆盖 _build_subscription_info / change_plan / cancel 等分支)"""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from unittest import mock
import pytest
from app.api.routes import subscription
from app.auth import AuthenticatedUser
from app.schemas.subscription import (
BillingCycle,
ChangePlanRequest,
MembershipType,
ToggleAutoRenewRequest,
)
@dataclass
class _FakeUserModel:
id: str = "u-1234567890"
subscription_plan: str | None = MembershipType.FREE
subscription_status: str | None = "active"
subscription_expires_at: datetime | None = None
created_at: datetime | None = None
@pytest.fixture
def auth_user():
return AuthenticatedUser(user=_FakeUserModel())
class TestBuildSubscriptionInfoLegacy:
"""覆盖 _build_subscription_info 旧档位归一化(subscription.py 57-58 行)"""
def test_legacy_standard_plan_normalized_to_monthly(self, auth_user):
new_user = replace(auth_user.user, subscription_plan="standard")
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.plan_id == MembershipType.MONTHLY
assert info.plan_name == "月卡会员"
def test_legacy_pro_plan_normalized_to_monthly(self, auth_user):
new_user = replace(auth_user.user, subscription_plan="pro")
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
# 旧 pro/standard/enterprise 都归一化到 monthly(按代码逻辑 {standard,pro,enterprise} → monthly
assert info.plan_id == MembershipType.MONTHLY
def test_legacy_enterprise_plan_normalized(self, auth_user):
new_user = replace(auth_user.user, subscription_plan="enterprise")
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.plan_id == MembershipType.MONTHLY
def test_no_expiry_gives_now_period(self, auth_user):
"""无过期时间时 period_start 和 period_end 都为 now(覆盖 else 分支 55-56 行)"""
new_user = replace(auth_user.user, subscription_expires_at=None)
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
# 两个时间都应非空且接近当前时间
assert info.current_period_start
assert info.current_period_end
def test_free_user_billing_cycle_defaults_to_monthly(self, auth_user):
"""免费用户 billing_cycle 回落到 monthly(覆盖第 64 行 !=FREE 判定 else 分支)"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.FREE)
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.billing_cycle == BillingCycle.MONTHLY
assert info.amount == 0
def test_yearly_user_passthrough(self, auth_user):
"""yearly 用户档位直接透传"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.YEARLY)
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.plan_id == MembershipType.YEARLY
assert info.plan_name == "年卡会员"
class TestChangePlanValidation:
"""覆盖 change_plan 入参校验 / 同档位提示 / 旧档位归一化"""
@pytest.mark.asyncio
async def test_invalid_plan_returns_400(self, auth_user):
"""无效 plan_id → 400(覆盖 169 行)"""
from fastapi import HTTPException
req = ChangePlanRequest(target_plan_id="totally_bogus_plan", billing_cycle=BillingCycle.MONTHLY)
with pytest.raises(HTTPException) as exc:
await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
assert exc.value.status_code == 400
assert "无效" in exc.value.detail
@pytest.mark.asyncio
async def test_invalid_billing_cycle_returns_400(self, auth_user):
"""无效 billing_cycle → 400(覆盖 176/178-179 行)"""
from fastapi import HTTPException
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle="bogus_cycle")
with pytest.raises(HTTPException) as exc:
await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_same_plan_returns_message(self, auth_user):
"""同档位变更 → 返回提示(覆盖 185 行分支)"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.MONTHLY)
auth_user = replace(auth_user, user=new_user)
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle=BillingCycle.MONTHLY)
resp = await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
assert resp.success is False
assert "已经是" in resp.message
@pytest.mark.asyncio
async def test_legacy_plan_normalized_for_same_plan_check(self, auth_user):
"""旧档位用户升级到 monthly → 应先归一化 current_plan 到 monthly,再判定为'同档位'"""
new_user = replace(auth_user.user, subscription_plan="standard")
auth_user = replace(auth_user, user=new_user)
# legacy → monthly
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle=BillingCycle.MONTHLY)
resp = await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
# standard 归一化到 monthly,所以 target monthly == current monthly → same plan
assert resp.success is False
class TestCancelSubscription:
"""覆盖 cancel_subscription 分支"""
@pytest.mark.asyncio
async def test_cancel_free_user_returns_400(self, auth_user):
"""免费用户取消订阅 → 400(覆盖 252 行)"""
from fastapi import HTTPException
new_user = replace(auth_user.user, subscription_plan=MembershipType.FREE)
auth_user = replace(auth_user, user=new_user)
with pytest.raises(HTTPException) as exc:
await subscription.cancel_subscription(current_user=auth_user, user_repository=mock.MagicMock())
assert exc.value.status_code == 400
assert "免费" in exc.value.detail
@pytest.mark.asyncio
async def test_cancel_paid_user_marks_cancelled(self, auth_user):
"""付费用户取消订阅 → save 被调用且 subscription_status='cancelled'"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.MONTHLY)
auth_user = replace(auth_user, user=new_user)
repo = mock.MagicMock()
resp = await subscription.cancel_subscription(current_user=auth_user, user_repository=repo)
assert resp.success is True
repo.save.assert_called_once()
saved_user = repo.save.call_args[0][0]
assert saved_user.subscription_status == "cancelled"
class TestToggleAutoRenew:
"""覆盖 toggle_auto_renew"""
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled,msg", [(True, "已开启"), (False, "已关闭")])
async def test_toggle_returns_message(self, auth_user, enabled, msg):
req = ToggleAutoRenewRequest(enabled=enabled)
resp = await subscription.toggle_auto_renew(request=req, current_user=auth_user)
assert resp.success is True
assert msg in resp.message
class TestBuildSubscriptionInfoEdgeCases:
"""覆盖 _build_subscription_info 的边缘分支"""
def test_created_at_none_uses_now(self, auth_user):
"""user.created_at 为 None 时,created_at 字段回落到 now.isoformat(覆盖 69 行)"""
new_user = replace(auth_user.user, created_at=None, subscription_plan=MembershipType.MONTHLY)
auth_user2 = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user2)
assert info.created_at # 非空
# 应为 ISO 格式字符串
from datetime import datetime
# 能解析即通过
datetime.fromisoformat(info.created_at)
+5 -2
View File
@@ -462,7 +462,9 @@ class TestQuotaTiers:
def test_all_tiers_exist(self):
from packages.domain.quota import QUOTA_TIERS
assert set(QUOTA_TIERS.keys()) == {"free", "basic", "premium", "pro"}
# 核心四档位 + 旧别名
for k in ("free", "monthly", "quarterly", "yearly", "basic", "premium", "pro", "standard", "enterprise"):
assert k in QUOTA_TIERS
# ============================================================
@@ -688,7 +690,8 @@ class TestQuotaRegistry:
reg = QuotaRegistry()
tiers = reg.list_tiers()
assert set(tiers) == {"free", "basic", "premium", "pro"}
for k in ("free", "monthly", "quarterly", "yearly", "basic", "premium", "pro"):
assert k in tiers
def test_register_new_dimension(self):
from packages.domain.quota import QuotaRegistry