Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia ea65033b01 fix(worker): 修复瘦身后PYTHONPATH缺少packages目录导致shared模块无法导入
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
2026-07-15 08:30:58 +08:00
1256 changed files with 45184 additions and 175198 deletions
+1 -2
View File
@@ -1,2 +1 @@
CI trigger file - safe to delete trigger: 1784009947
updated!
+27 -165
View File
@@ -1,198 +1,60 @@
# ============================================================ # 小虾 SaaS 环境变量配置
# 小虾 SaaS 环境变量完整配置
# ============================================================
# 本文件列出所有可配置的环境变量及默认值。
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
#
# 配置读取规则(pydantic-settings,大小写不敏感):
# 1. 系统环境变量(最高优先级)
# 2. .env.{APP_ENV} 文件(如 .env.staging
# 3. .env 文件
# 4. 代码中的默认值(最低优先级)
# ============================================================
# ==================== 应用配置 ====================
# ==================== 应用基本配置 ==================== APP_NAME=小虾 SaaS
APP_BASE_URL=http://localhost:3000
# 应用名称
APP_NAME=xiaoxia-saas
# 应用版本号(展示用,代码中已内置默认)
APP_VERSION=0.1.61
# 环境标识:development / staging / production
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
APP_ENV=development APP_ENV=development
# 是否开启 Debug 模式(开发环境 true,生产环境 false)
DEBUG=true
# 应用基础 URL,用于生成认证邮件、回调链接等
APP_BASE_URL=http://localhost:3000
# API 服务监听地址(容器内绑定,外部暴露由 Docker/Nginx 控制)
API_HOST=0.0.0.0
# API 服务监听端口
API_PORT=8000
# 是否自动创建数据库表结构(开发环境可开启,生产环境用 alembic migration
AUTO_CREATE_SCHEMA=false
# ==================== 数据库配置 ==================== # ==================== 数据库配置 ====================
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname # 开发环境:使用内存数据库(不需要 PostgreSQL
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas USE_IN_MEMORY_DB=true
# 连接池大小(常驻连接数)
DATABASE_POOL_SIZE=20
# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数)
DATABASE_MAX_OVERFLOW=10
# 获取连接超时时间(秒)
DATABASE_POOL_TIMEOUT=30
# 连接回收时间(秒),防止数据库端主动断开导致的死连接
DATABASE_POOL_RECYCLE=3600
# 是否使用内存数据库(SQLite,仅开发/测试可用;生产务必 false)
USE_IN_MEMORY_DB=false
# 生产环境:使用 PostgreSQL
# USE_IN_MEMORY_DB=false
# ==================== Redis 配置 ==================== # ==================== Redis 配置 ====================
# Redis 连接 URL(格式:redis://[:password@]host:port/db
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://localhost:6379/0
# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储) # ==================== JWT 配置 ====================
ENABLE_REDIS_SESSIONS=false
# ==================== Celery 任务队列 ====================
# Celery Broker(任务分发),默认用 Redis db0
CELERY_BROKER_URL=redis://localhost:6379/0
# Celery Result Backend(任务结果存储),默认用 Redis db1
CELERY_RESULT_BACKEND=redis://localhost:6379/1
# ==================== Worker 配置 ====================
# Worker 进程名称
WORKER_NAME=xiaoxia-saas-worker
# Worker 并发数(同时执行的任务数)
WORKER_CONCURRENCY=4
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
WORKER_MAX_TASKS_PER_CHILD=1000
# ==================== JWT 认证配置 ====================
# JWT 签名密钥 — 生产环境必须设置为强随机字符串(至少32字符)
# 内置不安全值会被拒绝:secret / changeme / password / your-secret-key 等
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
# JWT 签名算法
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
# Access Token 过期时间(分钟)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
# Refresh Token 过期时间(天)
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30 JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ==================== 邮件配置 ==================== # ==================== 邮件配置 ====================
# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用)
ENABLE_EMAIL_DELIVERY=false
# SMTP 服务器地址
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
# SMTP 端口
SMTP_PORT=587 SMTP_PORT=587
# SMTP 用户名
SMTP_USER=your-email@gmail.com SMTP_USER=your-email@gmail.com
# SMTP 密码 / 应用专用密码
SMTP_PASSWORD=your-app-specific-password SMTP_PASSWORD=your-app-specific-password
# 发件人邮箱
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
# 发件人显示名称
SMTP_FROM_NAME=小虾 SaaS SMTP_FROM_NAME=小虾 SaaS
# 是否启用 TLS # ==================== 环境配置 ====================
SMTP_USE_TLS=true ENVIRONMENT=development
DEBUG=true
# ==================== 阿里云 OSS 配置 ====================
# OSS 区域 endpoint
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
# OSS Access Key ID — 非开发环境必须设置
OSS_ACCESS_KEY_ID=your-access-key-id
# OSS Access Key Secret — 非开发环境必须设置
OSS_ACCESS_KEY_SECRET=your-access-key-secret
# OSS Bucket 名称
OSS_BUCKET_NAME=xiaoxia-autocut
# 直传最大文件大小(MB
OSS_DIRECT_UPLOAD_MAX_MB=2000
# 直传签名有效期(秒)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== CORS 配置 ==================== # ==================== CORS 配置 ====================
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
# 允许跨域的前端域名列表,逗号分隔 # ==================== 阿里云 OSS 配置 ====================
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000 OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=your-access-key-id
OSS_ACCESS_KEY_SECRET=your-access-key-secret
# ==================== 渲染引擎配置 ==================== OSS_BUCKET_NAME=xiaoxia-autocut
# 渲染引擎选择:
# legacy — 旧 VideoComposeService(稳定,功能完整)
# unified — 新 UnifiedRenderService(新架构,部分场景仍在验证)
RENDER_ENGINE=legacy
# ==================== CosyVoice 语音合成 ====================
# 阿里云百灵语音合成服务
# 模型选择:
# cosyvoice-v3-flash — 推荐,系统音色多,性价比高
# cosyvoice-v3-plus — 高质量,系统音色少
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus — 仅支持克隆/设计音色,无系统音色
# 音色:v3 系列系统音色带 _v3 后缀,如 longxiaochun_v3 / longxiaoxia_v3 / longanyang
# ==================== CosyVoice 语音合成配置 ====================
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
COSYVOICE_API_KEY=your-cosyvoice-api-key COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1 COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash COSYVOICE_MODEL=cosyvoice-v3-flash
COSYVOICE_VOICE=longxiaochun_v3 COSYVOICE_VOICE=longxiaochun_v3
COSYVOICE_SAMPLE_RATE=22050 COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3 COSYVOICE_FORMAT=mp3
# 音色克隆模型名(固定为 voice-enrollment,通常不需修改)
COSYVOICE_CLONE_MODEL=voice-enrollment
# ==================== 豆包大模型(火山引擎方舟) ====================
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
DOUBAO_API_KEY=your-doubao-api-key
DOUBAO_MODEL=doubao-seed-1-6-250615
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
+14
View File
@@ -0,0 +1,14 @@
[flake8]
max-line-length = 120
exclude =
.git,
.cache,
__pycache__,
.venv,
venv,
node_modules,
alembic
per-file-ignores =
tests/integration/*:F821
tests/unit/*:F821
-161
View File
@@ -1,161 +0,0 @@
name: ACR Cleanup
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
workflow_dispatch:
inputs:
pr_sha:
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
required: false
default: ""
dry_run:
description: "预览模式(dry-run),不实际删除"
required: false
default: "true"
pull_request_target:
types: [closed]
branches: [develop, main]
concurrency:
group: acr-cleanup-${{ gitea.ref }}
cancel-in-progress: false
jobs:
cleanup:
name: ACR Image Cleanup
runs-on: ci-l2
timeout-minutes: 20
permissions:
contents: read
env:
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
ACR_NAMESPACE: xiaoxiakeji
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
id: protected_images
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set +e
echo "获取staging服务器运行中镜像作为白名单..."
mkdir -p ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_port="${STAGING_SSH_PORT:-22222}"
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
# 获取所有运行容器的镜像,提取tag部分
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
PROTECTED_TAGS=""
if [ -n "$IMAGES" ]; then
while IFS= read -r img; do
# 从完整镜像名中提取tag(最后一个冒号后)
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
if [ -z "$PROTECTED_TAGS" ]; then
PROTECTED_TAGS="$tag"
else
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
fi
fi
done <<< "$IMAGES"
fi
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
# ====== Docker登录 ======
- name: Docker login to ACR
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
# ====== 模式1PR关闭时清理 ======
- name: Cleanup PR images (PR closed)
if: gitea.event_name == 'pull_request_target'
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
echo "============================================"
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
echo " Head SHA: ${PR_SHA::12}"
echo "============================================"
echo ""
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
--execute
# ====== 模式2Cron全量清理 ======
- name: Full cleanup (cron / manual)
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "============================================"
echo " ACR 全量清理(${{ gitea.event_name }}"
echo "============================================"
echo ""
# 决定是否dry-run
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
echo "模式: 预览模式 (dry-run)"
else
echo "模式: 执行模式"
fi
echo ""
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--protected-tags "$PROTECTED_TAGS" \
$DRY_RUN_FLAG
# ====== 模式3:手动指定PR SHA清理 ======
- name: Cleanup specific PR image (manual)
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "手动清理PR镜像: ${PR_SHA::12}"
echo ""
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
$DRY_RUN_FLAG
File diff suppressed because one or more lines are too long
-78
View File
@@ -1,78 +0,0 @@
name: CI Failure Monitor
on:
schedule:
- cron: '0 */6 * * *' # 每6小时检查一次
workflow_dispatch:
inputs:
days:
description: '统计最近N天的失败'
required: false
default: '7'
fail_threshold:
description: '失败次数阈值'
required: false
default: '3'
fail_rate_threshold:
description: '失败率阈值(%)'
required: false
default: '30'
permissions:
contents: read
jobs:
monitor:
name: CI重复失败检测
runs-on: ci-l2
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Run failure detection
shell: sh
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
FAIL_CHECK_DAYS: ${{ inputs.days || 7 }}
FAIL_THRESHOLD: ${{ inputs.fail_threshold || 3 }}
FAIL_RATE_THRESHOLD: ${{ inputs.fail_rate_threshold || 30 }}
run: |
set +e
python3 scripts/ci/ci_repeated_failure_detector.py
EXIT_CODE=$?
echo "检测完成,退出码: $EXIT_CODE"
# 0=无异常, 1=有警告, 2=有严重问题
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-103
View File
@@ -1,103 +0,0 @@
name: CI Health Daily Report
on:
schedule:
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
workflow_dispatch:
permissions:
contents: read
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: ci-l2
timeout-minutes: 15
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Generate CI Dashboard HTML
shell: sh
env:
GITEA_TOKEN: ${{ github.token }}
run: |
set +e
echo "=== 生成 CI 健康度 HTML 看板 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output ci_dashboard.html
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ] && [ -f ci_dashboard.html ]; then
HTML_SIZE=$(wc -c < ci_dashboard.html)
echo ""
echo "✅ HTML 看板生成成功 (${HTML_SIZE} bytes)"
echo "路径: $(pwd)/ci_dashboard.html"
# 输出文件内容前几行,方便在 Actions 日志中确认
echo ""
echo "--- 看板预览 (前 5 行) ---"
head -5 ci_dashboard.html
echo "...(完整内容见产物文件)"
else
echo "❌ HTML 看板生成失败 (exit code: $EXIT_CODE)"
fi
echo ""
# 永远成功,看板生成失败不影响主流程
exit 0
- name: Run CI health check and report
shell: sh
env:
GITEA_TOKEN: ${{ github.token }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
echo "=== CI健康度每日巡检 ==="
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
python3 scripts/ci/ci_health_report.py --limit 30
EXIT_CODE=$?
echo ""
echo "巡检完成 (exit code: $EXIT_CODE)"
# 永远成功,不影响CI状态(通知失败不应该标红)
exit 0
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
name: CI Trigger Monitor
on:
schedule:
- cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch:
inputs:
stale_threshold:
description: 'CI未触发告警阈值(分钟)'
required: false
default: '5'
permissions:
contents: read
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ci-l2
timeout-minutes: 5
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Check CI trigger status for all open PRs
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
GITEA_URL: https://git.xiaoxiajianji.com
GITEA_REPO: xiaoxia/xiaoxia-saas
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
run: |
set +e
python3 scripts/ci_trigger_monitor.py
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-79
View File
@@ -1,79 +0,0 @@
name: AI Code Review
on:
pull_request:
types:
- opened
- synchronize
- reopened
# 同一个 PR 只跑一个 review,新的取消旧的
concurrency:
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
code-review:
name: AI Code Review
runs-on: ci-l2
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Install dependencies
run: |
# 确保 python3-pip 可用(兼容不同基础镜像)
if ! python3 -m pip --version >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
fi
# 部分镜像 ensurepip 方式兜底
if ! python3 -m pip --version >/dev/null 2>&1; then
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
fi
python3 -m pip install --upgrade pip
python3 -m pip install requests
- name: Run AI Code Review
env:
# Gitea 配置(自动从运行环境获取)
GITEA_API_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
# 可选参数
MAX_DIFF_CHARS: "30000"
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 注意:脚本退出码决定job状态
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+134 -92
View File
@@ -1,5 +1,4 @@
name: Daily Health Check name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on: on:
schedule: schedule:
@@ -13,7 +12,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ───────────────────────────────────────────── # ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke: production-smoke:
name: Production Smoke Test name: Production Smoke Test
runs-on: ci-l2 runs-on: saas
timeout-minutes: 8 timeout-minutes: 8
outputs: outputs:
report: ${{ steps.smoke.outputs.report }} report: ${{ steps.smoke.outputs.report }}
@@ -24,9 +23,47 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \ set -eu
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ python3 - <<'PY'
| bash import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Production health check & smoke test - name: Production health check & smoke test
id: smoke id: smoke
shell: sh shell: sh
@@ -69,22 +106,10 @@ jobs:
echo "======================================" echo "======================================"
exit $SMOKE_EXIT exit $SMOKE_EXIT
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 2. Staging API 集成测试 ───────────────────────────────────────── # ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests: staging-api-tests:
name: Staging API Integration Tests name: Staging API Integration Tests
runs-on: ci-l2 runs-on: saas
timeout-minutes: 10 timeout-minutes: 10
outputs: outputs:
report: ${{ steps.smoke.outputs.report }} report: ${{ steps.smoke.outputs.report }}
@@ -95,15 +120,50 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \ set -eu
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ python3 - <<'PY'
| bash import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run API smoke test on staging - name: Run API smoke test on staging
id: smoke id: smoke
shell: sh shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: | run: |
set +e set +e
START_TIME=$(date +%s) START_TIME=$(date +%s)
@@ -111,8 +171,8 @@ jobs:
docker run --rm \ docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \ -e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \ -e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER="$STAGING_TEST_USER" \ -e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \ -e TEST_PASSWORD=Ying1234 \
-e CLEANUP_ENABLED=1 \ -e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \ -e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \ -e PERF_WARN_THRESHOLD_MS=500 \
@@ -183,22 +243,10 @@ jobs:
echo "report=FAIL" >> "${GITHUB_OUTPUT}" echo "report=FAIL" >> "${GITHUB_OUTPUT}"
fi fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 3. Staging 浏览器 E2E ────────────────────────────────────────── # ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e: staging-e2e:
name: Staging Browser E2E name: Staging Browser E2E
runs-on: ci-l2 runs-on: saas
timeout-minutes: 15 timeout-minutes: 15
outputs: outputs:
report: ${{ steps.smoke.outputs.report }} report: ${{ steps.smoke.outputs.report }}
@@ -209,9 +257,47 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \ set -eu
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \ python3 - <<'PY'
| bash import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run Playwright E2E on staging - name: Run Playwright E2E on staging
id: e2e id: e2e
shell: sh shell: sh
@@ -246,22 +332,10 @@ jobs:
echo "==========================================" echo "=========================================="
exit $EXIT_CODE exit $EXIT_CODE
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 4. 性能基线巡检 ──────────────────────────────────────────────── # ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check: performance-check:
name: Performance Baseline Check name: Performance Baseline Check
runs-on: ci-l2 runs-on: saas
timeout-minutes: 8 timeout-minutes: 8
outputs: outputs:
report: ${{ steps.report.outputs.report }} report: ${{ steps.report.outputs.report }}
@@ -270,9 +344,6 @@ jobs:
- name: Run performance baseline checks - name: Run performance baseline checks
id: perf id: perf
shell: sh shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: | run: |
set +e set +e
START_TIME=$(date +%s) START_TIME=$(date +%s)
@@ -308,10 +379,9 @@ jobs:
# 先登录获取 token # 先登录获取 token
echo "--- 准备: 获取测试 Token ---" echo "--- 准备: 获取测试 Token ---"
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \ AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$LOGIN_BODY" \ -d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \ "https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1) --max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1) AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -341,7 +411,7 @@ jobs:
# 构建 curl 命令 # 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30" CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\"" CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'" CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -389,9 +459,6 @@ jobs:
- name: Generate performance report - name: Generate performance report
id: report id: report
shell: sh shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: | run: |
set +e set +e
echo "" echo ""
@@ -406,11 +473,10 @@ jobs:
RESULTS="" RESULTS=""
START_TIME=$(date +%s) START_TIME=$(date +%s)
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
# 先登录获取 token # 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \ AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$LOGIN_BODY" \ -d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \ "https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1) --max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1) AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -426,7 +492,7 @@ jobs:
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30" local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\"" CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'" CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -514,22 +580,10 @@ jobs:
exit 0 exit 0
fi fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# ── 5. 每日巡检汇总报告 ──────────────────────────────────────────── # ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report: daily-report:
name: Daily Check Report name: Daily Check Report
runs-on: ci-l2 runs-on: saas
timeout-minutes: 2 timeout-minutes: 2
if: always() if: always()
needs: needs:
@@ -602,15 +656,3 @@ jobs:
# 不 exit 1,因为我们用了 always(),保持 report job 成功, # 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败 # 但其他失败的 job 已经让整体流水线标记为失败
fi fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-56
View File
@@ -1,56 +0,0 @@
name: PR Auto Scan
# 定时扫描所有open PR,对CI全绿的触发审批/合并
# 作为短作业模式的兜底,防止事件驱动遗漏
on:
schedule:
- cron: "*/5 * * * *" # 每5分钟扫描一次
workflow_dispatch:
permissions:
contents: read
jobs:
auto-scan:
name: Auto Scan Open PRs
runs-on: ci-check
timeout-minutes: 5
if: github.repository == 'xiaoxia/xiaoxia-saas'
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/pr_auto_scan.py?ref=develop" -o /tmp/pr_auto_scan.py
python3 /tmp/pr_auto_scan.py --help > /dev/null 2>&1 || {
# fallback: checkout
echo "使用checkout方式"
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=develop" | bash
}
- name: Scan and auto process PRs
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
run: |
set -eu
echo "=== 扫描所有open PR并自动处理 ==="
echo "时间: $(date)"
echo
python3 /tmp/pr_auto_scan.py --token "$REVIEW_TOKEN" --repo "$GITHUB_REPOSITORY" --base develop --approve --merge --dry-run false
echo ""
echo "✅ 扫描完成"
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "" || true
-113
View File
@@ -1,113 +0,0 @@
name: PR Automation
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
workflow_dispatch:
permissions:
contents: read
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: "🔍 脚本语法自检"
shell: bash
run: |
ERROR=0
for f in scripts/ci/*.sh; do [ -f "$f" ] && bash -n "$f" 2>&1 || ERROR=$((ERROR+1)); done
for f in scripts/ci/*.py; do [ -f "$f" ] && python3 -m py_compile "$f" 2>&1 || ERROR=$((ERROR+1)); done
if [ "$ERROR" -ne 0 ]; then echo "❌ 语法自检失败 ($ERROR个)"; exit 1; fi
echo "✅ 脚本语法自检通过"
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
bash scripts/ci/auto_approve.sh
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
auto-merge:
name: Auto Merge on CI Green + Approved
runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: "🔍 脚本语法自检(防止脚本bug导致所有PR挂掉)"
shell: bash
run: |
echo "=== CI脚本语法自检 ==="
ERROR=0
for f in scripts/ci/*.sh; do
[ -f "$f" ] || continue
if ! bash -n "$f" 2>&1; then
echo "FAIL: $f"
ERROR=1
fi
done
for f in scripts/ci/*.py; do
[ -f "$f" ] || continue
if ! python3 -m py_compile "$f" 2>&1; then
echo "FAIL: $f"
ERROR=1
fi
done
if [ "$ERROR" -ne 0 ]; then
echo "❌ 脚本语法自检失败"
exit 1
fi
echo "✅ 所有CI脚本语法自检通过"
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
bash scripts/ci/auto_merge.sh
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-207
View File
@@ -1,207 +0,0 @@
name: Preview Cleanup
on:
pull_request:
types:
- closed
branches:
- main
- develop
permissions:
contents: read
pull-requests: write
jobs:
cleanup-preview:
name: Cleanup Preview Environment
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Extract PR number
shell: sh
run: |
set -eu
# 优先从event payload中读取(兼容所有PR事件类型)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
fi
# fallback: 从GITHUB_REF中提取
if [ -z "${PR_NUMBER:-}" ]; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
# 再fallback: 兼容纯数字ref
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
fi
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
- name: Install SSH client
shell: sh
run: |
set -eu
# 先检查是否已存在ssh
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
echo "SSH client already available: $(ssh -V 2>&1)"
exit 0
fi
# 尝试多种包管理器安装
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client >/dev/null 2>&1
echo "openssh-client installed via apk"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed via apt-get"
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via yum"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y openssh-clients >/dev/null 2>&1
echo "openssh-client installed via dnf"
else
echo "ERROR: No package manager found and ssh not pre-installed"
which ssh 2>/dev/null || echo " ssh: not found"
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
exit 1
fi
- name: Remove preview directory from server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 检查目录是否存在
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
if [ "$DIR_EXISTS" = "yes" ]; then
echo "Removing preview directory: ${preview_dir}"
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
echo "Cleanup completed: ${preview_dir}"
else
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
fi
- name: Comment cleanup notice on PR
if: success()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
# 从event payload读取PR号(最可靠)
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
else
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
fi
export PR_NUMBER
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Cleanup comment posted"
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-283
View File
@@ -1,283 +0,0 @@
name: Preview Deploy
on:
pull_request:
types:
- opened
- synchronize
- reopened
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - 预览环境补跑"
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
name: Deploy Preview Environment
runs-on: runtime-builder
timeout-minutes: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Record job start time
shell: sh
run: |
set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
- name: Extract PR number
shell: sh
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
echo "PR number: $PR_NUMBER"
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Build frontend
shell: sh
run: |
set -eu
cd apps/web
# Config npm mirror for speed
npm config set registry https://registry.npmmirror.com
# Install dependencies with retry
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
done
# TypeScript check
echo "=== TypeScript check ==="
npx --no-install tsc --noEmit
# Vite build
echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
npx --no-install vite build
echo "=== Build completed ==="
ls -la dist/
- name: Install SSH client and rsync
shell: sh
run: |
set -eu
if command -v apk >/dev/null 2>&1; then
apk add --no-cache openssh-client rsync >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y openssh-clients rsync >/dev/null 2>&1
else
echo "ERROR: No package manager found"
exit 1
fi
echo "openssh-client and rsync installed"
- name: Deploy preview to server
shell: sh
env:
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
preview_user="${PREVIEW_SSH_USER:-root}"
preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
mkdir -p ~/.ssh
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key
key_path=""
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using key from PREVIEW_SSH_KEY secret"
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (builder key)"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
echo "Using key: $key_path (home key)"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
ls -la /root/.ssh/ 2>/dev/null || true
exit 1
fi
# SSH密钥完整性自检
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
echo "ERROR: SSH密钥损坏(private key contents do not match public"
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
head -2 "$key_path"
exit 1
fi
echo "SSH key integrity check passed"
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH keyscan done"
# 测试SSH连接
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
echo "SSH connection verified"
# 创建预览目录并上传文件
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
# 使用rsync上传dist目录内容
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
apps/web/dist/ \
"${preview_user}@${preview_host}:${preview_dir}/"
echo "Preview deployed to: ${preview_dir}"
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
- name: Comment preview link on PR
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
export PR_NUMBER PREVIEW_URL
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
import sys, json
try:
for c in json.load(sys.stdin):
if '预览环境已部署' in c.get('body', ''):
print(c['id'])
break
except Exception:
pass
")
if [ -n "$EXISTING_COMMENT_ID" ]; then
curl -s -X PATCH \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
> /dev/null
echo "Comment updated"
else
curl -s -X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$COMMENT_BODY" \
"$API_URL" \
> /dev/null
echo "Comment posted"
fi
- name: Job duration summary
if: always()
shell: sh
run: |
set +eu
if [ -n "$JOB_START_TIME" ]; then
END_TIME=$(date +%s)
DURATION=$((END_TIME - JOB_START_TIME))
MINS=$((DURATION / 60))
SECS=$((DURATION % 60))
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
echo "=== Job Duration: ${MINS}m${SECS}s ==="
else
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
echo "=== Job Duration: unknown ==="
fi
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
-103
View File
@@ -1,103 +0,0 @@
name: Worker Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements-worker.txt'
- 'infra/docker/worker-base-builder.Dockerfile'
- 'infra/docker/worker-base-runtime.Dockerfile'
workflow_dispatch: # 支持手动触发
jobs:
build-worker-base:
name: Build Worker Base Images
runs-on: runtime-builder
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: builder
dockerfile: infra/docker/worker-base-builder.Dockerfile
image_name: worker-base-builder
cache_name: worker-base-builder-cache
- name: runtime
dockerfile: infra/docker/worker-base-runtime.Dockerfile
image_name: worker-base-runtime
cache_name: worker-base-runtime-cache
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
- name: Docker login to Registry
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Setup buildx builder
shell: sh
run: |
set -eu
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
echo "Created $BUILDER_NAME"
else
docker buildx use "$BUILDER_NAME"
echo "Using existing $BUILDER_NAME"
fi
docker buildx inspect --bootstrap
- name: Build and push base image
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
echo "=== Building ${{ matrix.name }} base image ==="
echo "Image: ${IMAGE_TAG}"
echo "Cache: ${CACHE_REF}"
# 用通用构建脚本
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
# 同时推送到 Gitea Packages 作为备份(可选)
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
echo ""
echo "✅ ${{ matrix.name }} base image built and pushed"
- name: Cleanup buildx builder
if: always()
shell: sh
run: |
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
-5
View File
@@ -49,8 +49,3 @@ build/
tracker_tasks.json tracker_tasks.json
frontend-v21-ui-prototype-final.html frontend-v21-ui-prototype-final.html
!.vscode/
!.vscode/settings.json
.vscode/extensions.json
.coverage
-29
View File
@@ -1,29 +0,0 @@
repos:
- repo: https://github.com/psf/black
rev: 26.5.1
hooks:
- id: black
language_version: python3.12
- repo: https://github.com/pycqa/isort
rev: 8.0.1
hooks:
- id: isort
args: ["--profile", "black"]
language_version: python3.12
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix]
language_version: python3.12
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.11
hooks:
- id: prettier
name: prettier (frontend)
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
additional_dependencies:
- prettier@3.4.2
-49
View File
@@ -1,49 +0,0 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.requireConfig": true,
"isort.args": ["--profile", "black"],
"python.linting.ruffEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}
Executable → Regular
+4 -7
View File
@@ -1,3 +1,4 @@
import os
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
@@ -7,17 +8,13 @@ from alembic import context
# Import your models' Base here # Import your models' Base here
from packages.adapters.sqlalchemy_impl.models import Base from packages.adapters.sqlalchemy_impl.models import Base
# 使用统一配置入口获取 database_url,而非直接读环境变量
from packages.config import get_shared_settings
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config
# 从统一配置系统获取 database_url,确保与应用使用同一配置源 database_url = os.getenv("DATABASE_URL")
settings = get_shared_settings() if database_url:
if settings.database_url: config.set_main_option("sqlalchemy.url", database_url)
config.set_main_option("sqlalchemy.url", settings.database_url)
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
@@ -1,29 +0,0 @@
"""add result_count to edit_plans
Revision ID: 041_result_count
Revises: 040_playback_speed
Create Date: 2026-07-15 14:05:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "041_result_count"
down_revision = "040_playback_speed"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plans",
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("edit_plans", "result_count")
@@ -1,29 +0,0 @@
"""add storage_key to assets
Revision ID: 042_storage_key
Revises: 041_result_count
Create Date: 2026-07-17 18:10:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "042_storage_key"
down_revision = "041_result_count"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"assets",
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("assets", "storage_key")
@@ -1,33 +0,0 @@
"""add updated_at to generation_tasks
Revision ID: 043_updated_at_generation_tasks
Revises: 042_storage_key
Create Date: 2026-07-18 19:30:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "043_updated_at_generation_tasks"
down_revision = "042_storage_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"updated_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "updated_at")
@@ -1,34 +0,0 @@
"""add user_id to generated_videos
Revision ID: 044_user_id_generated_videos
Revises: 043_updated_at_generation_tasks
Create Date: 2026-07-19 08:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "044_user_id_generated_videos"
down_revision = "043_updated_at_generation_tasks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generated_videos",
sa.Column(
"user_id",
sa.String(36),
nullable=False,
server_default="",
index=True,
),
)
def downgrade() -> None:
op.drop_column("generated_videos", "user_id")
-36
View File
@@ -1,36 +0,0 @@
"""backfill user_id for generated_videos from generation_tasks
Revision ID: 045_backfill_user_id_generated_videos
Revises: 044_user_id_generated_videos
Create Date: 2026-07-19 10:50:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "045_backfill_user_id"
down_revision = "044_user_id_generated_videos"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 回填 generated_videos.user_id:通过 generation_task_id 关联 generation_tasks 表
# 取 generation_tasks.created_by_user_id 作为 user_id
# 回填不到的(无关联task的兜底记录)保持空字符串
op.execute("""
UPDATE generated_videos gv
SET user_id = gt.created_by_user_id
FROM generation_tasks gt
WHERE gv.generation_task_id = gt.id
AND gv.user_id = ''
AND gt.created_by_user_id != ''
""")
def downgrade() -> None:
# 降级不做处理(无法精确区分哪些是回填的)
pass
-33
View File
@@ -1,33 +0,0 @@
"""add video_title to generation_tasks
Revision ID: 046_add_video_title_to_generation_tasks
Revises: 045_backfill_user_id_generated_videos
Create Date: 2026-07-19 11:20:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "046_task_title"
down_revision = "045_backfill_user_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"video_title",
sa.String(255),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "video_title")
@@ -1,64 +0,0 @@
"""Phase 2 - 模板发布版本化:version字段 + 发布历史表
Revision ID: 047
Revises: 046
Create Date: 2026-07-20
Changes:
1. edit_templates 加 version 字段(INT,默认1,每次发布+1)
2. 新建 edit_template_versions 表存发布历史快照,支持回滚
"""
import sqlalchemy as sa
from alembic import op
revision = "047_template_versioning"
down_revision = "046_task_title"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# 1. edit_templates 加 version 字段
op.add_column(
"edit_templates",
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
)
# 2. 新建 edit_template_versions 发布历史表
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS edit_template_versions (
id VARCHAR(36) PRIMARY KEY,
template_id VARCHAR(32) NOT NULL,
version INTEGER NOT NULL,
name VARCHAR(200) NOT NULL DEFAULT '',
editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take',
config JSONB NOT NULL DEFAULT '{}',
clip_configs JSONB NOT NULL DEFAULT '[]',
change_note VARCHAR(500) NOT NULL DEFAULT '',
published_by VARCHAR(36) NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)"
)
)
conn.execute(
sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version "
"ON edit_template_versions(template_id, version)"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions"))
op.drop_column("edit_templates", "version")
@@ -1,37 +0,0 @@
"""Phase 3 - 清理 EditPlan 表冗余字段
Revision ID: 048
Revises: 047
Create Date: 2026-07-21
Changes:
1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用,
生成结果数由 generation_tasks.result_count 承载)
"""
import sqlalchemy as sa
from alembic import op
revision = "048_cleanup_result_count"
down_revision = "047_template_versioning"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 删除 result_count 字段(剪辑计划独立功能遗留字段)
op.drop_column("edit_plans", "result_count")
def downgrade() -> None:
# 回滚:恢复 result_count 字段,默认值 0
op.add_column(
"edit_plans",
sa.Column(
"result_count",
sa.Integer,
nullable=False,
server_default="0",
),
)
@@ -1,97 +0,0 @@
"""#558 - 微信登录:手机号绑定字段 + 验证码表
Revision ID: 049
Revises: 048
Create Date: 2026-07-21
Changes:
1. users 表新增 phone_verified / binding_completed_at 字段(phone 字段已在 029 中添加)
2. users 表 phone 字段添加唯一索引(幂等)
3. 新建 verification_codes 表(统一管理邮箱+手机验证码)
"""
import sqlalchemy as sa
from alembic import context, op
revision = "049_wechat_login_phone"
down_revision = "048_cleanup_result_count"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
"""检查列是否已存在。离线模式下返回 False。"""
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
{"table": table, "column": column},
)
return result.first() is not None
def _index_exists(index_name: str) -> bool:
"""检查索引是否已存在。离线模式下返回 False。"""
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
{"index_name": index_name},
)
return result.first() is not None
def upgrade() -> None:
# 1. users 表新增手机号验证状态字段(幂等)
if not _column_exists("users", "phone_verified"):
op.add_column(
"users",
sa.Column(
"phone_verified",
sa.Boolean,
nullable=False,
server_default=sa.text("false"),
),
)
if not _column_exists("users", "binding_completed_at"):
op.add_column(
"users",
sa.Column("binding_completed_at", sa.DateTime, nullable=True),
)
# 2. phone 字段唯一索引(幂等 - 029 加了字段但没加索引)
if not _index_exists("ix_users_phone"):
op.create_index("ix_users_phone", "users", ["phone"], unique=True)
# 3. verification_codes 表
op.create_table(
"verification_codes",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("recipient", sa.String(255), nullable=False, index=True),
sa.Column("code", sa.String(10), nullable=False),
sa.Column("code_type", sa.String(32), nullable=False, index=True),
sa.Column("expires_at", sa.DateTime, nullable=False),
sa.Column("used_at", sa.DateTime, nullable=True),
sa.Column("attempts", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime, nullable=False),
sa.Index(
"ix_verification_recipient_type",
"recipient",
"code_type",
"created_at",
),
)
def downgrade() -> None:
op.drop_table("verification_codes")
if _index_exists("ix_users_phone"):
op.drop_index("ix_users_phone", table_name="users")
if _column_exists("users", "binding_completed_at"):
op.drop_column("users", "binding_completed_at")
if _column_exists("users", "phone_verified"):
op.drop_column("users", "phone_verified")
-48
View File
@@ -1,48 +0,0 @@
"""#P3-2 - 视频分享表 video_shares
Revision ID: 050
Revises: 049
Create Date: 2026-07-22
Changes:
1. 新建 video_shares 表,支持视频匿名分享链接
2. share_token 唯一索引,用于公开分享URL
3. 支持密码保护、有效期、浏览/下载计数
"""
import sqlalchemy as sa
from alembic import context, op
revision = "050_video_shares"
down_revision = "049_wechat_login_phone"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查表是否已存在(幂等)
result = conn.execute(sa.text("SELECT to_regclass('public.video_shares')"))
if result.scalar() is not None:
return
op.create_table(
"video_shares",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("video_id", sa.String(32), nullable=False, index=True),
sa.Column("user_id", sa.String(32), nullable=False, index=True),
sa.Column("share_token", sa.String(16), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("view_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("download_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("video_shares")
@@ -1,53 +0,0 @@
"""#632 - 一键生成输出分辨率可配置
Revision ID: 051
Revises: 050
Create Date: 2026-07-23
Changes:
1. generation_tasks 表新增 resolution 字段,存储用户指定的输出分辨率(如 "1280x720"
2. 为空时使用默认值(1280x720)
"""
import sqlalchemy as sa
from alembic import context, op
revision = "051_generation_task_resolution"
down_revision = "050_video_shares"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column("resolution", sa.String(20), nullable=False, server_default=""),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "resolution")
@@ -1,64 +0,0 @@
"""#642 - 生成任务新增 bgm_config 字段
Revision ID: 052_generation_task_bgm_config
Revises: 051_generation_task_resolution
Create Date: 2026-07-25
Changes:
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
2. 为空时使用默认空字典
背景:
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
创建生成任务时直接 500。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "052_generation_task_bgm_config"
down_revision = "051_generation_task_resolution"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column(
"bgm_config",
sa.JSON,
nullable=False,
server_default=sa.text("'{}'::json"),
),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "bgm_config")
BIN
View File
Binary file not shown.
View File
+4 -15
View File
@@ -1,4 +1,3 @@
from app.api.routes.ai import router as ai_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.asset_libraries import router as asset_libraries_router
from app.api.routes.assets import router as assets_router from app.api.routes.assets import router as assets_router
@@ -6,18 +5,17 @@ from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.duplication import router as duplication_router from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.feature_flags import router as feature_flags_router from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.projects import router as projects_router from app.api.routes.projects import router as projects_router
from app.api.routes.share import router as share_router
from app.api.routes.subscription import router as subscription_router from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router from app.api.routes.templates import router as templates_router
from app.api.routes.templates_editor import router as templates_editor_router
from app.api.routes.titles import router as titles_router from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router from app.api.routes.upload import router as upload_router
@@ -106,10 +104,6 @@ api_router.include_router(
videos_router, videos_router,
tags=["VideoCenter"], tags=["VideoCenter"],
) )
api_router.include_router(
share_router,
tags=["Share"],
)
api_router.include_router( api_router.include_router(
duplication_router, duplication_router,
prefix="/duplication", prefix="/duplication",
@@ -126,20 +120,15 @@ api_router.include_router(
tags=["Template"], tags=["Template"],
) )
api_router.include_router( api_router.include_router(
templates_editor_router, edit_plans_router,
prefix="/templates/{template_id}/editor", prefix="/edit-plans",
tags=["TemplateEditor"], tags=["EditPlan"],
) )
api_router.include_router( api_router.include_router(
tts_router, tts_router,
prefix="/tts", prefix="/tts",
tags=["TTS"], tags=["TTS"],
) )
api_router.include_router(
ai_router,
prefix="/ai",
tags=["AI"],
)
api_router.include_router( api_router.include_router(
feature_flags_router, feature_flags_router,
tags=["Internal"], tags=["Internal"],
-93
View File
@@ -1,6 +1,5 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。""" """路由层共享辅助函数 — 消除跨文件重复定义。"""
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -47,95 +46,3 @@ def require_project_and_library(
libraries = asset_library_repository.find_by_project(project_id) libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries): if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def auto_select_video_assets(
*,
project_id: str,
asset_library_repo: Any,
asset_repo: Any,
logger=None,
) -> list[str]:
"""从项目视频素材库自动选取 ready 状态的视频素材。
Args:
project_id: 项目 ID
asset_library_repo: 素材库仓储
asset_repo: 素材仓储
logger: 可选的 logger 实例,用于记录警告
Returns:
选中的素材 ID 列表,无可用素材时返回空列表
"""
if not project_id:
return []
# 找到项目的视频素材库
libs = asset_library_repo.find_by_project(project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if not video_lib:
if logger:
logger.warning("自动选素材: 项目 %s 无视频素材库", project_id)
return []
# 从素材库中选取 ready 状态的视频素材
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
# 过滤横屏素材(只保留竖屏/正方形)
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
# 注意:这只是选素材优化,渲染引擎本身支持任何分辨率的素材
filtered_videos = []
skipped_landscape = 0
for a in ready_videos:
width = a.width if hasattr(a, "width") and a.width else 0
height = a.height if hasattr(a, "height") and a.height else 0
if not width or not height:
# 从 metadata 兜底
if a.metadata and isinstance(a.metadata, dict):
width = int(a.metadata.get("width", 0) or 0)
height = int(a.metadata.get("height", 0) or 0)
if width and height and width > height:
skipped_landscape += 1
continue
filtered_videos.append(a)
if skipped_landscape and logger:
logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape)
if not filtered_videos:
if logger:
logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name)
return []
# 按创建时间降序(新素材在前)
filtered_videos.sort(key=lambda a: a.created_at, reverse=True)
return [a.id for a in filtered_videos]
def format_utc_datetime(dt: datetime | None) -> str:
"""将数据库读出的 UTC naive datetime 格式化为带时区的 ISO 8601 字符串。
数据库 DateTime 列不带时区信息,但存的是 UTC 时间。
直接 .isoformat() 输出无时区标识,前端会按本地时间解析,导致差 8 小时。
输出带 Z 后缀,前端 new Date() 自动转本地时间。
"""
if dt is None:
return ""
if isinstance(dt, str):
return dt
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
-136
View File
@@ -1,136 +0,0 @@
"""AI 相关接口 — 智能标题、智能素材匹配等.
基于豆包大模型的 AI 能力接口,未配置 API Key 时自动降级为本地模拟。
"""
from __future__ import annotations
from typing import List, Literal
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
from fastapi import APIRouter
from pydantic import BaseModel, Field
router = APIRouter()
# ── 请求/响应模型 ────────────────────────────────────────────────────────────
class GenerateTitlesRequest(BaseModel):
"""智能标题生成请求."""
description: str = Field(..., min_length=1, max_length=500, description="视频内容描述")
style: Literal["viral", "emotional", "informative"] = Field(
default="viral",
description="标题风格:viral爆款 / emotional情感 / informative信息",
)
count: int = Field(default=5, ge=3, le=10, description="生成数量,3-10个")
class GenerateTitlesResponse(BaseModel):
"""智能标题生成响应."""
titles: List[str] = Field(..., description="生成的标题列表")
style: str = Field(..., description="实际使用的风格")
source: str = Field(..., description="来源:doubao 或 fallback")
description: str = Field(..., description="原始描述")
class TitleStyleInfo(BaseModel):
"""标题风格信息."""
key: str
name: str
description: str
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
class AssetMatchItem(BaseModel):
"""待匹配素材项."""
id: str = Field(..., description="素材ID")
name: str = Field(default="", description="素材名称")
tags: List[str] = Field(default_factory=list, description="标签列表")
description: str = Field(default="", description="素材描述")
class SemanticMatchRequest(BaseModel):
"""语义匹配请求."""
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
class SemanticMatchResultItem(AssetMatchItem):
"""匹配结果项."""
match_score: float = Field(..., description="匹配度评分 0-1")
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
class SemanticMatchResponse(BaseModel):
"""语义匹配响应."""
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
source: str = Field(..., description="来源:doubao / fallback")
description: str = Field(..., description="原始描述")
total: int = Field(..., description="输入素材总数")
# ── 路由 ────────────────────────────────────────────────────────────────────
@router.post("/titles/generate", response_model=GenerateTitlesResponse)
def generate_titles(request: GenerateTitlesRequest):
"""生成智能标题.
根据视频描述生成指定风格的标题,支持爆款、情感、信息三种风格。
未配置豆包 API Key 时自动降级为本地规则生成。
"""
result = generate_smart_titles(
description=request.description,
style=request.style,
count=request.count,
)
return GenerateTitlesResponse(**result)
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
def list_title_styles():
"""获取支持的标题风格列表."""
return [
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
for key, info in TITLE_STYLES.items()
]
@router.post("/assets/match", response_model=SemanticMatchResponse)
def match_assets(request: SemanticMatchRequest):
"""智能素材语义匹配.
根据用户描述,对素材列表做语义匹配并按匹配度排序。
未配置豆包 API Key 时自动降级为关键词匹配。
- 支持最多 100 个素材同时匹配
- 返回 match_score (0-1),按降序排列
- top_k 可限制返回数量
"""
# 转为 dict 传给服务层
assets_dict = [asset.model_dump() for asset in request.assets]
result = semantic_match_assets(
description=request.description,
assets=assets_dict,
top_k=request.top_k,
)
return SemanticMatchResponse(
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
source=result["source"],
description=result["description"],
total=result["total"],
)
+33 -98
View File
@@ -1,7 +1,7 @@
import logging import logging
from typing import Any, Optional from typing import Any, Optional
from app.api.routes._helpers import check_project_access, format_utc_datetime from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service from app.core.storage import get_storage_service
from app.dependencies import ( from app.dependencies import (
@@ -71,7 +71,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
status=item.status.value, status=item.status.value,
classification_status=item.classification_status.value, classification_status=item.classification_status.value,
quality_score=item.quality_score, quality_score=item.quality_score,
created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id, uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []), tag_ids=getattr(item, "tag_ids", []),
) )
@@ -95,12 +94,6 @@ def list_assets(
None, None,
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他", description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
), ),
status: Optional[str] = Query(
"default",
description="按状态筛选,逗号分隔多值;默认返回除deleted外的所有状态;传deleted查看回收站;传all返回所有状态",
),
page: Optional[int] = Query(None, ge=1, description="页码,从1开始;与 page_size 配对使用,优先于 skip/limit"),
page_size: Optional[int] = Query(None, ge=1, le=500, description="每页数量;与 page 配对使用"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -110,26 +103,6 @@ def list_assets(
) -> ListAssetsResponse: ) -> ListAssetsResponse:
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
# ── 分页:page/page_size 优先于 skip/limit
if page is not None and page_size is not None:
skip = (page - 1) * page_size
limit = page_size
# ── 解析 status 过滤
status_list: list[str] | None
if status and status.lower() == "all":
status_list = None # None = 不过滤,返回所有状态
elif status and status.lower() == "deleted":
status_list = ["deleted"] # 仅查回收站
elif status and status.lower() == "default":
status_list = ["ready", "uploading", "processing", "error"] # 默认排除deleted
elif status:
status_list = [s.strip() for s in status.split(",") if s.strip()]
if not status_list:
status_list = ["ready", "uploading", "processing", "error"]
else:
status_list = ["ready", "uploading", "processing", "error"]
# kind → file_type 映射(voice 对应 audio # kind → file_type 映射(voice 对应 audio
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"} kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
@@ -198,13 +171,11 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository) check_project_access(library.project_id, user_id, project_repository)
if ft: if ft:
items = asset_repository.find_by_library_and_file_type( items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
library_id, ft, skip=skip, limit=limit, status=status_list total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
)
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
else: else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list) items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id, status=status_list) total = asset_repository.count_by_project(library.project_id)
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in items], items=[_to_asset_response(item) for item in items],
total=total, total=total,
@@ -216,14 +187,14 @@ def list_assets(
if project_id: if project_id:
check_project_access(project_id, user_id, project_repository) check_project_access(project_id, user_id, project_repository)
if ft: if ft:
items = asset_repository.find_by_project_and_file_type( # 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
project_id, ft, skip=skip, limit=limit, status=status_list all_items = asset_repository.find_by_project(project_id)
) items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
total = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list) total = len(items)
paged = items paged = items[skip : skip + limit]
else: else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list) items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(project_id, status=status_list) total = asset_repository.count_by_project(project_id)
paged = items paged = items
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged], items=[_to_asset_response(item) for item in paged],
@@ -243,43 +214,22 @@ def list_assets(
if not project_ids: if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
if ft: total = asset_repository.count_by_project_ids(project_ids)
# 有 kind 过滤:逐项目查 file_type凑够一页 # 跨项目分页:逐项目累积直到凑够一页
total = 0 paged_items: list = []
paged_items: list = [] offset = skip
offset = skip remaining = limit
remaining = limit for pid in project_ids:
for pid in project_ids: proj_total = asset_repository.count_by_project(pid)
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list) if offset >= proj_total:
total += proj_total offset -= proj_total
if offset >= proj_total: continue
offset -= proj_total proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
continue paged_items.extend(proj_items)
proj_items = asset_repository.find_by_project_and_file_type( remaining -= len(proj_items)
pid, ft, skip=offset, limit=remaining, status=status_list offset = 0
) if remaining <= 0:
paged_items.extend(proj_items) break
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
else:
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid, status=status_list)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
return ListAssetsResponse( return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged_items], items=[_to_asset_response(item) for item in paged_items],
@@ -295,21 +245,12 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository) check_project_access(library.project_id, user_id, project_repository)
if kind: if kind:
all_items = asset_repository.find_by_library_and_file_type( all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
library_id, kind_to_file_type[kind], status=status_list
)
else: else:
all_items = asset_repository.find_by_library(library_id, status=status_list) all_items = asset_repository.find_by_library(library_id)
elif project_id: elif project_id:
check_project_access(project_id, user_id, project_repository) check_project_access(project_id, user_id, project_repository)
if kind: all_items = asset_repository.find_by_project(project_id)
ft = kind_to_file_type.get(kind)
if ft:
all_items = asset_repository.find_by_project_and_file_type(project_id, ft, status=status_list)
else:
all_items = asset_repository.find_by_project(project_id, status=status_list)
else:
all_items = asset_repository.find_by_project(project_id, status=status_list)
else: else:
try: try:
projects = project_repository.find_accessible_projects(user_id) projects = project_repository.find_accessible_projects(user_id)
@@ -318,18 +259,12 @@ def list_assets(
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit) return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = [] all_items = []
for proj in projects: for proj in projects:
if kind and kind_to_file_type.get(kind): all_items.extend(asset_repository.find_by_project(proj.id))
all_items.extend(
asset_repository.find_by_project_and_file_type(proj.id, kind_to_file_type[kind], status=status_list)
)
else:
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
# 应用 kind 过滤(如果有)+ keyword/gender/style # 应用 kind 过滤(如果有)+ keyword/gender/style
if kind: if kind:
ft = kind_to_file_type.get(kind) ft = kind_to_file_type.get(kind)
if ft: all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
all_items = [i for i in all_items if i.file_type == ft]
filtered = _apply_memory_filters(all_items) filtered = _apply_memory_filters(all_items)
total = len(filtered) total = len(filtered)
paged = filtered[skip : skip + limit] paged = filtered[skip : skip + limit]
-206
View File
@@ -81,9 +81,6 @@ class CurrentUserResponse(BaseModel):
username: str username: str
display_name: str display_name: str
email_verified: bool email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
class PasswordResetRequestModel(BaseModel): class PasswordResetRequestModel(BaseModel):
@@ -262,16 +259,12 @@ async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> CurrentUserResponse: ) -> CurrentUserResponse:
user = authenticated_user.user user = authenticated_user.user
binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
return CurrentUserResponse( return CurrentUserResponse(
user_id=user.id, user_id=user.id,
email=user.email, email=user.email,
username=user.username, username=user.username,
display_name=user.display_name, display_name=user.display_name,
email_verified=user.email_verified, email_verified=user.email_verified,
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
) )
@@ -387,202 +380,3 @@ async def wechat_sync(
raise HTTPException(status_code=400, detail=error) raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict()) return WechatSyncResponse(**response.to_dict())
# ==================== 微信网页登录(OAuth ====================
class WechatAuthUrlResponse(BaseModel):
auth_url: str
state: str
class WechatCallbackRequest(BaseModel):
code: str
state: str = ""
class WechatLoginResponse(BaseModel):
access_token: str
refresh_token: str
user_id: str
display_name: str
avatar_url: str = ""
is_new_user: bool
binding_complete: bool
expires_in: int
@router.get("/wechat/url", response_model=WechatAuthUrlResponse)
async def get_wechat_auth_url() -> WechatAuthUrlResponse:
"""获取微信扫码登录授权链接"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
auth_url, state = oauth_service.generate_auth_url()
return WechatAuthUrlResponse(auth_url=auth_url, state=state)
@router.post("/wechat/callback", response_model=WechatLoginResponse)
async def wechat_callback(
request: WechatCallbackRequest,
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatLoginResponse:
"""微信登录回调处理"""
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
# 1. 用 code 换微信用户信息
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
raise HTTPException(status_code=400, detail=err)
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
use_case = WechatSyncUseCase(user_repository=user_repository)
sync_request = SyncRequest(
openid=wechat_user.openid,
unionid=wechat_user.unionid,
nickname=wechat_user.nickname,
avatar_url=wechat_user.avatar_url,
source="web",
)
response, err = use_case.execute(sync_request)
if err:
raise HTTPException(status_code=400, detail=err)
# 3. 判断绑定状态
user = user_repository.find_by_id(response.user_id)
binding_complete = False
if user:
binding_complete = (
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return WechatLoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
display_name=response.nickname,
avatar_url=response.avatar_url or wechat_user.avatar_url,
is_new_user=response.is_new_user,
binding_complete=binding_complete,
expires_in=response.expires_in,
)
# ==================== 验证码 & 绑定 ====================
class SendVerificationCodeRequest(BaseModel):
target: str # phone / email
value: str
purpose: str # bind / login / reset_password
class SendVerificationCodeResponse(BaseModel):
expires_in: int
resend_after: int
class BindContactRequest(BaseModel):
phone: str = ""
phone_code: str = ""
email: str = ""
email_code: str = ""
class BindContactResponse(BaseModel):
success: bool
user: dict
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
async def send_verification_code(
request: SendVerificationCodeRequest,
) -> SendVerificationCodeResponse:
"""发送验证码(手机或邮箱)"""
from app.dependencies import get_db_session
from packages.adapters.sms.sms_service import get_sms_service
from packages.adapters.smtp import get_email_service
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
)
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
from packages.application.auth.bind_contact_use_case import (
SendVerificationCodeUseCase,
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=repo)
sms_service = get_sms_service()
email_service = get_email_service()
use_case = SendVerificationCodeUseCase(
verification_code_service=vc_service,
sms_service=sms_service,
email_service=email_service,
)
uc_request = UseCaseRequest(
target=request.target,
value=request.value,
purpose=request.purpose,
)
response, err = use_case.execute(uc_request)
if err:
raise HTTPException(status_code=400, detail=err)
return SendVerificationCodeResponse(
expires_in=response.expires_in,
resend_after=response.resend_after,
)
@router.post("/bind-contact", response_model=BindContactResponse)
async def bind_contact(
request: BindContactRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> BindContactResponse:
"""绑定手机号和/或邮箱(需登录态)"""
from app.dependencies import get_db_session
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
SQLAlchemyVerificationCodeRepository,
)
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
from packages.application.auth.bind_contact_use_case import (
BindContactUseCase,
)
from packages.application.auth.verification_code_service import VerificationCodeService
db = next(get_db_session())
vc_repo = SQLAlchemyVerificationCodeRepository(db)
vc_service = VerificationCodeService(repo=vc_repo)
use_case = BindContactUseCase(
user_repository=user_repository,
verification_code_service=vc_service,
)
uc_request = UseCaseRequest(
user_id=current_user.user.id,
phone=request.phone,
phone_code=request.phone_code,
email=request.email,
email_code=request.email_code,
)
response, err = use_case.execute(uc_request)
if err:
raise HTTPException(status_code=400, detail=err)
return BindContactResponse(success=True, user=response.to_dict()["user"])
# ==================== 当前用户信息扩展 ====================
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
# 通过给 get_current_user_info 返回值补充字段实现
+2 -4
View File
@@ -239,9 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record) return _to_detail_response(record)
@router.delete( @router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_duplication_record( def delete_duplication_record(
record_id: str, record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -289,7 +287,7 @@ def retry_duplication(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), detail=str(e),
) from e )
if updated is None: if updated is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+462
View File
@@ -0,0 +1,462 @@
"""剪辑计划管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选)
- GET /api/v1/edit-plans/{id} 详情
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
拆分模块(各自独立 router,由本文件 include_router 聚合):
- edit_plans_generation.py 生成相关(generate / generation-status / generations
- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover
- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template
业务逻辑委托给 EditPlanService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditPlanCreateRequest(BaseModel):
"""创建剪辑计划请求体"""
template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID")
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
project_id: str = Field(default="", description="所属项目 ID")
class EditPlanUpdateRequest(BaseModel):
"""更新剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称")
config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)")
total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)")
status: Optional[str] = Field(
default=None,
description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft",
)
class EditPlanResponse(BaseModel):
"""剪辑计划响应体"""
id: str
template_id: str
name: str
status: str
total_duration: float
project_id: str = ""
created_by_user_id: str = ""
config: dict[str, Any]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditPlanListResponse(BaseModel):
"""剪辑计划列表响应体"""
items: List[EditPlanResponse]
total: int
page: int
page_size: int
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
clips: List[ClipStatusItem]
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
class EditPlanGenerationsResponse(BaseModel):
"""剪辑计划关联的生成记录列表响应体"""
items: List[GenerationTaskResponse]
total: int
# ── AI 推荐片段方案 Schemas(任务 3.09) ──────────────────────────────────────
class AIRecommendRequest(BaseModel):
"""AI 推荐片段方案请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
class AIRecommendClipItem(BaseModel):
"""AI 推荐的单个片段"""
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
order: int = Field(..., ge=0, description="片段顺序")
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
class AIRecommendResponse(BaseModel):
"""AI 推荐片段方案响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
config: dict[str, Any] = Field(..., description="推荐的 plan configcover/title/subtitle/bgm")
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── AI 封面生成 Schemas(任务 3.09) ─────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
class GenerateFromTemplateRequest(BaseModel):
"""基于模板生成剪辑计划请求体"""
template_id: str = Field(..., description="剪辑模板 ID")
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
project_id: str = Field(default="", description="所属项目 ID")
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
class _PlanClipItem(BaseModel):
"""片段响应体"""
id: str
clip_type: str
order: int
asset_id: str
text_content: str
start_time: float
duration: float
transition_effect: str
transition_duration: float
playback_speed: float = 1.0
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class GenerateFromTemplateResponse(BaseModel):
"""基于模板生成剪辑计划响应体"""
plan: EditPlanResponse
clips: List[_PlanClipItem]
# ── Helpers ───────────────────────────────────────────────────────────────────
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
template_id=p.template_id,
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "",
config=p.config,
created_at=p.created_at,
updated_at=p.updated_at,
)
# ── CRUD Routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
def list_plans(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
project_id: Optional[str] = Query(default=None, description="按项目 ID 筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: draft / editing / rendering / completed / failed",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanListResponse:
"""获取剪辑计划列表(支持分页、按模板/状态/项目筛选)"""
svc = EditPlanService(db)
# 空串 project_id 视为未传(避免 DB 中匹配到空串记录)
if project_id is not None and not project_id.strip():
project_id = None
# 解析状态筛选
status_enum: Optional[EditPlanStatus] = None
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
check_project_access(project_id, current_user.user.id, project_repository)
skip = (page - 1) * page_size
plans = svc.list_plans(
template_id=template_id,
project_id=project_id,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_plans(
template_id=template_id,
project_id=project_id,
status=status_enum,
)
return EditPlanListResponse(
items=[_to_response(p) for p in plans],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""获取单个剪辑计划详情"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
return _to_response(plan)
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def create_plan(
body: EditPlanCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""创建剪辑计划"""
# 空串 project_id 统一为 ""
project_id = (body.project_id or "").strip()
# 项目鉴权
if project_id:
check_project_access(project_id, current_user.user.id, project_repository)
svc = EditPlanService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_plan_config(body.config)
try:
created = svc.create_plan(
template_id=body.template_id,
name=body.name,
config=normalized_config,
total_duration=body.total_duration,
project_id=project_id,
created_by_user_id=current_user.user.id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
created.name,
current_user.user.id,
)
return _to_response(created)
@router.put("/{plan_id}", response_model=EditPlanResponse)
def update_plan(
plan_id: str,
body: EditPlanUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""更新剪辑计划(支持状态机流转)"""
svc = EditPlanService(db)
# 项目鉴权
existing = svc.get_plan(plan_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if existing.project_id:
check_project_access(existing.project_id, current_user.user.id, project_repository)
# 基础字段更新
try:
if body.name is not None or body.config is not None or body.total_duration is not None:
# 标准化 config(如果提供了)
config_to_update = normalize_plan_config(body.config) if body.config is not None else None
svc.update_plan(
plan_id,
name=body.name,
config=config_to_update,
total_duration=body.total_duration,
)
# 状态机流转
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
err_msg = str(exc)
if "不存在" in err_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
# 返回最新状态
result = svc.get_plan_or_raise(plan_id)
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑计划"""
svc = EditPlanService(db)
# 项目鉴权
existing = svc.get_plan(plan_id)
if existing and existing.project_id:
check_project_access(existing.project_id, current_user.user.id, project_repository)
deleted = svc.delete_plan(plan_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
logger.info(
"删除剪辑计划: id=%s by user=%s",
plan_id,
current_user.user.id,
)
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
from .edit_plans_ai import router as ai_router
from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router)
router.include_router(ai_router)
router.include_router(timeline_router)
+199
View File
@@ -0,0 +1,199 @@
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/ai-recommend AI 推荐片段方案
- POST /{plan_id}/generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
AIRecommendClipItem,
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/{plan_id}/ai-recommend",
response_model=AIRecommendResponse,
)
def ai_recommend_clips(
plan_id: str,
body: AIRecommendRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> AIRecommendResponse:
"""AI 推荐片段方案
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
流程:
1. 验证计划存在且状态为 draft/editing
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
3. 清除计划现有片段,按推荐方案重新创建
4. 更新计划 configcover/title/subtitle/bgm)和 total_duration
5. 返回推荐方案详情
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
)
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
try:
svc.delete_all_clips(plan_id)
for clip_data in result["clips"]:
svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
normalized_config = normalize_plan_config(result.get("config", {}))
svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
except Exception as rollback_err:
logger.error(
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
plan_id,
rollback_err,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
AIRecommendClipItem(
clip_type=c["clip_type"],
order=c["order"],
text_content=c.get("text_content", ""),
duration=c["duration"],
transition_effect=c.get("transition_effect", "cut"),
asset_id=c.get("asset_id", ""),
start_time=c.get("start_time", 0.0),
config=c.get("config", {}),
)
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@router.post(
"/{plan_id}/generate-cover",
response_model=GenerateCoverResponse,
)
def generate_cover(
plan_id: str,
body: GenerateCoverRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateCoverResponse:
"""AI 生成封面
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
"""
svc = EditPlanService(db)
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config)
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"AI 封面生成: plan_id=%s type=%s by user=%s",
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(
plan_id=plan_id,
cover=cover_data,
)
@@ -0,0 +1,383 @@
"""剪辑计划生成相关 API 端点。
从 edit_plans.py 拆分,包含:
- POST /{plan_id}/generate 触发剪辑渲染生成
- GET /{plan_id}/generation-status 查询生成进度
- GET /{plan_id}/generations 查询关联的生成记录
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
def _auto_fallback_assign_assets(
svc: EditPlanService,
plan_id: str,
plan_check,
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
if not clips_without_asset:
return
material_mode = (plan_check.config or {}).get("material_mode", "manual")
if material_mode != "auto" or not plan_check.project_id:
return
import random
logger.info(
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
plan_id,
video_lib.name,
len(ready_videos),
len(clips_without_asset),
)
else:
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
else:
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
def generate_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发剪辑计划渲染生成
前置条件:计划状态必须为 editing,且至少有一个片段。
流程:
1. 验证计划状态为 editing
2. 将 pending 片段标记为 ready
3. 创建 GenerationTask
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
svc = EditPlanService(db)
plan_check = svc.get_plan(plan_id)
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# 自动兜底流程
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
# 检查是否可生成
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
# 核心生成流程
try:
clip_count = svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
)
)
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = svc.get_plan_or_raise(plan_id)
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
@router.get(
"/{plan_id}/generation-status",
response_model=EditPlanGenerationStatusResponse,
)
def get_generation_status(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationStatusResponse:
"""查询剪辑计划生成进度"""
svc = EditPlanService(db)
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"]
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
@router.get(
"/{plan_id}/generations",
response_model=EditPlanGenerationsResponse,
)
def list_plan_generations(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanGenerationsResponse:
"""查询剪辑计划关联的所有生成记录"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
from app.schemas.generation_task import GenerationTaskResponse
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
+221
View File
@@ -0,0 +1,221 @@
"""剪辑计划时间线 & 模板生成 API 端点。
从 edit_plans.py 拆分,包含:
- GET /{plan_id}/timeline 时间线场景数据
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
"""
from __future__ import annotations
import logging
from typing import Any, List
from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
_PlanClipItem,
_to_response,
)
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Timeline Schemas ──────────────────────────────────────────────────────────
class TimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str = Field(..., description="场景描述")
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
duration: float = Field(..., ge=0, description="时长(秒)")
color: str = Field(..., description="展示颜色")
clip_id: str = Field(default="", description="关联的片段 ID")
clip_type: str = Field(default="", description="片段类型")
class TimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[TimelineSceneResponse]
# clip_type → 颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""将秒数格式化为 M:SS"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""根据 clip_type 和 text_content 生成场景描述"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get(
"/{plan_id}/timeline",
response_model=TimelineResponse,
)
def get_plan_timeline(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> TimelineResponse:
"""获取剪辑计划的时间线场景数据"""
svc = EditPlanService(db)
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
clips.sort(key=lambda c: c.order)
scenes: List[TimelineSceneResponse] = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
TimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return TimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划"""
from app.services import EditTemplateService
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
+9 -7
View File
@@ -32,7 +32,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"]) router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
# 允许管理的 flag 白名单(防止误操作其他系统 flag) # 允许管理的 flag 白名单(防止误操作其他系统 flag)
ALLOWED_FLAGS: set[str] = set() ALLOWED_FLAGS = {
"render_engine",
}
def _get_feature_flag_store() -> RedisFeatureFlagStore: def _get_feature_flag_store() -> RedisFeatureFlagStore:
@@ -103,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name) return sorted(result, key=lambda x: x.name)
except Exception as exc: except Exception as exc:
logger.error("Failed to list feature flags: %s", exc) logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
@router.get("/{name}", response_model=FeatureFlagResponse) @router.get("/{name}", response_model=FeatureFlagResponse)
@@ -118,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc) logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse) @router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
@@ -134,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier) return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc: except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc) logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
@router.put("/{name}", response_model=FeatureFlagResponse) @router.put("/{name}", response_model=FeatureFlagResponse)
@@ -168,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc) logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@@ -176,7 +178,7 @@ async def delete_feature_flag(
name: str, name: str,
_: bool = Depends(_verify_internal_api_key), _: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store), store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
): ) :
"""删除 Feature Flag。 """删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。 只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -189,4 +191,4 @@ async def delete_feature_flag(
pass pass
except Exception as exc: except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc) logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+13 -88
View File
@@ -43,7 +43,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse( return GenerationTaskResponse(
id=task.id, id=task.id,
@@ -58,9 +57,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
source_edit_plan_id=task.source_edit_plan_id or "", source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""), asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""), batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
bgm_config=getattr(task, "bgm_config", {}) or {},
logs=getattr(task, "logs", "[]"), logs=getattr(task, "logs", "[]"),
status=task.status, status=task.status,
progress=task.progress, progress=task.progress,
@@ -106,7 +102,7 @@ def _select_assets_from_library(
Args: Args:
assets: 素材库中所有素材(Asset 实体列表) assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性) mode: 选取模式 — all=全部, random=随机, smart=按质量评分
count: 选取数量,0 表示全部(仅 random/smart 模式有效) count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns: Returns:
@@ -124,19 +120,17 @@ def _select_assets_from_library(
return [a.id for a in selected] return [a.id for a in selected]
if mode == "smart": if mode == "smart":
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker # 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
# 注意:这里使用简单的 quality_score 排序保持向后兼容 sorted_assets = sorted(
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
scored_assets = sorted(
ready_video_assets, ready_video_assets,
key=lambda a: ( key=lambda a: (
-(a.quality_score if a.quality_score is not None else 0.0), a.quality_score if a.quality_score is not None else 0.0,
-(getattr(a, "duration", 0.0) or 0.0), a.duration if a.duration is not None else 0.0,
), ),
reverse=True,
) )
if count > 0: selected = sorted_assets if count <= 0 else sorted_assets[:count]
scored_assets = scored_assets[:count] return [a.id for a in selected]
return [a.id for a in scored_assets]
# 默认 all 模式:返回全部 ready 视频素材 # 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets] return [a.id for a in ready_video_assets]
@@ -227,20 +221,6 @@ def create_generation_task(
mode=request.asset_select_mode, mode=request.asset_select_mode,
count=request.asset_select_count, count=request.asset_select_count,
) )
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id)
if assets:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
if not resolved_asset_ids:
raise HTTPException(
status_code=422,
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository) use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count count = request.count
@@ -287,9 +267,6 @@ def create_generation_task(
source_edit_plan_id=request.source_edit_plan_id, source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode, asset_select_mode=request.asset_select_mode,
batch_id=batch_id, batch_id=batch_id,
video_title=request.video_title,
resolution=request.resolution,
bgm_config=request.bgm_config,
auto_retry_enabled=request.auto_retry_enabled, auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max, auto_retry_max=request.auto_retry_max,
) )
@@ -305,28 +282,28 @@ def create_generation_task(
created_tasks.append(task) created_tasks.append(task)
else: else:
failed_tasks.append(task) failed_tasks.append(task)
except UserPendingLimitExceeded as _e: except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住 # 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="您的待处理任务过多,请等待完成后再提交", detail="您的待处理任务过多,请等待完成后再提交",
) from _e )
break break
except GlobalQueueFull as _e: except GlobalQueueFull:
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from _e )
break break
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True) logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks] items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items)) return BatchGenerationTaskResponse(items=items, total=len(items))
@@ -427,8 +404,6 @@ def retry_generation_task(
created_by_user_id=user_id, created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "", source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""), asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""),
) )
) )
try: try:
@@ -451,53 +426,3 @@ def retry_generation_task(
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from None ) from None
return _to_generation_task_response(retried) return _to_generation_task_response(retried)
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
def cancel_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""取消生成任务。
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
# 权限校验
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
# 终态不可取消
if status_val in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=409,
detail=f"Cannot cancel task in {status_val} status",
)
# 执行取消
try:
task.mark_cancelled()
task.append_log(
stage="cancelled",
message="用户主动取消任务",
level="INFO",
cancelled_by=authenticated_user.user.id,
)
generation_task_repository.update(task)
logger.info(
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
task_id,
authenticated_user.user.id,
status_val,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_generation_task_response(task)
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone from datetime import datetime
import psycopg2 import psycopg2
import redis import redis
@@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"])
async def health_check(): async def health_check():
return { return {
"status": "healthy", "status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.utcnow().isoformat(),
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
} }
@@ -33,7 +33,7 @@ async def startup_check():
all_ready = all(check["status"] == "healthy" for check in checks.values()) all_ready = all(check["status"] == "healthy" for check in checks.values())
response = { response = {
"status": "started" if all_ready else "starting", "status": "started" if all_ready else "starting",
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.utcnow().isoformat(),
"checks": checks, "checks": checks,
} }
if not all_ready: if not all_ready:
+3 -3
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository) use_case = DeleteProjectUseCase(project_repository)
try: try:
deleted = use_case.execute(project_id, authenticated_user.user.id) deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError as _e: except PermissionError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project", detail="Only the project owner can delete this project",
) from _e )
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return # type: ignore[return-value] return
-298
View File
@@ -1,298 +0,0 @@
"""视频分享 API 路由."""
from __future__ import annotations
import logging
from typing import Optional
from app.api.routes._helpers import format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_db_session, get_generated_video_repository
from app.schemas.video_share import (
CreateShareRequest,
ShareAccessResponse,
ShareListResponse,
ShareMetaResponse,
ShareResponse,
UpdateShareRequest,
VerifySharePasswordRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.video_share_repository import (
SQLAlchemyVideoShareRepository,
)
from packages.application.video_share.commands import (
CreateShareCommand,
UpdateShareCommand,
)
from packages.application.video_share.use_cases import (
AccessShareUseCase,
CreateShareUseCase,
GetShareByTokenUseCase,
InvalidPasswordError,
ListSharesByUserUseCase,
ListSharesByVideoUseCase,
NotFoundError,
PasswordRequiredError,
RecordShareDownloadUseCase,
RevokeShareUseCase,
ShareExpiredError,
UpdateShareUseCase,
VideoNotFoundError,
)
from packages.ports.generated_video_repository import GeneratedVideoRepository
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_share_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyVideoShareRepository:
return SQLAlchemyVideoShareRepository(session)
def _to_share_response(share) -> ShareResponse:
return ShareResponse(
id=share.id,
video_id=share.video_id,
share_token=share.share_token,
has_password=share.has_password,
expires_at=share.expires_at,
view_count=share.view_count,
download_count=share.download_count,
is_active=share.is_active,
created_at=format_utc_datetime(share.created_at),
updated_at=format_utc_datetime(share.updated_at),
)
# ── 用户侧:创建/管理分享 ──────────────────────────────────
@router.post("/videos/{video_id}/share", response_model=ShareResponse)
def create_share(
video_id: str,
request: CreateShareRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
) -> ShareResponse:
"""为视频创建分享链接."""
use_case = CreateShareUseCase(share_repo, video_repo)
try:
share = use_case.execute(
CreateShareCommand(
video_id=video_id,
user_id=authenticated_user.user.id,
password=request.password,
expires_at=request.expires_at,
)
)
except VideoNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
logger.info(
"Share created: video_id=%s share_id=%s token=%s user=%s",
video_id,
share.id,
share.share_token,
authenticated_user.user.id,
)
return _to_share_response(share)
@router.get("/videos/{video_id}/shares", response_model=ShareListResponse)
def list_video_shares(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> ShareListResponse:
"""获取某个视频的所有分享记录."""
use_case = ListSharesByVideoUseCase(share_repo)
items = use_case.execute(video_id, authenticated_user.user.id)
return ShareListResponse(
items=[_to_share_response(s) for s in items],
total=len(items),
skip=0,
limit=len(items),
)
@router.get("/shares", response_model=ShareListResponse)
def list_user_shares(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> ShareListResponse:
"""获取用户创建的所有分享记录."""
use_case = ListSharesByUserUseCase(share_repo)
items, total = use_case.execute(authenticated_user.user.id, skip=skip, limit=limit)
return ShareListResponse(
items=[_to_share_response(s) for s in items],
total=total,
skip=skip,
limit=limit,
)
@router.patch("/shares/{share_id}", response_model=ShareResponse)
def update_share(
share_id: str,
request: UpdateShareRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> ShareResponse:
"""更新分享配置(密码、有效期等)."""
use_case = UpdateShareUseCase(share_repo)
try:
share = use_case.execute(
UpdateShareCommand(
share_id=share_id,
user_id=authenticated_user.user.id,
password=request.password,
expires_at=request.expires_at,
)
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return _to_share_response(share)
@router.delete(
"/shares/{share_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
)
def revoke_share(
share_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> Response:
"""撤销/删除分享链接."""
use_case = RevokeShareUseCase(share_repo)
try:
use_case.execute(share_id, authenticated_user.user.id)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ── 公开侧:访问分享内容(无需登录) ──────────────────────
@router.get("/share/{token}/meta", response_model=ShareMetaResponse)
def get_share_meta(
token: str,
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
) -> ShareMetaResponse:
"""获取分享元信息(不需要密码,用于分享页加载前判断)。"""
use_case = GetShareByTokenUseCase(share_repo)
try:
share = use_case.execute(token)
except (NotFoundError, ShareExpiredError) as e:
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
video = video_repo.get(share.video_id)
video_name = video.name if video else ""
video_duration = video.duration if video else 0.0
thumbnail_url = None
if video and video.thumbnail_url:
try:
thumbnail_url = storage.get_download_url(video.thumbnail_url)
except Exception:
thumbnail_url = video.thumbnail_url
return ShareMetaResponse(
share_token=share.share_token,
has_password=share.has_password,
is_expired=share.is_expired,
is_active=share.is_active,
video_name=video_name,
video_duration=video_duration,
thumbnail_url=thumbnail_url,
created_at=format_utc_datetime(share.created_at),
)
@router.post("/share/{token}/access", response_model=ShareAccessResponse)
def access_share(
token: str,
request: Optional[VerifySharePasswordRequest] = None,
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
) -> ShareAccessResponse:
"""访问分享内容(验证密码后返回视频信息+播放/下载地址)。"""
use_case = AccessShareUseCase(share_repo, video_repo)
password = request.password if request else None
try:
result = use_case.execute(token, password=password)
except NotFoundError as e:
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
except ShareExpiredError as e:
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
except PasswordRequiredError as e:
raise HTTPException(status_code=403, detail="需要访问密码") from e
except InvalidPasswordError as e:
raise HTTPException(status_code=403, detail="密码错误") from e
except VideoNotFoundError as e:
raise HTTPException(status_code=404, detail="视频不存在") from e
# 生成下载URL
download_url = None
if result.video.file_url:
try:
download_url = storage.get_download_url(result.video.file_url)
except Exception:
download_url = result.video.file_url
# 缩略图URL
thumbnail_url = None
if result.video.thumbnail_url:
try:
thumbnail_url = storage.get_download_url(result.video.thumbnail_url)
except Exception:
thumbnail_url = result.video.thumbnail_url
return ShareAccessResponse(
share=_to_share_response(result.share),
video_name=result.video.name,
video_duration=result.video.duration,
video_size=result.video.file_size,
thumbnail_url=thumbnail_url,
download_url=download_url,
password_verified=result.password_verified,
)
@router.post("/share/{token}/download")
def record_share_download(
token: str,
request: Optional[VerifySharePasswordRequest] = None,
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
) -> dict:
"""记录分享下载(下载计数+1)。"""
use_case = RecordShareDownloadUseCase(share_repo)
password = request.password if request else None
try:
use_case.execute(token, password=password)
except NotFoundError as e:
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
except ShareExpiredError as e:
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
except InvalidPasswordError as e:
raise HTTPException(status_code=403, detail="密码错误") from e
return {"success": True}
+1 -6
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List from typing import List
@@ -21,8 +20,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -257,9 +254,7 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id} return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e: except Exception as e:
session.rollback() session.rollback()
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}") raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
# 不返回原始异常信息,避免泄漏内部实现细节
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
finally: finally:
session.close() session.close()
+12 -11
View File
@@ -1,7 +1,6 @@
import logging import logging
from typing import Any from typing import Any
from app.api.routes._helpers import format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.task_enqueue import ( from app.core.task_enqueue import (
@@ -25,6 +24,8 @@ from app.schemas.task_center import (
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import ( from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
RetryGenerationTaskUseCase, RetryGenerationTaskUseCase,
SubmitIngestJobCommand, SubmitIngestJobCommand,
SubmitIngestJobUseCase, SubmitIngestJobUseCase,
@@ -100,8 +101,8 @@ def _generation_task_to_user_response(task) -> UserTaskResponse:
retryable=_status_value(task.status) == "failed", retryable=_status_value(task.status) == "failed",
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
source_id=task.id, source_id=task.id,
created_at=format_utc_datetime(task.created_at), created_at=task.created_at,
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at), updated_at=task.completed_at or task.started_at or task.created_at,
) )
@@ -120,8 +121,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
source_id=task.id, source_id=task.id,
template_id=task.template_id, template_id=task.template_id,
created_at=format_utc_datetime(task.created_at), created_at=task.created_at,
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at), updated_at=task.completed_at or task.started_at or task.created_at,
) )
@@ -285,8 +286,8 @@ def list_project_tasks(
user_message=_humanize_task_error(job.error_message), user_message=_humanize_task_error(job.error_message),
retryable=_status_value(job.status) == "failed", retryable=_status_value(job.status) == "failed",
source_id=job.id, source_id=job.id,
created_at=format_utc_datetime(job.created_at), created_at=job.created_at,
updated_at=format_utc_datetime(job.updated_at), updated_at=job.updated_at,
) )
) )
@@ -367,9 +368,9 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Ingest job not found") raise HTTPException(status_code=404, detail="Ingest job not found")
if _status_value(job.status) != "failed": if _status_value(job.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried") raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment] use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute( retried = use_case.execute(
SubmitIngestJobCommand( # type: ignore[arg-type] SubmitIngestJobCommand(
project_id=job.project_id, project_id=job.project_id,
library_id=job.library_id, library_id=job.library_id,
storage_key=job.storage_key, storage_key=job.storage_key,
@@ -384,7 +385,7 @@ def retry_project_task(
progress=0, progress=0,
current_step=_ingest_step(retried), current_step=_ingest_step(retried),
source_id=retried.id, source_id=retried.id,
created_at=format_utc_datetime(retried.created_at), created_at=retried.created_at,
updated_at=format_utc_datetime(retried.updated_at), # type: ignore[attr-defined] updated_at=retried.updated_at,
) )
raise HTTPException(status_code=400, detail="Unsupported task type") raise HTTPException(status_code=400, detail="Unsupported task type")
+15 -17
View File
@@ -147,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id) usage = template_repository.get_usage_count(template_id)
except Exception as _e: except Exception:
logger.exception("get_template 查询失败: template_id=%s", template_id) logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage) return _to_response(template, usage_count=usage)
@@ -186,7 +186,7 @@ def create_template(
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -226,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository) use_case = UpdateTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -264,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository) use_case = CopyTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -299,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
try: try:
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
except Exception as _e: except Exception:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False) return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository) use_case = ValidateTemplateUseCase(template_repository)
try: try:
result = use_case.execute(command) result = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return ValidateTemplateResponse( return ValidateTemplateResponse(
template=_to_response(result.template), template=_to_response(result.template),
@@ -375,9 +375,7 @@ def create_category(
) )
@router.delete( @router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category( def delete_category(
category_id: str, category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -1,65 +0,0 @@
"""模板编辑器 API 路由包.
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
- schemas.py: 所有 Pydantic model
- dependencies.py: 依赖注入
- _utils.py: 工具函数
- _fallback.py: 自动兜底逻辑
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
- bgm.py: BGM 管理
- effects.py: 转场 + 滤镜
- export.py: 导出配置
- cover.py: 封面管理 + AI 生成封面
- subtitles.py: 字幕管理
- ai_features.py: AI 推荐
- generation.py: 生成(触发/进度/记录)
- timeline.py: 时间线
挂载路径: /api/v1/templates/{template_id}/editor/
"""
from __future__ import annotations
# 向后兼容:测试和其他模块可能直接从 templates_editor 导入这些符号
from app.auth import get_current_user # noqa: F401
from app.dependencies import get_db_session # noqa: F401
from fastapi import APIRouter
from .adjustments import router as adjustments_router
from .ai_features import router as ai_features_router
from .bgm import router as bgm_router
from .clips import router as clips_router
from .cover import router as cover_router
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .draft import router as draft_router
from .effects import router as effects_router
from .export import router as export_router
from .generation import router as generation_router
from .subtitles import router as subtitles_router
from .timeline import router as timeline_router
# 主 router,所有子路由都合并到这里
router = APIRouter(tags=["Template Editor"])
# 合并所有子模块的路由(不用 include_router 是因为子路由有空路径 "")
_sub_routers = [
draft_router,
clips_router,
adjustments_router,
bgm_router,
effects_router,
export_router,
cover_router,
subtitles_router,
ai_features_router,
generation_router,
timeline_router,
]
for sub in _sub_routers:
for route in sub.routes:
router.routes.append(route)
__all__ = ["router"]
@@ -1,163 +0,0 @@
"""模板编辑器自动兜底逻辑.
generate_editor_draft 触发生成前的自动修复流程:
1. draft → editing 状态迁移
2. 无片段时从模板复制片段配置
3. 为无素材片段分配指定素材
4. 项目有素材库时自动选素材
"""
from __future__ import annotations
import logging
import random
from typing import Any
from app.services.edit_plan_service import EditPlanService
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
def _auto_fallback_draft_to_editing(
svc: EditPlanService, plan_id: str, plan_check
) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(
svc: EditPlanService, plan_id: str, plan_check, db: Session
) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value
if hasattr(cfg.clip_type, "value")
else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect,
)
logger.info(
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
plan_id,
len(configs),
)
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info(
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
plan_id,
len(segments),
)
def _auto_fallback_assign_assets(
svc: EditPlanService, plan_id: str, plan_check
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"模板编辑器自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
logger.info("模板编辑器自动兜底3: plan=%s 素材分配完成", plan_id)
clips_without_asset = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 项目有视频素材库时自动选素材"""
if not clips_without_asset:
return
if not plan_check.project_id:
return
logger.info(
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"模板编辑器自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材",
plan_id,
video_lib.name,
len(ready_videos),
)
@@ -1,109 +0,0 @@
"""模板编辑器内部工具函数.
纯函数,不依赖请求上下文。
"""
from __future__ import annotations
from typing import Any
from .schemas import ClipAdjustResponse
# 时间线场景颜色映射
_CLIP_TYPE_COLORS = {
"intro": "#6366f1",
"title": "#6366f1",
"product": "#818cf8",
"showcase": "#10b981",
"scene": "#10b981",
"subtitle": "#f59e0b",
"text": "#f59e0b",
"cta": "#ef4444",
"outro": "#ef4444",
"voiceover": "#8b5cf6",
"transition": "#64748b",
}
_DEFAULT_COLOR = "#6366f1"
def _format_time(seconds: float) -> str:
"""秒数格式化为 m:ss"""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m}:{s:02d}"
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
"""片段类型转时间线场景标签"""
type_labels = {
"intro": "开场",
"title": "标题",
"product": "产品展示",
"showcase": "场景展示",
"scene": "场景",
"subtitle": "字幕",
"text": "文字",
"cta": "结尾 CTA",
"outro": "结尾",
"voiceover": "配音",
"transition": "转场",
}
label = type_labels.get(clip_type, clip_type or "片段")
if text_content:
short = text_content[:20].strip()
if short:
return f"{label} - {short}"
return label
# ── 片段调整相关工具 ────────────────────────────────────────────────────────
def _get_clip_config(clip) -> dict:
"""安全获取 clip.config"""
config = getattr(clip, "config", {}) or {}
if not isinstance(config, dict):
config = {}
return config
def _get_adjust_volume(clip) -> float:
"""获取片段音量"""
config = _get_clip_config(clip)
return float(config.get("volume", 1.0))
def _get_adjust_trim(clip) -> tuple[float, float]:
"""获取片段裁剪起止"""
config = _get_clip_config(clip)
trim_start = float(config.get("trim_start", 0.0))
trim_end = float(config.get("trim_end", 0.0))
return trim_start, trim_end
def _build_adjust_response(clip) -> ClipAdjustResponse:
"""构造片段调整响应"""
trim_start, trim_end = _get_adjust_trim(clip)
return ClipAdjustResponse(
clip_id=clip.id,
speed=clip.playback_speed,
volume=_get_adjust_volume(clip),
trim_start=trim_start,
trim_end=trim_end,
duration=clip.duration,
)
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
"""校验裁剪时长合法性"""
if trim_start + trim_end >= total_duration:
raise ValueError(
f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s"
)
def _clip_value(value: Any) -> str:
"""获取枚举/字符串值的统一方法"""
if hasattr(value, "value"):
return value.value
return str(value)
@@ -1,167 +0,0 @@
"""片段调整路由.
端点:
- PUT /clips/{clip_id}/speed 调速
- PUT /clips/{clip_id}/volume 调音量
- PUT /clips/{clip_id}/trim 裁剪
- PUT /clips/{clip_id}/adjustments 统一调整
- POST /clips/batch-speed 批量调速
"""
from __future__ import annotations
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException
from ._utils import _build_adjust_response, _get_adjust_trim, _get_clip_config, _validate_trim
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
BatchSpeedRequest,
BatchSpeedResponse,
ClipAdjustmentsRequest,
ClipAdjustResponse,
SpeedAdjustRequest,
TrimAdjustRequest,
VolumeAdjustRequest,
)
router = APIRouter(tags=["Template Editor"])
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
def adjust_editor_clip_speed(
template_id: str,
clip_id: str,
body: SpeedAdjustRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipAdjustResponse:
"""调整片段播放速度"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
updated = plan_svc.update_clip(clip_id, playback_speed=body.speed)
return _build_adjust_response(updated)
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
def adjust_editor_clip_volume(
template_id: str,
clip_id: str,
body: VolumeAdjustRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipAdjustResponse:
"""调整片段音量"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
config = dict(_get_clip_config(clip))
config["volume"] = body.volume
updated = plan_svc.update_clip(clip_id, config=config)
return _build_adjust_response(updated)
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
def adjust_editor_clip_trim(
template_id: str,
clip_id: str,
body: TrimAdjustRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipAdjustResponse:
"""裁剪片段(trim in/out"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
try:
_validate_trim(body.trim_start, body.trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
config = dict(_get_clip_config(clip))
config["trim_start"] = body.trim_start
config["trim_end"] = body.trim_end
updated = plan_svc.update_clip(clip_id, config=config)
return _build_adjust_response(updated)
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
def adjust_editor_clip_all(
template_id: str,
clip_id: str,
body: ClipAdjustmentsRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipAdjustResponse:
"""统一调整片段的 speed / volume / trim"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
update_kwargs: dict[str, Any] = {}
config_updates: dict[str, Any] = {}
if body.speed is not None:
update_kwargs["playback_speed"] = body.speed
if body.volume is not None:
config_updates["volume"] = body.volume
if body.trim_start is not None:
config_updates["trim_start"] = body.trim_start
if body.trim_end is not None:
config_updates["trim_end"] = body.trim_end
current_trim_start, current_trim_end = _get_adjust_trim(clip)
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
if body.trim_start is not None or body.trim_end is not None:
try:
_validate_trim(new_trim_start, new_trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if config_updates:
config = dict(_get_clip_config(clip))
config.update(config_updates)
update_kwargs["config"] = config
if not update_kwargs:
return _build_adjust_response(clip)
updated = plan_svc.update_clip(clip_id, **update_kwargs)
return _build_adjust_response(updated)
@router.post("/clips/batch-speed", response_model=BatchSpeedResponse)
def batch_adjust_editor_speed(
template_id: str,
body: BatchSpeedRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> BatchSpeedResponse:
"""批量调整草稿内所有片段的播放速度"""
_, plan_svc = services
clips = plan_svc.list_clips(plan_id, limit=500, skip=0)
count = 0
for clip in clips:
plan_svc.update_clip(clip.id, playback_speed=body.speed)
count += 1
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
@@ -1,121 +0,0 @@
"""AI 功能路由.
端点:
- POST /ai-recommend AI 推荐片段方案
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import AIRecommendRequest, AIRecommendResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.post("/ai-recommend", response_model=AIRecommendResponse)
def editor_ai_recommend(
template_id: str,
body: AIRecommendRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> AIRecommendResponse:
"""AI 推荐片段方案"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status not in ("draft", "editing"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前草稿状态不支持AI推荐,请先编辑后再试",
)
from packages.shared.ai_service import run_ai_recommend
result = run_ai_recommend(
plan_id=plan_id,
template_id=plan.template_id,
asset_ids=body.asset_ids,
editing_mode=body.editing_mode,
target_duration=body.target_duration,
)
try:
plan_svc.delete_all_clips(plan_id)
for clip_data in result["clips"]:
plan_svc.create_clip(
plan_id=plan_id,
clip_type=clip_data["clip_type"],
order=clip_data["order"],
text_content=clip_data.get("text_content", ""),
duration=clip_data["duration"],
transition_effect=clip_data.get("transition_effect", "cut"),
asset_id=clip_data.get("asset_id", ""),
start_time=clip_data.get("start_time", 0.0),
config=clip_data.get("config", {}),
)
normalized_config = normalize_plan_config(result.get("config", {}))
plan_svc.update_plan(
plan_id,
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception as _e:
logger.exception(
"模板编辑器AI推荐写入失败: template_id=%s plan_id=%s",
template_id,
plan_id,
)
try:
db.rollback()
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
) from _e
logger.info(
"模板编辑器AI推荐: template_id=%s plan_id=%s clips=%d duration=%.1f by user=%s",
template_id,
plan_id,
len(result["clips"]),
result["total_duration"],
current_user.user.id,
)
return AIRecommendResponse(
plan_id=plan_id,
clips=[
{
"clip_type": c["clip_type"],
"order": c["order"],
"text_content": c.get("text_content", ""),
"duration": c["duration"],
"transition_effect": c.get("transition_effect", "cut"),
"asset_id": c.get("asset_id", ""),
"start_time": c.get("start_time", 0.0),
"config": c.get("config", {}),
}
for c in result["clips"]
],
config=normalized_config,
total_duration=result["total_duration"],
confidence=result["confidence"],
)
@@ -1,133 +0,0 @@
"""BGM 管理路由.
端点:
- GET /bgm 获取 BGM 配置
- PUT /bgm 更新 BGM 配置
- GET /bgm/presets 预设 BGM 列表
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import BGMConfigUpdateRequest
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.get("/bgm", response_model=dict[str, Any])
def get_editor_bgm(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取草稿的 BGM 配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = plan.config or {}
return {
"plan_id": plan.id,
"bgm": config.get("bgm", {}),
}
@router.put("/bgm", response_model=dict[str, Any])
def update_editor_bgm(
template_id: str,
body: BGMConfigUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""更新草稿的 BGM 配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = dict(plan.config) if plan.config else {}
current_bgm = dict(config.get("bgm", {}))
update_data = body.model_dump(exclude_none=True)
current_bgm.update(update_data)
if current_bgm.get("enabled"):
has_source = any(
current_bgm.get(key)
for key in ("asset_id", "preset_id", "audio_url")
if current_bgm.get(key)
)
if not has_source:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url",
)
config["bgm"] = current_bgm
updated_plan = plan_svc.update_plan_config(plan_id, config)
logger.info(
"模板编辑器更新BGM: template_id=%s plan_id=%s enabled=%s by user=%s",
template_id,
plan_id,
current_bgm.get("enabled", False),
current_user.user.id,
)
return {
"plan_id": updated_plan.id,
"bgm": current_bgm,
}
@router.get("/bgm/presets", response_model=dict[str, Any])
def list_editor_bgm_presets(
style: str | None = Query(default=None, description="按风格筛选"),
keyword: str | None = Query(default=None, description="关键词搜索"),
skip: int = Query(default=0, ge=0, description="分页偏移"),
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取预设 BGM 列表"""
from packages.domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
list_preset_bgm_by_style,
search_preset_bgm,
)
bgm_list = PRESET_BGM_LIBRARY
if keyword:
bgm_list = search_preset_bgm(keyword)
elif style:
bgm_list = list_preset_bgm_by_style(style)
total = len(bgm_list)
paged = bgm_list[skip : skip + limit]
return {
"total": total,
"skip": skip,
"limit": limit,
"styles": BGM_STYLES,
"items": [
{
"id": bgm.id,
"name": bgm.name,
"style": bgm.style,
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
"duration": bgm.duration,
"artist": bgm.artist,
"description": bgm.description,
"tags": bgm.tags,
"audio_url": bgm.audio_url,
}
for bgm in paged
],
}
@@ -1,318 +0,0 @@
"""片段管理路由.
端点:
- GET /clips 片段列表
- POST /clips 创建片段
- GET /clips/{clip_id} 片段详情
- PUT /clips/{clip_id} 更新片段
- DELETE /clips/{clip_id} 删除片段
- POST /clips/{clip_id}/split 分割片段
- POST /clips/merge 合并片段
- POST /clips/reorder 重排片段
- POST /clips/batch-delete 批量删除
- POST /clips/from-assets 从素材创建片段
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
ClipBatchDeleteRequest,
ClipBatchDeleteResponse,
ClipReorderRequest,
ClipReorderResponse,
ClipsFromAssetsRequest,
ClipsFromAssetsResponse,
EditorClipCreateRequest,
EditorClipListResponse,
EditorClipResponse,
EditorClipUpdateRequest,
MergeClipsRequest,
SplitClipRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
def _clip_to_response(clip) -> EditorClipResponse:
"""统一构造片段响应"""
return EditorClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type.value
if hasattr(clip.clip_type, "value")
else str(clip.clip_type),
order=clip.order,
duration=clip.duration,
text_content=clip.text_content or "",
transition_effect=clip.transition_effect.value
if hasattr(clip.transition_effect, "value")
else str(clip.transition_effect),
playback_speed=clip.playback_speed or 1.0,
config=clip.config or {},
)
@router.get("/clips", response_model=EditorClipListResponse)
def list_draft_clips(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取草稿的片段列表"""
_, plan_svc = services
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
total = plan_svc.count_clips(plan_id)
return EditorClipListResponse(
items=[_clip_to_response(c) for c in clips],
total=total,
)
@router.post("/clips", response_model=EditorClipResponse, status_code=status.HTTP_201_CREATED)
def create_draft_clip(
template_id: str,
req: EditorClipCreateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""在草稿中创建新片段"""
_, plan_svc = services
try:
clip = plan_svc.create_clip(
plan_id,
clip_type=req.clip_type,
order=req.order,
duration=req.duration,
text_content=req.text_content,
transition_effect=req.transition_effect,
config=req.config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _clip_to_response(clip)
@router.put("/clips/{clip_id}", response_model=EditorClipResponse)
def update_draft_clip(
template_id: str,
clip_id: str,
req: EditorClipUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""更新草稿中的片段"""
_, plan_svc = services
try:
clip = plan_svc.update_clip(
clip_id,
order=req.order,
duration=req.duration,
text_content=req.text_content,
transition_effect=req.transition_effect,
playback_speed=req.playback_speed,
config=req.config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _clip_to_response(clip)
@router.delete("/clips/{clip_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_clip(
template_id: str,
clip_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""删除草稿中的片段"""
_, plan_svc = services
success = plan_svc.delete_clip(clip_id)
if not success:
raise HTTPException(status_code=404, detail="片段不存在")
return None
@router.get("/clips/{clip_id}", response_model=EditorClipResponse)
def get_draft_clip_detail(
template_id: str,
clip_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取草稿中的片段详情"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="片段不存在")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail="片段不存在")
return _clip_to_response(clip)
@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
def split_draft_clip(
template_id: str,
clip_id: str,
body: SplitClipRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将一个片段从指定时间点分割为两个片段"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail="片段不存在")
try:
result = plan_svc.split_clip(clip_id, body.split_time)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
left = result["left_clip"]
right = result["right_clip"]
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
}
@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
def merge_draft_clips(
template_id: str,
body: MergeClipsRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将多个连续的同类型片段合并为一个片段"""
_, plan_svc = services
for cid in body.clip_ids:
clip = plan_svc.get_clip(cid)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {cid}")
try:
merged = plan_svc.merge_clips(body.clip_ids)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
}
@router.post("/clips/reorder", response_model=ClipReorderResponse)
def reorder_editor_clips(
template_id: str,
body: ClipReorderRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipReorderResponse:
"""批量重排片段顺序"""
_, plan_svc = services
count = 0
for item in body.items:
try:
plan_svc.update_clip(item.clip_id, order=item.new_order)
count += 1
except ValueError:
pass
return ClipReorderResponse(updated_count=count, plan_id=plan_id)
@router.post("/clips/batch-delete", response_model=ClipBatchDeleteResponse)
def batch_delete_editor_clips(
template_id: str,
body: ClipBatchDeleteRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipBatchDeleteResponse:
"""批量删除片段"""
_, plan_svc = services
deleted = 0
for clip_id in body.clip_ids:
if plan_svc.delete_clip(clip_id):
deleted += 1
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor(
template_id: str,
body: ClipsFromAssetsRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段"""
_, plan_svc = services
clips = []
for i, asset_id in enumerate(body.asset_ids):
try:
clip = plan_svc.create_clip(
plan_id,
clip_type="main",
order=body.start_order + i if hasattr(body, "start_order") else i,
duration=5.0,
asset_id=asset_id,
)
clips.append(clip)
except ValueError:
pass
logger.info(
"模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
template_id,
plan_id,
len(clips),
current_user.user.id,
)
return ClipsFromAssetsResponse(
created_count=len(clips),
plan_id=plan_id,
clip_ids=[c.id for c in clips],
)
@@ -1,209 +0,0 @@
"""封面管理路由.
端点:
- GET /cover 封面配置
- PUT /cover 更新封面
- POST /cover/extract 抽帧生成封面
- POST /cover/smart 智能选帧
- POST /generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
CoverConfigResponse,
CoverExtractRequest,
CoverGenerateResponse,
CoverSmartRequest,
CoverUpdateRequest,
GenerateCoverRequest,
GenerateCoverResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.get("/cover", response_model=CoverConfigResponse)
def get_editor_cover(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> CoverConfigResponse:
"""获取草稿封面配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = plan.config or {}
cover_config = config.get("cover", {})
return CoverConfigResponse(
type=cover_config.get("cover_type", "auto"),
image_url=cover_config.get("cover_image_url", ""),
frame_time=cover_config.get("frame_time", 0.0),
)
@router.put("/cover", response_model=CoverConfigResponse)
def update_editor_cover(
template_id: str,
body: CoverUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> CoverConfigResponse:
"""更新草稿封面配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = dict(plan.config) if plan.config else {}
current_cover = dict(config.get("cover", {}))
update_data = body.model_dump(exclude_none=True)
current_cover.update(update_data)
config["cover"] = current_cover
normalized = normalize_plan_config(config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return CoverConfigResponse(
type=current_cover.get("cover_type", "auto"),
image_url=current_cover.get("cover_image_url", ""),
frame_time=current_cover.get("frame_time", 0.0),
)
@router.post("/cover/extract", response_model=CoverGenerateResponse)
def extract_editor_cover(
template_id: str,
body: CoverExtractRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> CoverGenerateResponse:
"""从指定片段抽帧生成封面"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
clip = plan_svc.get_clip(body.clip_id)
if not clip or clip.plan_id != plan_id:
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
config = dict(plan.config) if plan.config else {}
cover_config = dict(config.get("cover", {}))
cover_config.update(
{
"cover_type": "extract",
"cover_image_url": cover_url,
"clip_id": body.clip_id,
"frame_time": body.frame_time,
}
)
config["cover"] = cover_config
normalized = normalize_plan_config(config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
template_id,
plan_id,
body.clip_id,
current_user.user.id,
)
return CoverGenerateResponse(
type="extract",
image_url=cover_url,
frame_time=body.frame_time,
)
@router.post("/cover/smart", response_model=CoverGenerateResponse)
def smart_editor_cover(
template_id: str,
body: CoverSmartRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> CoverGenerateResponse:
"""智能选帧生成封面"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
cover_url = f"cover/smart/{plan_id}_smart.jpg"
strategy = getattr(body, "strategy", "auto")
config = dict(plan.config) if plan.config else {}
cover_config = dict(config.get("cover", {}))
cover_config.update(
{
"cover_type": "smart",
"cover_image_url": cover_url,
"strategy": strategy,
}
)
config["cover"] = cover_config
normalized = normalize_plan_config(config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
template_id,
plan_id,
strategy,
current_user.user.id,
)
return CoverGenerateResponse(
type="smart",
image_url=cover_url,
frame_time=None,
)
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def editor_generate_cover(
template_id: str,
body: GenerateCoverRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse:
"""AI 生成封面"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
from packages.shared.ai_service import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
template_id,
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
@@ -1,141 +0,0 @@
"""模板编辑器依赖注入.
核心依赖:
- get_editor_services: 获取模板+计划服务
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
- _check_queue_limits: 生成队列限流检查
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
logger = logging.getLogger(__name__)
def get_editor_services(
db: Session = Depends(get_db_session),
) -> tuple[EditTemplateService, EditPlanService]:
"""获取模板编辑器所需的两个服务"""
return EditTemplateService(db), EditPlanService(db)
def get_draft_plan_id(
template_id: str,
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
) -> str:
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
兼容策略:优先从新模板系统(edit_templates 表)查找,
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
"""
tpl_svc, plan_svc = services
user_id = str(current_user.user.id)
# 1. 草稿已存在 → 直接返回
draft = tpl_svc.get_template_draft(template_id)
if draft is not None:
return draft.id
# 2. 新系统有模板 → 用新服务创建草稿
if tpl_svc.get_template(template_id) is not None:
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
return draft.id
# 3. 回退到旧模板系统(templates 表)
old_repo = SQLAlchemyTemplateRepository(db)
old_template = old_repo.get(template_id, user_id=user_id)
if old_template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
# 4. 基于旧模板创建草稿计划
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
pseudo_template = EditTemplate(
id=old_template.id,
name=old_template.name,
editing_mode=old_template.mode,
status=EditTemplateStatus.ACTIVE,
)
# 将旧模板 segments 转换为 clip_configs
clip_configs: list[TemplateClipConfig] = []
for seg in old_template.segments or []:
clip_configs.append(
TemplateClipConfig(
id=f"seg_{seg.id}",
template_id=old_template.id,
clip_type=ClipType.MAIN,
order=seg.segment_order,
min_duration=seg.duration_min,
max_duration=seg.duration_max,
)
)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=pseudo_template,
clip_configs=clip_configs,
asset_ids=[],
created_by_user_id=user_id,
name=f"{old_template.name} - 草稿",
)
plan = result["plan"]
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
logger.info(
"旧模板自动创建草稿: template_id=%s draft_plan_id=%s user_id=%s",
template_id,
plan.id,
user_id,
)
return plan.id
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = (
hasattr(gen_task_repo, "count_pending_by_user")
and hasattr(gen_task_repo, "count_pending_total")
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
@@ -1,164 +0,0 @@
"""草稿管理路由.
端点:
- GET / 获取草稿详情
- PUT / 更新草稿
- POST /publish 发布草稿到模板
- GET /versions 模板版本历史
- POST /rollback 回滚到指定版本
"""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
EditorDraftResponse,
EditorPublishResponse,
EditorRollbackRequest,
EditorRollbackResponse,
EditorTemplateVersionItem,
EditorUpdateRequest,
EditorVersionListResponse,
)
router = APIRouter(tags=["Template Editor"])
@router.get("", response_model=EditorDraftResponse)
def get_editor_draft(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取模板编辑器草稿详情
首次访问时自动创建草稿。
"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
clips = plan_svc.list_clips(plan_id)
return EditorDraftResponse(
plan_id=plan.id,
template_id=plan.template_id,
name=plan.name,
status=plan.status.value if hasattr(plan.status, "value") else str(plan.status),
config=plan.config or {},
total_duration=plan.total_duration,
clip_count=len(clips),
)
@router.put("", response_model=EditorDraftResponse)
def update_editor_draft(
template_id: str,
req: EditorUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""更新模板编辑器草稿"""
_, plan_svc = services
plan = plan_svc.update_plan(
plan_id,
name=req.name,
config=req.config,
total_duration=req.total_duration,
)
clips = plan_svc.list_clips(plan_id)
return EditorDraftResponse(
plan_id=plan.id,
template_id=plan.template_id,
name=plan.name,
status=plan.status.value if hasattr(plan.status, "value") else str(plan.status),
config=plan.config or {},
total_duration=plan.total_duration,
clip_count=len(clips),
)
@router.post("/publish", response_model=EditorPublishResponse, status_code=status.HTTP_200_OK)
def publish_draft_to_template(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将草稿发布(同步)到正式模板
草稿的 config 和 clips 会同步覆盖到模板,事务保证一致性。
"""
tpl_svc, plan_svc = services
try:
tpl = tpl_svc.publish_template_from_draft(template_id, plan_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
clips = plan_svc.list_clips(plan_id)
return EditorPublishResponse(
template_id=tpl.id,
status="published",
clip_count=len(clips),
version=tpl.version,
)
@router.get("/versions", response_model=EditorVersionListResponse)
def list_template_versions(
template_id: str,
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
limit: int = Query(default=50, ge=1, le=200),
):
"""查询模板发布版本历史"""
tpl_svc, _ = services
versions = tpl_svc.list_template_versions(template_id, limit=limit)
items = [
EditorTemplateVersionItem(
version=v.version,
name=v.name,
editing_mode=v.editing_mode,
clip_count=len(v.clip_configs),
change_note=v.change_note,
published_by=v.published_by,
created_at=(
v.created_at.isoformat()
if hasattr(v.created_at, "isoformat")
else str(v.created_at)
),
)
for v in versions
]
return EditorVersionListResponse(versions=items, total=len(items))
@router.post("/rollback", response_model=EditorRollbackResponse, status_code=status.HTTP_200_OK)
def rollback_template(
template_id: str,
request: EditorRollbackRequest,
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""回滚模板到指定历史版本
回滚本身也是一次发布,版本号会 +1,可以再次回滚。
"""
tpl_svc, _ = services
try:
tpl = tpl_svc.rollback_to_version(template_id, request.version)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
clip_configs = tpl_svc.list_clip_configs(template_id)
return EditorRollbackResponse(
template_id=tpl.id,
status="rolled_back",
rollback_to_version=request.version,
new_version=tpl.version,
clip_count=len(clip_configs),
)
@@ -1,195 +0,0 @@
"""转场 & 滤镜路由.
端点:
- GET /transition-presets 转场预设列表
- PUT /clips/{clip_id}/transition 单片段转场
- POST /transitions/batch 批量转场
- GET /filter-presets 滤镜预设列表
- GET /filter 滤镜配置
- PUT /filter 更新滤镜
"""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
BatchTransitionRequest,
BatchTransitionResponse,
ClipTransitionResponse,
FilterConfigResponse,
FilterPresetListResponse,
FilterUpdateRequest,
TransitionPresetListResponse,
TransitionUpdateRequest,
)
router = APIRouter(tags=["Template Editor"])
# ── 转场 ────────────────────────────────────────────────────────────────────
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
def list_editor_transition_presets(
_: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse:
"""获取转场预设列表"""
from packages.domain.transition_presets import TRANSITION_PRESETS
items = [
{
"id": p["id"],
"name": p["name"],
"category": p.get("category", "通用"),
"duration": p.get("default_duration", 0.5),
"description": p.get("description", ""),
}
for p in TRANSITION_PRESETS
]
return TransitionPresetListResponse(items=items, total=len(items))
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
def update_editor_clip_transition(
template_id: str,
clip_id: str,
body: TransitionUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ClipTransitionResponse:
"""设置单个片段的转场效果"""
_, plan_svc = services
try:
clip = plan_svc.update_clip(
clip_id,
transition_effect=body.effect,
transition_duration=body.duration,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return ClipTransitionResponse(
clip_id=clip.id,
effect=clip.transition_effect.value
if hasattr(clip.transition_effect, "value")
else clip.transition_effect,
duration=clip.transition_duration or 0.5,
)
@router.post("/transitions/batch", response_model=BatchTransitionResponse)
def batch_update_editor_transitions(
template_id: str,
body: BatchTransitionRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> BatchTransitionResponse:
"""批量设置所有片段的转场效果"""
_, plan_svc = services
clips = plan_svc.list_clips(plan_id, limit=500)
updated = 0
for clip in clips:
if clip.order > 0: # 第一个片段不加转场
try:
plan_svc.update_clip(
clip.id,
transition_effect=body.effect,
transition_duration=body.duration,
)
updated += 1
except ValueError:
pass
return BatchTransitionResponse(
updated_count=updated,
plan_id=plan_id,
)
# ── 滤镜 ────────────────────────────────────────────────────────────────────
@router.get("/filter-presets", response_model=FilterPresetListResponse)
def list_editor_filter_presets(
_: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
from packages.domain.filter_presets import FILTER_PRESETS
items = [
{
"id": p["id"],
"name": p["name"],
"category": p.get("category", "通用"),
"thumbnail": p.get("thumbnail", ""),
"description": p.get("description", ""),
}
for p in FILTER_PRESETS
]
return FilterPresetListResponse(items=items, total=len(items))
@router.get("/filter", response_model=FilterConfigResponse)
def get_editor_filter(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> FilterConfigResponse:
"""获取草稿的全局滤镜配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = plan.config or {}
filter_config = config.get("filter", {})
return FilterConfigResponse(
plan_id=plan.id,
enabled=filter_config.get("enabled", False),
preset_id=filter_config.get("preset_id", ""),
intensity=filter_config.get("intensity", 1.0),
brightness=filter_config.get("brightness", 0.0),
contrast=filter_config.get("contrast", 1.0),
saturation=filter_config.get("saturation", 1.0),
warmth=filter_config.get("warmth", 0.0),
)
@router.put("/filter", response_model=FilterConfigResponse)
def update_editor_filter(
template_id: str,
body: FilterUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> FilterConfigResponse:
"""更新草稿的全局滤镜配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = dict(plan.config) if plan.config else {}
current_filter = dict(config.get("filter", {}))
update_data = body.model_dump(exclude_none=True)
current_filter.update(update_data)
config["filter"] = current_filter
updated_plan = plan_svc.update_plan_config(plan_id, normalize_plan_config(config))
return FilterConfigResponse(
plan_id=updated_plan.id,
enabled=current_filter.get("enabled", False),
preset_id=current_filter.get("preset_id", ""),
intensity=current_filter.get("intensity", 1.0),
brightness=current_filter.get("brightness", 0.0),
contrast=current_filter.get("contrast", 1.0),
saturation=current_filter.get("saturation", 1.0),
warmth=current_filter.get("warmth", 0.0),
)
@@ -1,106 +0,0 @@
"""导出配置路由.
端点:
- GET /export-presets 导出预设列表
- GET /export 导出配置
- PUT /export 更新导出配置
"""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import ExportConfigResponse, ExportPresetListResponse, ExportUpdateRequest
router = APIRouter(tags=["Template Editor"])
@router.get("/export-presets", response_model=ExportPresetListResponse)
def list_editor_export_presets(
_: AuthenticatedUser = Depends(get_current_user),
) -> ExportPresetListResponse:
"""获取导出预设列表"""
from packages.domain.export_presets import EXPORT_PRESETS
items = [
{
"id": p["id"],
"name": p["name"],
"resolution": p.get("resolution", "1080p"),
"fps": p.get("fps", 30),
"video_bitrate": p.get("bitrate", ""),
"audio_bitrate": p.get("audio_bitrate", 128),
"format": p.get("format", "mp4"),
"quality_preset": p.get("quality_preset", "balanced"),
"description": p.get("description", ""),
"size_hint": p.get("size_hint", ""),
}
for p in EXPORT_PRESETS
]
return ExportPresetListResponse(items=items, total=len(items))
@router.get("/export", response_model=ExportConfigResponse)
def get_editor_export(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ExportConfigResponse:
"""获取草稿的导出配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = plan.config or {}
export_config = config.get("export", {})
return ExportConfigResponse(
plan_id=plan.id,
resolution=export_config.get("resolution", "1080p"),
fps=export_config.get("fps", 30),
video_bitrate=export_config.get("video_bitrate", 4000),
audio_bitrate=export_config.get("audio_bitrate", 128),
format=export_config.get("format", "mp4"),
quality_preset=export_config.get("quality_preset", "balanced"),
watermark_enabled=export_config.get("watermark_enabled", True),
watermark_text=export_config.get("watermark_text", ""),
)
@router.put("/export", response_model=ExportConfigResponse)
def update_editor_export(
template_id: str,
body: ExportUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> ExportConfigResponse:
"""更新草稿的导出配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = dict(plan.config) if plan.config else {}
current_export = dict(config.get("export", {}))
update_data = body.model_dump(exclude_none=True)
current_export.update(update_data)
config["export"] = current_export
updated_plan = plan_svc.update_plan_config(plan_id, normalize_plan_config(config))
updated_export = (updated_plan.config or {}).get("export", {})
return ExportConfigResponse(
plan_id=updated_plan.id,
resolution=updated_export.get("resolution", "1080p"),
fps=updated_export.get("fps", 30),
video_bitrate=updated_export.get("video_bitrate", 4000),
audio_bitrate=updated_export.get("audio_bitrate", 128),
format=updated_export.get("format", "mp4"),
quality_preset=updated_export.get("quality_preset", "balanced"),
watermark_enabled=updated_export.get("watermark_enabled", True),
watermark_text=updated_export.get("watermark_text", ""),
)
@@ -1,250 +0,0 @@
"""草稿生成路由.
端点:
- POST /generate 触发生成
- GET /generation-status 生成进度
- GET /generations 生成记录列表
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
)
from app.schemas.generation_task import GenerationTaskResponse
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
from ._fallback import (
_auto_fallback_assign_assets,
_auto_fallback_auto_material_mode,
_auto_fallback_copy_template_clips,
_auto_fallback_draft_to_editing,
)
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
from .schemas import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.post("/generate", response_model=EditPlanGenerateResponse)
def generate_editor_draft(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发模板草稿渲染生成"""
_, plan_svc = services
plan_check = plan_svc.get_plan_or_raise(plan_id)
# 自动兜底流程
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
)
# 检查是否可生成
try:
can_gen, reason = plan_svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
if not can_gen:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=reason
)
try:
clip_count = plan_svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = plan_svc.get_plan_or_raise(plan_id)
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
),
)
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = plan_svc.get_plan_or_raise(plan_id)
logger.info(
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
template_id,
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value
if hasattr(updated_plan.status, "value")
else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception as _e:
logger.exception(
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
template_id,
plan_id,
)
try:
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
def get_editor_generation_status(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
storage_service: OSSStorageService = Depends(get_storage_service),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationStatusResponse:
"""查询草稿生成进度"""
_, plan_svc = services
try:
gen_status = plan_svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
plan = gen_status["plan"]
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
raw_video_url = (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
try:
video_url = storage_service.get_download_url(
raw_video_url, expires_seconds=86400
)
except Exception as e:
logger.warning(
"生成视频签名URL失败: template_id=%s error=%s", template_id, e
)
video_url = raw_video_url
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
plan_status_val = (
plan.status.value if hasattr(plan.status, "value") else plan.status
)
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@router.get("/generations", response_model=EditPlanGenerationsResponse)
def list_editor_generations(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationsResponse:
"""查询草稿关联的生成记录列表"""
_, plan_svc = services
plan_svc.get_plan_or_raise(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -1,622 +0,0 @@
"""模板编辑器所有 Pydantic Schema 定义.
集中管理,避免在路由文件里散落 40+ 个 model。
"""
from __future__ import annotations
import re as _re
from typing import Any, List, Optional
from app.schemas.generation_task import GenerationTaskResponse
from pydantic import BaseModel, Field, validator
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
# ── 生成状态相关 ────────────────────────────────────────────────────────────
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem]
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
class EditPlanGenerationsResponse(BaseModel):
"""剪辑计划关联的生成记录列表响应体"""
items: List[GenerationTaskResponse]
total: int
# ── AI 推荐 ────────────────────────────────────────────────────────────────
class AIRecommendRequest(BaseModel):
"""AI 推荐片段方案请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
editing_mode: str = Field(
default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip"
)
target_duration: float = Field(
default=30.0, ge=1.0, le=600.0, description="目标时长(秒)"
)
class AIRecommendClipItem(BaseModel):
"""AI 推荐的单个片段"""
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
order: int = Field(..., ge=0, description="片段顺序")
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
class AIRecommendResponse(BaseModel):
"""AI 推荐片段方案响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
config: dict[str, Any] = Field(..., description="推荐的 plan configcover/title/subtitle/bgm")
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── 封面生成 ────────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── BGM ────────────────────────────────────────────────────────────────────
class BGMConfigUpdateRequest(BaseModel):
"""更新BGM配置请求体"""
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
# ── 片段调整 ────────────────────────────────────────────────────────────────
class SpeedAdjustRequest(BaseModel):
"""调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
class VolumeAdjustRequest(BaseModel):
"""音量调节请求"""
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.01.0=原音量)")
class TrimAdjustRequest(BaseModel):
"""裁剪请求"""
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
class ClipAdjustmentsRequest(BaseModel):
"""统一调整请求"""
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
trim_start: Optional[float] = Field(default=None, ge=0.0)
trim_end: Optional[float] = Field(default=None, ge=0.0)
class BatchSpeedRequest(BaseModel):
"""批量调速请求"""
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
class ClipAdjustResponse(BaseModel):
"""片段调整响应"""
clip_id: str
speed: float
volume: float
trim_start: float
trim_end: float
duration: float
class BatchSpeedResponse(BaseModel):
"""批量调速响应"""
updated_count: int
plan_id: str
# ── 片段批量操作 ────────────────────────────────────────────────────────────
class ClipReorderItem(BaseModel):
"""重排序条目"""
clip_id: str
new_order: int = Field(..., ge=0, description="新的排序序号")
class ClipReorderRequest(BaseModel):
"""片段重排序请求"""
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
class ClipReorderResponse(BaseModel):
"""片段重排序响应"""
success: bool = True
updated_count: int
message: str = ""
class ClipBatchDeleteRequest(BaseModel):
"""批量删除片段请求"""
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
class ClipBatchDeleteResponse(BaseModel):
"""批量删除片段响应"""
success: bool = True
deleted_count: int
message: str = ""
class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求"""
asset_ids: List[str] = Field(
..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
)
clip_type: str = Field(default="main", description="片段类型,默认 main")
class ClipsFromAssetsResponse(BaseModel):
"""从素材批量创建片段响应"""
success: bool = True
created_count: int
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
# ── 封面配置 ────────────────────────────────────────────────────────────────
class CoverConfigResponse(BaseModel):
"""封面配置响应"""
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
image_url: str = Field(default="", description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
class CoverUpdateRequest(BaseModel):
"""更新封面配置请求"""
type: Optional[str] = Field(default=None, description="封面类型")
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
class CoverExtractRequest(BaseModel):
"""从片段抽帧生成封面请求"""
clip_id: str = Field(..., description="片段 ID")
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
class CoverSmartRequest(BaseModel):
"""智能选帧请求"""
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
class CoverGenerateResponse(BaseModel):
"""封面生成响应"""
type: str = Field(..., description="封面类型")
image_url: str = Field(..., description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
# ── 导出配置 ────────────────────────────────────────────────────────────────
class ExportConfigResponse(BaseModel):
"""导出配置响应"""
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
watermark_enabled: bool
watermark_text: str
class ExportUpdateRequest(BaseModel):
"""更新导出配置请求"""
resolution: Optional[str] = None
fps: Optional[int] = Field(default=None, ge=15, le=60)
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
format: Optional[str] = None
quality_preset: Optional[str] = None
watermark_enabled: Optional[bool] = None
watermark_text: Optional[str] = None
@validator("resolution")
def validate_resolution(cls, v):
if v is None:
return v
if not _EXPORT_RESOLUTION_PATTERN.match(v):
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
w, h = v.split("x")
if int(w) < 100 or int(h) < 100:
raise ValueError("分辨率数值过小")
if int(w) > 4096 or int(h) > 4096:
raise ValueError("分辨率数值过大,最大 4096x4096")
return v
@validator("format")
def validate_format(cls, v):
if v is None:
return v
if v not in _EXPORT_VALID_FORMATS:
raise ValueError(f"无效格式: {v},支持: {_EXPORT_VALID_FORMATS}")
return v
@validator("quality_preset")
def validate_quality_preset(cls, v):
if v is None:
return v
if v not in _EXPORT_VALID_QUALITY_PRESETS:
raise ValueError(f"无效质量预设: {v},支持: {_EXPORT_VALID_QUALITY_PRESETS}")
return v
class ExportPresetItem(BaseModel):
"""导出预设条目"""
id: str
name: str
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
description: str
size_hint: str
class ExportPresetListResponse(BaseModel):
"""导出预设列表响应"""
items: List[ExportPresetItem]
total: int
# ── 滤镜 ────────────────────────────────────────────────────────────────────
class FilterPresetResponse(BaseModel):
"""滤镜预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
class FilterConfigResponse(BaseModel):
"""滤镜配置响应"""
enabled: bool
preset_id: str
intensity: int
brightness: float
contrast: float
saturation: float
warmth: float
class FilterUpdateRequest(BaseModel):
"""更新滤镜配置请求"""
enabled: Optional[bool] = None
preset_id: Optional[str] = None
intensity: Optional[int] = Field(default=None, ge=0, le=100)
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
class FilterPresetListResponse(BaseModel):
"""滤镜预设列表响应"""
items: List[FilterPresetResponse]
total: int
# ── 转场 ────────────────────────────────────────────────────────────────────
class TransitionPresetResponse(BaseModel):
"""转场预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
default_duration: float
min_duration: float
max_duration: float
class TransitionUpdateRequest(BaseModel):
"""更新转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
class BatchTransitionRequest(BaseModel):
"""批量设置转场请求"""
effect: str = Field(..., description="转场效果 ID")
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
apply_to: str = Field(
default="all",
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
)
class ClipTransitionResponse(BaseModel):
"""片段转场信息响应"""
clip_id: str
effect: str
duration: float
class BatchTransitionResponse(BaseModel):
"""批量转场响应"""
updated_count: int
plan_id: str
class TransitionPresetListResponse(BaseModel):
"""转场预设列表响应"""
items: List[TransitionPresetResponse]
total: int
# ── 编辑器草稿 & 片段 ───────────────────────────────────────────────────────
class EditorDraftResponse(BaseModel):
"""模板编辑器草稿详情响应"""
plan_id: str
template_id: str
name: str
status: str
config: dict[str, Any]
total_duration: float
clip_count: int
is_draft: bool = True
class EditorUpdateRequest(BaseModel):
"""更新草稿请求"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
config: Optional[dict[str, Any]] = Field(default=None)
total_duration: Optional[float] = Field(default=None, ge=0.0)
class EditorClipResponse(BaseModel):
"""片段响应"""
id: str
plan_id: str
clip_type: str
order: int
duration: float
text_content: str = ""
transition_effect: str = "cut"
playback_speed: float = 1.0
config: dict[str, Any] = Field(default_factory=dict)
class EditorClipListResponse(BaseModel):
"""片段列表响应"""
items: List[EditorClipResponse]
total: int
class EditorClipCreateRequest(BaseModel):
"""创建片段请求"""
clip_type: str = Field(..., min_length=1, max_length=32)
order: int = Field(..., ge=0)
duration: float = Field(..., gt=0.0)
text_content: str = Field(default="", max_length=2000)
transition_effect: str = Field(default="cut", max_length=32)
config: dict[str, Any] = Field(default_factory=dict)
class EditorClipUpdateRequest(BaseModel):
"""更新片段请求"""
order: Optional[int] = Field(default=None, ge=0)
duration: Optional[float] = Field(default=None, gt=0.0)
text_content: Optional[str] = Field(default=None, max_length=2000)
transition_effect: Optional[str] = Field(default=None, max_length=32)
playback_speed: Optional[float] = Field(default=None, gt=0.0)
config: Optional[dict[str, Any]] = None
class EditorPublishResponse(BaseModel):
"""发布草稿响应"""
template_id: str
status: str = "published"
clip_count: int
version: int = 1
class EditorTemplateVersionItem(BaseModel):
"""模板版本历史条目"""
version: int
name: str
editing_mode: str
clip_count: int
change_note: str
published_by: str
created_at: str
class EditorVersionListResponse(BaseModel):
"""模板版本列表响应"""
versions: list[EditorTemplateVersionItem]
total: int
class EditorRollbackRequest(BaseModel):
"""回滚请求体"""
version: int
class EditorRollbackResponse(BaseModel):
"""回滚响应"""
template_id: str
status: str = "rolled_back"
rollback_to_version: int
new_version: int
clip_count: int
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
class SplitClipRequest(BaseModel):
"""分割片段请求体"""
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
class MergeClipsRequest(BaseModel):
"""合并片段请求体"""
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
# ── 时间线 ──────────────────────────────────────────────────────────────────
class EditorTimelineSceneResponse(BaseModel):
"""时间线场景"""
scene: str
time: str
duration: float
color: str
clip_id: str = ""
clip_type: str = ""
class EditorTimelineResponse(BaseModel):
"""时间线响应"""
plan_id: str
total_duration: float
scenes: List[EditorTimelineSceneResponse]
@@ -1,173 +0,0 @@
"""字幕管理路由.
端点:
- GET /clips/{clip_id}/subtitles 字幕列表
- POST /clips/{clip_id}/subtitles 新增字幕
- PUT /clips/{clip_id}/subtitles/{subtitle_id} 更新字幕
- DELETE /clips/{clip_id}/subtitles/{subtitle_id} 删除字幕
- PUT /clips/{clip_id}/subtitles 批量更新字幕(全量替换)
"""
from __future__ import annotations
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, status
from .dependencies import get_draft_plan_id, get_editor_services
router = APIRouter(tags=["Template Editor"])
def _get_clip_subtitles(plan_svc: EditPlanService, clip_id: str, plan_id: str) -> list[dict[str, Any]]:
"""获取片段字幕列表,统一校验"""
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail="片段不存在")
config = clip.config or {}
subtitles = config.get("subtitles", [])
if not isinstance(subtitles, list):
subtitles = []
return subtitles
@router.get("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]])
def get_editor_clip_subtitles(
template_id: str,
clip_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> list[dict[str, Any]]:
"""获取片段的字幕列表"""
_, plan_svc = services
return _get_clip_subtitles(plan_svc, clip_id, plan_id)
@router.post("/clips/{clip_id}/subtitles", response_model=dict[str, Any])
def create_editor_clip_subtitle(
template_id: str,
clip_id: str,
body: dict[str, Any],
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, Any]:
"""新增片段字幕"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
config = dict(clip.config) if clip.config else {}
subtitles = config.get("subtitles", [])
if not isinstance(subtitles, list):
subtitles = []
new_id = f"sub_{len(subtitles) + 1}"
new_subtitle = {
"id": body.get("id", new_id),
"start_time": body.get("start_time", 0.0),
"end_time": body.get("end_time", 0.0),
"text": body.get("text", ""),
"style": body.get("style", {}),
}
subtitles.append(new_subtitle)
config["subtitles"] = subtitles
plan_svc.update_clip(clip_id, config=config)
return new_subtitle
@router.put("/clips/{clip_id}/subtitles/{subtitle_id}", response_model=dict[str, Any])
def update_editor_clip_subtitle(
template_id: str,
clip_id: str,
subtitle_id: str,
body: dict[str, Any],
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, Any]:
"""更新片段字幕"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
config = dict(clip.config) if clip.config else {}
subtitles = config.get("subtitles", [])
if not isinstance(subtitles, list):
subtitles = []
found = False
for i, sub in enumerate(subtitles):
if sub.get("id") == subtitle_id:
subtitles[i].update(body)
found = True
break
if not found:
raise HTTPException(status_code=404, detail="字幕不存在")
config["subtitles"] = subtitles
plan_svc.update_clip(clip_id, config=config)
return subtitles[i]
@router.delete(
"/clips/{clip_id}/subtitles/{subtitle_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_editor_clip_subtitle(
template_id: str,
clip_id: str,
subtitle_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""删除片段字幕"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
config = dict(clip.config) if clip.config else {}
subtitles = config.get("subtitles", [])
if not isinstance(subtitles, list):
subtitles = []
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
if len(new_subtitles) == len(subtitles):
raise HTTPException(status_code=404, detail="字幕不存在")
config["subtitles"] = new_subtitles
plan_svc.update_clip(clip_id, config=config)
return None
@router.put("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]])
def batch_update_editor_clip_subtitles(
template_id: str,
clip_id: str,
body: list[dict[str, Any]],
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> list[dict[str, Any]]:
"""批量更新片段字幕(全量替换)"""
_, plan_svc = services
clip = plan_svc.get_clip(clip_id)
if not clip:
raise HTTPException(status_code=404, detail="片段不存在")
config = dict(clip.config) if clip.config else {}
config["subtitles"] = body
plan_svc.update_clip(clip_id, config=config)
return body
@@ -1,61 +0,0 @@
"""时间线路由.
端点:
- GET /timeline 时间线场景数据
"""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends
from ._utils import _CLIP_TYPE_COLORS, _DEFAULT_COLOR, _clip_type_to_scene_label, _format_time
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import EditorTimelineResponse, EditorTimelineSceneResponse
router = APIRouter(tags=["Template Editor"])
@router.get("/timeline", response_model=EditorTimelineResponse)
def get_editor_timeline(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditorTimelineResponse:
"""获取草稿的时间线场景数据"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
clips = plan_svc.list_clips(plan_id=plan_id, skip=0, limit=200)
clips.sort(key=lambda c: c.order)
scenes = []
current_time = 0.0
for clip in clips:
start = current_time
end = start + clip.duration
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
scenes.append(
EditorTimelineSceneResponse(
scene=scene_label,
time=f"{_format_time(start)} - {_format_time(end)}",
duration=clip.duration,
color=color,
clip_id=clip.id,
clip_type=clip.clip_type,
)
)
current_time = end
total_duration = sum(s.duration for s in scenes) or plan.total_duration
return EditorTimelineResponse(
plan_id=plan_id,
total_duration=total_duration,
scenes=scenes,
)
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item) return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository) use_case = UpdateTitleLibraryUseCase(title_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item) return _to_response(item)
+13 -10
View File
@@ -6,7 +6,6 @@ import logging
from typing import Optional from typing import Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import ( from app.dependencies import (
get_audio_url_signer, get_audio_url_signer,
get_cosyvoice_service, get_cosyvoice_service,
@@ -182,9 +181,13 @@ def synthesize(
try: try:
if is_segment: if is_segment:
celery_app.send_task("worker.process_tts_segment_synthesis", args=[job.id]) from worker_app.tasks import process_tts_segment_synthesis
process_tts_segment_synthesis.delay(job.id)
else: else:
celery_app.send_task("worker.process_tts_synthesis", args=[job.id]) from worker_app.tasks import process_tts_synthesis
process_tts_synthesis.delay(job.id)
except Exception as e: except Exception as e:
# Celery 调度失败,标记 job 为 failed # Celery 调度失败,标记 job 为 failed
try: try:
@@ -233,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository) use_case = GetTTSJobUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url) return _to_response(job, sign_url)
@@ -250,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository) use_case = GetTTSJobStatusUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url output_url = job.output_audio_url
if output_url: if output_url:
output_url = sign_url(output_url) output_url = sign_url(output_url)
@@ -306,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository) get_use_case = GetTTSJobUseCase(tts_repository)
try: try:
job = get_use_case.execute(job_id, user_id) job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成 # 校验已完成
if not job.is_completed: if not job.is_completed:
@@ -360,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return SaveToLibraryResponse( return SaveToLibraryResponse(
id=item.id, id=item.id,
+10 -69
View File
@@ -1,20 +1,19 @@
import logging import logging
import uuid
from app.api.routes._helpers import format_utc_datetime from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository from app.dependencies import get_generated_video_repository
from app.schemas.video_center import ( from app.schemas.video_center import (
BatchDeleteRequest,
BatchDownloadRequest, BatchDownloadRequest,
BatchDownloadResponse, BatchDownloadResponse,
BatchOperationResponse,
ListVideosResponse, ListVideosResponse,
UpdateVideoReviewRequest, UpdateVideoReviewRequest,
VideoItemResponse, VideoItemResponse,
) )
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import ( from packages.application import (
GetGeneratedVideoUseCase, GetGeneratedVideoUseCase,
@@ -51,13 +50,13 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
review_status=item.review_status, review_status=item.review_status,
generation_params=item.generation_params, generation_params=item.generation_params,
download_url=download_url, download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
) )
@router.get("/videos", response_model=ListVideosResponse) @router.get("/videos", response_model=ListVideosResponse)
def list_videos( def list_videos(
project_id: str | None = Query(None, description="项目ID可选过滤"), project_id: str | None = Query(None, description="项目ID不传则返回所有项目"),
status: str | None = Query(None, description="按状态筛选"), status: str | None = Query(None, description="按状态筛选"),
review_status: str | None = Query(None, description="按复核状态筛选"), review_status: str | None = Query(None, description="按复核状态筛选"),
page: int = Query(1, ge=1, description="页码"), page: int = Query(1, ge=1, description="页码"),
@@ -66,10 +65,9 @@ def list_videos(
storage: OSSStorageService = Depends(get_storage_service), storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
): ):
"""成片列表,默认返回当前用户的所有成片,支持按项目/状态/复核状态筛选。""" """成片列表,支持分页、按项目/状态/复核状态筛选。"""
use_case = ListGeneratedVideosPaginatedUseCase(repo) use_case = ListGeneratedVideosPaginatedUseCase(repo)
items, total = use_case.execute( items, total = use_case.execute(
user_id=current_user.user.id,
project_id=project_id, project_id=project_id,
status=status, status=status,
review_status=review_status, review_status=review_status,
@@ -112,67 +110,10 @@ def update_video_review_status(
item = use_case.execute(video_id, request.review_status) item = use_case.execute(video_id, request.review_status)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Video not found") raise HTTPException(status_code=404, detail="Video not found")
logger.info( logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user.id
)
return _to_video_response(item, storage) return _to_video_response(item, storage)
@router.delete("/videos/{video_id}", status_code=204, response_class=Response)
def delete_video(
video_id: str,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""删除单个成片(硬删除)。"""
use_case = GetGeneratedVideoUseCase(repo)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail="Video not found")
# 尝试删除 OSS 文件,失败不影响数据库删除
if item.file_url:
try:
key = storage._normalize_storage_key(item.file_url)
storage.delete_file(key)
except Exception:
logger.warning("删除 OSS 视频文件失败,跳过: video_id=%s", video_id)
repo.delete(video_id)
return Response(status_code=204)
@router.post("/videos/batch-delete", response_model=BatchOperationResponse)
def batch_delete_videos(
request: BatchDeleteRequest,
repo=Depends(get_generated_video_repository),
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""批量删除成片。"""
videos = repo.get_by_ids(request.video_ids)
existing_ids = {v.id for v in videos}
failed_ids = [vid for vid in request.video_ids if vid not in existing_ids]
failed_details = {vid: "Video not found" for vid in failed_ids}
# 尝试删除 OSS 文件
for video in videos:
if video.file_url:
try:
key = storage._normalize_storage_key(video.file_url)
storage.delete_file(key)
except Exception:
logger.warning("批量删除 OSS 视频文件失败,跳过: video_id=%s", video.id)
success_count = repo.batch_delete(list(existing_ids))
return BatchOperationResponse(
success_count=success_count,
failed_ids=failed_ids,
failed_details=failed_details,
)
@router.post("/videos/batch-download", response_model=BatchDownloadResponse) @router.post("/videos/batch-download", response_model=BatchDownloadResponse)
def batch_download_videos( def batch_download_videos(
request: BatchDownloadRequest, request: BatchDownloadRequest,
@@ -180,7 +121,7 @@ def batch_download_videos(
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
): ):
"""批量下载成片,异步打包 zip。 """批量下载成片,异步打包 zip。
传入 video_ids 列表创建一个批量下载任务任务完成后返回 zip 下载链接 传入 video_ids 列表创建一个批量下载任务任务完成后返回 zip 下载链接
""" """
if not request.video_ids: if not request.video_ids:
@@ -197,7 +138,7 @@ def batch_download_videos(
# 发送 celery 任务 # 发送 celery 任务
task = celery_app.send_task( task = celery_app.send_task(
"worker.batch_download_videos", "worker.batch_download_videos",
args=[request.video_ids, current_user.user.id], args=[request.video_ids, current_user.user_id],
) )
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids)) logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
@@ -213,7 +154,7 @@ def get_batch_download_status(
from celery.result import AsyncResult from celery.result import AsyncResult
task = AsyncResult(job_id, app=celery_app) task = AsyncResult(job_id, app=celery_app)
status_map = { status_map = {
"PENDING": "pending", "PENDING": "pending",
"STARTED": "running", "STARTED": "running",
+14 -11
View File
@@ -6,7 +6,6 @@ import logging
from typing import Optional from typing import Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
from app.schemas.voice_clone import ( from app.schemas.voice_clone import (
CreateVoiceCloneRequest, CreateVoiceCloneRequest,
@@ -98,7 +97,9 @@ def create_voice_clone(
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
if profile.status == "processing" and task_id: if profile.status == "processing" and task_id:
try: try:
celery_app.send_task("worker.process_voice_clone", args=[profile.id]) from worker_app.tasks import process_voice_clone
process_voice_clone.delay(profile.id)
logger.info(f"Celery task dispatched for voice clone {profile.id}") logger.info(f"Celery task dispatched for voice clone {profile.id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to dispatch Celery task: {e}") logger.error(f"Failed to dispatch Celery task: {e}")
@@ -140,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository) use_case = GetVoiceCloneUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return _to_response(profile) return _to_response(profile)
@@ -156,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository) use_case = GetVoiceCloneStatusUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return VoiceCloneStatusResponse( return VoiceCloneStatusResponse(
id=profile.id, id=profile.id,
status=profile.status, status=profile.status,
@@ -200,19 +201,21 @@ def retry_voice_clone(
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
try: try:
profile = workflow.retry_clone(clone_id, user_id) profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError as _e: except VoiceCloneNotRetryableError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)", detail="Voice clone is not retryable (only failed clones can be retried)",
) from _e )
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
if profile.status == "processing" and task_id: if profile.status == "processing" and task_id:
try: try:
celery_app.send_task("worker.process_voice_clone", args=[profile.id]) from worker_app.tasks import process_voice_clone
process_voice_clone.delay(profile.id)
logger.info(f"Celery task dispatched for voice clone retry {profile.id}") logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to dispatch Celery task: {e}") logger.error(f"Failed to dispatch Celery task: {e}")
+5 -65
View File
@@ -9,7 +9,7 @@ from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.schemas.voice import ( from app.schemas.voice import (
PresetVoiceItemResponse, PresetVoiceItemResponse,
PresetVoiceListResponse, PresetVoiceListResponse,
@@ -27,7 +27,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
from packages.application.voice_library.use_cases import ( from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase, CreateVoiceLibraryUseCase,
@@ -38,18 +37,11 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError, QuotaExceededError,
UpdateVoiceLibraryUseCase, UpdateVoiceLibraryUseCase,
) )
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
router = APIRouter() router = APIRouter()
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
# key: voice_id, value: (audio_url, timestamp)
_preset_preview_cache: dict[str, tuple[str, float]] = {}
PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
# 每个预置音色的默认试听文本
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository: def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
return SQLAlchemyVoiceLibraryRepository(session) return SQLAlchemyVoiceLibraryRepository(session)
@@ -224,58 +216,6 @@ def list_preset_voices() -> PresetVoiceListResponse:
return PresetVoiceListResponse(items=items, total=len(items)) return PresetVoiceListResponse(items=items, total=len(items))
@router.get("/presets/{voice_id}/preview")
def get_preset_voice_preview(
voice_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> dict:
"""获取预置音色试听音频(实时 TTS 合成)。
- 首次调用会合成并缓存7天
- 相同 voice_id 重复调用直接返回缓存的音频URL
- 可传入自定义 text 参数试听不同文本
"""
import time
preset = get_preset_voice_by_id(voice_id)
if preset is None:
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
# 有自定义文本时不缓存
use_cache = not text.strip()
if use_cache and voice_id in _preset_preview_cache:
audio_url, cached_at = _preset_preview_cache[voice_id]
if time.time() - cached_at < PREVIEW_CACHE_TTL:
return {"voice_id": voice_id, "audio_url": audio_url, "cached": True}
# 合成试听音频
preview_text = text.strip() or PREVIEW_TEMPLATE.format(name=preset.name)
try:
result = cosyvoice.synthesize_speech(
text=preview_text,
voice_id=preset.voice_id,
format="mp3",
speed=1.0,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
audio_url = result.audio_url
# 缓存(仅默认试听文本)
if use_cache:
_preset_preview_cache[voice_id] = (audio_url, time.time())
return {
"voice_id": voice_id,
"audio_url": audio_url,
"text": preview_text,
"cached": False,
}
# ==================== 原有 CRUD 端点(保持向后兼容)==================== # ==================== 原有 CRUD 端点(保持向后兼容)====================
@@ -347,7 +287,7 @@ def create_voice(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item, sign_url) return _to_response(item, sign_url)
@@ -377,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository) use_case = UpdateVoiceLibraryUseCase(voice_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url) return _to_response(item, sign_url)
+166 -23
View File
@@ -1,28 +1,171 @@
"""API 服务配置(向后兼容层)。 import os
from typing import Optional
统一配置已迁移到 packages.config.api_settings from pydantic import AliasChoices, Field, field_validator
新代码请使用 from pydantic_settings import BaseSettings, SettingsConfigDict
from packages.config import APISettings, get_api_settings
本文件保留 Settings 类名get_settings() 函数settings 模块级单例
确保所有旧的 import 路径仍然有效
"""
from packages.config import APISettings as Settings class Settings(BaseSettings):
from packages.config import get_api_settings as get_settings APP_NAME: str = "xiaoxia-saas"
from packages.config import reload_settings_cache APP_VERSION: str = "0.1.61"
ENVIRONMENT: str = "development"
DEBUG: bool = True
# 应用基础 URL,用于生成认证邮件中的链接
# 开发环境默认 http://localhost:3000
# 生产环境应通过环境变量 APP_BASE_URL 设置
APP_BASE_URL: str = "http://localhost:3000"
# Container bind address; external expose is controlled by Docker/Nginx.
API_HOST: str = "0.0.0.0" # nosec: B104
API_PORT: int = 8000
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
DATABASE_POOL_TIMEOUT: int = 30
DATABASE_POOL_RECYCLE: int = 3600
USE_IN_MEMORY_DB: bool = False
AUTO_CREATE_SCHEMA: bool = False
REDIS_URL: str = "redis://localhost:6379/0"
ENABLE_REDIS_SESSIONS: bool = False
# JWT secret key - MUST be set via environment variable, no default allowed
JWT_SECRET_KEY: Optional[str] = None
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
@field_validator("JWT_SECRET_KEY", mode="before")
@classmethod
def validate_jwt_secret_key(cls, v):
if v is None or v == "":
raise ValueError(
"JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!"
)
# Block known insecure default values
insecure_defaults = [
"your-secret-key-change-in-production",
"your-secret-key",
"secret",
"changeme",
"password",
]
if v.lower() in [d.lower() for d in insecure_defaults]:
raise ValueError(
f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable."
)
return v
ENABLE_EMAIL_DELIVERY: bool = False
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM_EMAIL: str = ""
SMTP_FROM_NAME: str = "小虾 SaaS"
SMTP_USE_TLS: bool = True
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
# OSS 七牛云相关
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
@classmethod
def validate_oss_access_key_id(cls, v):
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
raise ValueError(
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
)
return v or ""
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
@classmethod
def validate_oss_access_key_secret(cls, v):
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
raise ValueError(
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
)
return v or ""
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
default=2000,
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
RENDER_ENGINE: str = "legacy"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
@property
def CORS_ORIGINS(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()]
@property
def database_url(self) -> str:
return self.DATABASE_URL
@property
def redis_url(self) -> str:
return self.REDIS_URL
@property
def celery_broker_url(self) -> str:
return self.CELERY_BROKER_URL
@property
def celery_result_backend(self) -> str:
return self.CELERY_RESULT_BACKEND
@property
def oss_endpoint(self) -> str:
return self.OSS_ENDPOINT
@property
def oss_access_key_id(self) -> str:
return self.OSS_ACCESS_KEY_ID
@property
def oss_access_key_secret(self) -> str:
return self.OSS_ACCESS_KEY_SECRET
@property
def oss_bucket_name(self) -> str:
return self.OSS_BUCKET_NAME
_settings: Optional[Settings] = None
def get_settings() -> Settings:
global _settings
if _settings is None:
env = os.getenv("APP_ENV", "development")
env_file = f".env.{env}" if env != "development" else ".env"
if os.path.exists(env_file):
_settings = Settings(_env_file=env_file)
else:
_settings = Settings()
return _settings
# 模块级单例(向后兼容)
settings = get_settings() settings = get_settings()
# 暴露旧的 reload_settings 函数名
def reload_settings():
"""重新加载配置(测试用)。"""
reload_settings_cache()
global settings
settings = get_settings()
return settings
__all__ = ["Settings", "get_settings", "settings", "reload_settings"]
Executable → Regular
+9 -10
View File
@@ -1,20 +1,19 @@
"""向后兼容层 — 配置已统一到 packages.config。 """Compatibility layer for the canonical API settings module.
新代码请使用 Use `app.config` as the single source of truth for API configuration.
from packages.config import get_api_settings, APISettings This module remains only for older imports during migration.
""" """
from packages.config import APISettings as AppSettings from app.config import Settings as AppSettings
from packages.config import get_api_settings as get_settings from app.config import get_settings, settings
from packages.config import reload_settings_cache
def reload_settings() -> AppSettings: def reload_settings() -> AppSettings:
"""重新加载配置(测试用)。""" """Reload settings for tests and legacy callers."""
reload_settings_cache() import app.config as canonical_config
return get_settings()
canonical_config.settings = canonical_config.get_settings()
return canonical_config.settings
settings = get_settings()
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"] __all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
+2 -2
View File
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
global_pending_limit, global_pending_limit,
user_id or "unknown", user_id or "unknown",
) )
exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit) exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc raise exc
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
if global_over or user_over: if global_over or user_over:
if global_over: if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else: else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}" reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit) exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
+1 -1
View File
@@ -132,7 +132,7 @@ def get_tag_repository(
session: Session = Depends(get_db_session), session: Session = Depends(get_db_session),
) -> TagRepository: ) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation.""" """Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session) # type: ignore[return-value] return SQLAlchemyTagRepository(session)
def get_user_repository( def get_user_repository(
+1 -1
View File
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self.paths = set(paths) if paths else None self.paths = set(paths) if paths else None
self.requests: dict[str, list[float]] = {} self.requests = {} # {ip: [timestamps]}
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# 如果配置了路径过滤,只对指定路径限流 # 如果配置了路径过滤,只对指定路径限流
-1
View File
@@ -50,7 +50,6 @@ class AssetResponse(BaseModel):
status: str status: str
classification_status: str classification_status: str
quality_score: float | None = None quality_score: float | None = None
created_at: str
uploaded_by_user_id: str uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list) tag_ids: list[str] = Field(default_factory=list)
-15
View File
@@ -23,8 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ── # ── 来源剪辑计划 ──
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
# ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ── # ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50") count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ── # ── 素材库自动匹配 ──
@@ -46,16 +44,6 @@ class CreateGenerationTaskRequest(BaseModel):
le=5, le=5,
description="最大自动重试次数,0表示不自动重试,最大5次", description="最大自动重试次数,0表示不自动重试,最大5次",
) )
# ── 输出分辨率 ──
resolution: str = Field(
default="",
description="输出分辨率,格式为 WIDTHxHEIGHT,如 1280x720、1080x1920。为空使用默认 1280x720",
)
# ── 自定义 BGM ──
bgm_config: dict = Field(
default_factory=dict,
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
)
@model_validator(mode="after") @model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -83,9 +71,6 @@ class GenerationTaskResponse(BaseModel):
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
asset_select_mode: str = "" asset_select_mode: str = ""
batch_id: str = "" batch_id: str = ""
video_title: str = ""
resolution: str = ""
bgm_config: dict = Field(default_factory=dict)
status: str status: str
progress: float progress: float
result_count: int result_count: int
+6 -4
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -15,8 +17,8 @@ class ProjectTaskResponse(BaseModel):
retry_count: int = 0 retry_count: int = 0
source_id: str = "" source_id: str = ""
template_id: str = "" template_id: str = ""
created_at: str = "" created_at: datetime | None = None
updated_at: str = "" updated_at: datetime | None = None
class ListProjectTasksResponse(BaseModel): class ListProjectTasksResponse(BaseModel):
@@ -40,8 +42,8 @@ class UserTaskResponse(BaseModel):
retryable: bool = False retryable: bool = False
retry_count: int = 0 retry_count: int = 0
source_id: str = "" source_id: str = ""
created_at: str = "" created_at: datetime | None = None
updated_at: str = "" updated_at: datetime | None = None
class ListTasksResponse(BaseModel): class ListTasksResponse(BaseModel):
-13
View File
@@ -35,19 +35,6 @@ class UpdateVideoReviewRequest(BaseModel):
review_status: VideoReviewStatus review_status: VideoReviewStatus
MAX_BATCH_DELETE_SIZE = 200
class BatchDeleteRequest(BaseModel):
video_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_DELETE_SIZE, description="要删除的成片ID列表")
class BatchOperationResponse(BaseModel):
success_count: int = Field(..., ge=0, description="成功删除数量")
failed_ids: list[str] = Field(default_factory=list, description="失败的ID列表")
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情")
class BatchDownloadRequest(BaseModel): class BatchDownloadRequest(BaseModel):
video_ids: list[str] video_ids: list[str]
-92
View File
@@ -1,92 +0,0 @@
"""视频分享相关 schemas."""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class CreateShareRequest(BaseModel):
"""创建分享请求."""
password: Optional[str] = Field(
None,
description="访问密码(可选,不设置则无需密码)",
min_length=0,
max_length=50,
)
expires_at: Optional[datetime] = Field(
None,
description="过期时间(可选,不设置则永久有效)",
)
class UpdateShareRequest(BaseModel):
"""更新分享配置请求."""
password: Optional[str] = Field(
None,
description="新密码(传空字符串清除密码,不传则不修改)",
max_length=50,
)
expires_at: Optional[datetime] = Field(
None,
description="新的过期时间(不传则不修改)",
)
class VerifySharePasswordRequest(BaseModel):
"""验证分享密码请求."""
password: str = Field(..., description="访问密码")
class ShareResponse(BaseModel):
"""分享记录响应."""
id: str
video_id: str
share_token: str
has_password: bool = False
expires_at: Optional[datetime] = None
view_count: int = 0
download_count: int = 0
is_active: bool = True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class ShareListResponse(BaseModel):
"""分享列表响应."""
items: List[ShareResponse]
total: int = 0
skip: int = 0
limit: int = 20
class ShareAccessResponse(BaseModel):
"""分享访问成功响应(含视频信息)."""
share: ShareResponse
video_name: str
video_duration: float = 0.0
video_size: int = 0
thumbnail_url: Optional[str] = None
download_url: Optional[str] = None
password_verified: bool = True
class ShareMetaResponse(BaseModel):
"""分享元信息响应(访问前获取,用于判断是否需要密码)。"""
share_token: str
has_password: bool = False
is_expired: bool = False
is_active: bool = True
video_name: str = ""
video_duration: float = 0.0
thumbnail_url: Optional[str] = None
created_at: Optional[datetime] = None
-348
View File
@@ -1,348 +0,0 @@
"""统一 AI 服务层 — 豆包大模型接入.
提供基于字节跳动豆包大模型的 AI 能力
- 智能标题生成爆款/情感/信息三种风格
- 后续扩展智能素材匹配AI 推荐片段编排等
设计原则
1. API Key 或调用失败时自动降级为本地模拟不阻塞主流程
2. 统一的客户端封装新增能力只需加方法
3. 所有模型相关配置集中在 Settings
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
# ── 智能标题风格定义 ─────────────────────────────────────────────────────────
TITLE_STYLES = {
"viral": {
"name": "爆款",
"description": "吸引点击、引发好奇的爆款标题,带有数字、疑问或反差感",
"examples": [
"3个方法让你效率翻倍,第2个最绝",
"为什么越努力越穷?真相扎心了",
"看完这个,我删掉了手机里一半的APP",
],
},
"emotional": {
"name": "情感",
"description": "触动人心、引发共鸣的情感向标题",
"examples": [
"那些年我们一起追过的梦想",
"生活不易,但请相信光",
"致每一个在城市里打拼的你",
],
},
"informative": {
"name": "信息",
"description": "清晰直白、传递核心信息的干货标题",
"examples": [
"2026年最新个税政策解读,一文讲透",
"新手剪辑入门:从0到1完整指南",
"产品对比:10款热门手机深度评测",
],
},
}
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
def _generate_titles_fallback(
description: str,
style: str = "viral",
count: int = 5,
) -> List[str]:
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
return _generate_titles_fallback_base(description, style_info, count)
def generate_smart_titles(
description: str,
style: str = "viral",
count: int = 5,
) -> Dict[str, Any]:
"""生成智能标题.
Args:
description: 视频内容描述
style: 标题风格 viral/emotional/informative
count: 生成数量5-10
Returns:
{
"titles": [...],
"style": "viral",
"source": "doubao" | "fallback", # 实际来源
"description": "...",
}
"""
# 参数校验与边界处理
if style not in TITLE_STYLES:
style = "viral"
count = max(3, min(10, count)) # 3-10 个
description = (description or "").strip()
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级生成标题")
titles = _generate_titles_fallback(description, style, count)
return {
"titles": titles,
"style": style,
"source": "fallback",
"description": description,
}
style_info = TITLE_STYLES[style]
system_prompt = (
f"你是一个专业的短视频标题创作专家,擅长根据视频内容生成吸引人的标题。\n"
f"请根据以下视频描述,生成{count}{style_info['name']}风格的标题。\n"
f"风格说明:{style_info['description']}\n"
f"要求:\n"
f"1. 每个标题控制在8-25字之间\n"
f"2. 直接返回JSON数组格式,不要其他文字\n"
f"3. 标题要贴合内容,有吸引力"
)
user_prompt = f"视频描述:{description}\n\n请生成标题:"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.8,
max_tokens=512,
)
if result:
titles = _parse_titles_from_response(result)
if len(titles) >= 2: # 至少解析出2个才算成功
titles = titles[:count]
logger.info(
"豆包智能标题生成成功: style=%s count=%d description=%s...",
style,
len(titles),
description[:20],
)
return {
"titles": titles,
"style": style,
"source": "doubao",
"description": description,
}
logger.warning("豆包返回内容解析失败,降级到本地生成: %s", result[:100])
# 降级到本地生成
titles = _generate_titles_fallback(description, style, count)
return {
"titles": titles,
"style": style,
"source": "fallback",
"description": description,
}
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
def _semantic_match_fallback(
description: str,
assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
return _semantic_match_fallback_base(description, assets)
def _parse_semantic_match_response(
content: str,
asset_ids: List[str],
) -> Optional[Dict[str, float]]:
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
result = _parse_semantic_match_base(content, asset_ids)
if result is None:
return None
return dict(result)
def semantic_match_assets(
description: str,
assets: List[Dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
"""智能素材语义匹配.
根据用户描述评估每个素材的语义匹配度并排序
Args:
description: 用户描述的目标视频内容
assets: 素材列表每个素材需含 id/name/tags/description 等字段
top_k: 返回前K个0表示返回全部
Returns:
{
"matches": [{"asset_id": ..., "match_score": ..., ...}],
"source": "doubao" | "fallback",
"description": "...",
"total": 总数,
}
"""
description = (description or "").strip()
if not assets:
return {"matches": [], "source": "fallback", "description": description, "total": 0}
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级做素材语义匹配")
matched = _semantic_match_fallback(description, assets)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "fallback",
"description": description,
"total": len(assets),
}
# 构建素材信息(控制 token 数量)
asset_summaries = []
for asset in assets[:50]: # 最多传50个素材给模型
aid = asset.get("id", "")
name = asset.get("name", "")[:50]
tags = asset.get("tags", [])
tags_str = ",".join(str(t) for t in tags[:5])
desc = str(asset.get("description", ""))[:80]
asset_summaries.append(f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}")
asset_ids = [str(a.get("id", "")) for a in assets[:50]]
system_prompt = (
"你是一个专业的视频素材匹配助手。"
"根据用户的视频目标描述,评估每个素材的匹配程度。\n"
"评分规则:\n"
"- 0.0-0.3: 完全不相关\n"
"- 0.3-0.6: 有一定关联但不够匹配\n"
"- 0.6-0.8: 比较匹配,适合使用\n"
"- 0.8-1.0: 高度匹配,非常适合\n"
"只返回JSON对象,key为素材ID,value为匹配分数(0-1之间的小数)。"
"不要其他文字说明。"
)
user_prompt = (
f"目标视频描述:{description}\n\n"
f"素材列表:\n" + "\n".join(asset_summaries) + "\n\n请返回每个素材的匹配分数JSON"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.3,
max_tokens=1024,
)
if result:
scores = _parse_semantic_match_response(result, asset_ids)
if scores:
# 把评分填回素材
matched = []
for asset in assets:
aid = str(asset.get("id", ""))
score = scores.get(aid, 0.3) # 没评分的给默认偏低分
matched.append(
{
**asset,
"match_score": round(score, 3),
"match_reason": "doubao_semantic",
}
)
matched.sort(key=lambda x: x["match_score"], reverse=True)
logger.info(
"豆包语义匹配完成: assets=%d top_score=%.2f description=%s...",
len(matched),
matched[0]["match_score"] if matched else 0,
description[:20],
)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "doubao",
"description": description,
"total": len(assets),
}
logger.warning("豆包语义匹配返回解析失败,降级到本地: %s", result[:100])
# 降级
matched = _semantic_match_fallback(description, assets)
if top_k > 0:
matched = matched[:top_k]
return {
"matches": matched,
"source": "fallback",
"description": description,
"total": len(assets),
}
# ── 单例入口 ─────────────────────────────────────────────────────────────────
def get_ai_service() -> "AIService":
"""获取 AI 服务单例."""
global _ai_service
if _ai_service is None:
_ai_service = AIService()
return _ai_service
_ai_service: Optional["AIService"] = None
class AIService:
"""AI 服务统一入口,便于后续扩展更多能力."""
def __init__(self) -> None:
self._client = get_doubao_client()
@property
def is_available(self) -> bool:
return self._client.is_available
def generate_titles(
self,
description: str,
style: str = "viral",
count: int = 5,
) -> Dict[str, Any]:
return generate_smart_titles(description, style, count)
def semantic_match(
self,
description: str,
assets: List[Dict[str, Any]],
top_k: int = 0,
) -> Dict[str, Any]:
return semantic_match_assets(description, assets, top_k)
+1 -2
View File
@@ -12,7 +12,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -156,7 +155,7 @@ class AutoClipService:
self, self,
clip: EditPlanClip, clip: EditPlanClip,
project_id: str, project_id: str,
config_map: Mapping[str, object], config_map: dict[str, object],
) -> ClipAssignDetail: ) -> ClipAssignDetail:
"""为单个片段分配素材。""" """为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
-276
View File
@@ -1,276 +0,0 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力
抽帧使用 FFmpeg上传使用共享存储服务
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID用于生成存储路径
asset_id: 素材 ID
frame_time: 抽帧时间点
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+96 -212
View File
@@ -16,11 +16,6 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanRepository, SQLAlchemyEditPlanRepository,
SQLAlchemyGenerationTaskRepository, SQLAlchemyGenerationTaskRepository,
) )
from packages.domain.clip_operations import calculate_merge as _calc_merge
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
from packages.domain.clip_operations import calculate_split as _calc_split
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
from packages.domain.clip_operations import validate_split_time as _validate_split
from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
@@ -44,6 +39,70 @@ class EditPlanService:
# ── 剪辑计划 CRUD ────────────────────────────────────────────────────── # ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
def list_plans(
self,
*,
template_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
"""列出剪辑计划
Args:
template_id: 按模板 ID 筛选
project_id: 按项目 ID 筛选
status: 按状态筛选
skip: 分页偏移
limit: 每页数量
"""
if project_id:
return self._plan_repo.list_by_project(
project_id,
status=status,
skip=skip,
limit=limit,
)
if template_id:
return self._plan_repo.list_by_template(
template_id,
status=status,
skip=skip,
limit=limit,
)
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
def count_plans(
self,
*,
template_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
) -> int:
"""统计计划数量
Note:
当指定 template_id/project_id 通过全量查询计算 totalrepo 限制
"""
if project_id:
all_matching = self._plan_repo.list_by_project(
project_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
if template_id:
all_matching = self._plan_repo.list_by_template(
template_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
return self._plan_repo.count(status=status)
def get_plan(self, plan_id: str) -> Optional[EditPlan]: def get_plan(self, plan_id: str) -> Optional[EditPlan]:
"""获取计划详情""" """获取计划详情"""
return self._plan_repo.get(plan_id) return self._plan_repo.get(plan_id)
@@ -65,7 +124,7 @@ class EditPlanService:
project_id: str = "", project_id: str = "",
created_by_user_id: str = "", created_by_user_id: str = "",
) -> EditPlan: ) -> EditPlan:
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用) """创建剪辑计划
Raises: Raises:
ValueError: 参数校验失败 ValueError: 参数校验失败
@@ -82,19 +141,6 @@ class EditPlanService:
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
return created return created
def _auto_resume_editing(self, plan_id: str) -> None:
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
plan = self._plan_repo.get(plan_id)
if plan is None:
return
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
try:
plan.resume_editing()
self._plan_repo.update(plan)
logger.info("自动重新编辑: plan_id=%s", plan_id)
except ValueError:
pass
def update_plan( def update_plan(
self, self,
plan_id: str, plan_id: str,
@@ -110,10 +156,6 @@ class EditPlanService:
""" """
existing = self.get_plan_or_raise(plan_id) existing = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
existing = self.get_plan_or_raise(plan_id)
updated = EditPlan( updated = EditPlan(
id=existing.id, id=existing.id,
template_id=existing.template_id, template_id=existing.template_id,
@@ -131,6 +173,23 @@ class EditPlanService:
logger.info("更新剪辑计划: id=%s", plan_id) logger.info("更新剪辑计划: id=%s", plan_id)
return result return result
def delete_plan(self, plan_id: str) -> bool:
"""删除剪辑计划及其所有片段
Returns:
bool: 是否删除成功
"""
existing = self._plan_repo.get(plan_id)
if existing is None:
return False
# 先删除所有片段
self._clip_repo.delete_by_plan(plan_id)
# 再删除计划
self._plan_repo.delete(plan_id)
logger.info("删除剪辑计划: id=%s", plan_id)
return True
# ── 状态机流转 ────────────────────────────────────────────────────────── # ── 状态机流转 ──────────────────────────────────────────────────────────
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan: def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
@@ -153,24 +212,8 @@ class EditPlanService:
return plan return plan
# 根据目标状态调用对应的状态机方法 # 根据目标状态调用对应的状态机方法
# EDITING 支持从 draft / completed / failed 进入
if target_status == EditPlanStatus.EDITING:
if plan.status == EditPlanStatus.DRAFT:
plan.start_editing()
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
plan.resume_editing()
else:
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
result = self._plan_repo.update(plan)
logger.info(
"状态流转: plan_id=%s %s%s",
plan_id,
plan.status,
target_status,
)
return result
transition_map = { transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering, EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed, EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed, EditPlanStatus.FAILED: plan.mark_failed,
@@ -249,8 +292,6 @@ class EditPlanService:
""" """
# 确保计划存在 # 确保计划存在
self.get_plan_or_raise(plan_id) self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
clip = EditPlanClip.create( clip = EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -298,9 +339,6 @@ class EditPlanService:
""" """
existing = self.get_clip_or_raise(clip_id) existing = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(existing.plan_id)
# 速度边界钳制 # 速度边界钳制
if playback_speed is not None: if playback_speed is not None:
if playback_speed <= 0: if playback_speed <= 0:
@@ -343,8 +381,6 @@ class EditPlanService:
ValueError: 片段不存在或 asset_id 为空 ValueError: 片段不存在或 asset_id 为空
""" """
clip = self.get_clip_or_raise(clip_id) clip = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(clip.plan_id)
clip.assign_asset(asset_id) clip.assign_asset(asset_id)
result = self._clip_repo.update(clip) result = self._clip_repo.update(clip)
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id) logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
@@ -371,162 +407,21 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count return count
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
"""将一个片段从指定位置分割为两个片段
Args:
clip_id: 要分割的片段 ID
split_time: 分割点相对于片段起始的秒数必须在 (0, duration) 范围内
Returns:
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
Raises:
ValueError: 片段不存在分割时间越界
"""
clip = self.get_clip_or_raise(clip_id)
plan_id = clip.plan_id
# 纯逻辑:校验 + 计算
_validate_split(split_time, clip.duration)
split = _calc_split(
duration=clip.duration,
split_time=split_time,
start_time=clip.start_time,
)
self._auto_resume_editing(plan_id)
original_order = clip.order
# 更新左半部分(原片段)
clip.duration = split.left_duration
left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id)
shifts = _calc_shift_orders(
all_clips,
threshold_order=original_order,
shift=1,
excluded_ids={clip_id},
id_attr="id",
order_attr="order",
)
for c, new_order in shifts:
c.order = new_order
self._clip_repo.update(c)
# 创建右半部分新片段(继承原片段的大部分属性)
right_config = dict(clip.config) if clip.config else {}
# 素材裁剪信息
if clip.asset_id:
# 右半部分从 split_time 开始播放
right_config["trim_start"] = split.right_trim_start
# 左半部分在 split_time 处结束
left_config = dict(left_clip.config) if left_clip.config else {}
left_config["trim_end"] = split.left_trim_end
left_clip.config = left_config
left_clip = self._clip_repo.update(left_clip)
right_clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip.clip_type,
order=original_order + 1,
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=split.right_start_time,
duration=split.right_duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
config=right_config,
)
created_right = self._clip_repo.create(right_clip)
logger.info(
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
clip_id,
plan_id,
split_time,
split.left_duration,
split.right_duration,
)
return {
"left_clip": left_clip,
"right_clip": created_right,
}
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
"""合并多个连续片段为一个片段
Args:
clip_ids: 要合并的片段 ID 列表至少2个必须属于同一个计划且 order 连续
Returns:
EditPlanClip: 合并后的新片段
Raises:
ValueError: 数量不足不属于同一计划不连续类型不一致
"""
if len(clip_ids) < 2:
raise ValueError("至少需要 2 个片段才能合并")
# 读取所有片段
clips = []
for cid in clip_ids:
clip = self.get_clip_or_raise(cid)
clips.append(clip)
# 纯逻辑:校验 + 计算
plan_id, first_order = _validate_merge(clips)
merge = _calc_merge(clips)
self._auto_resume_editing(plan_id)
# 更新第一个片段(保留它作为合并结果)
first_clip = sorted(clips, key=lambda c: c.order)[0]
first_clip.duration = merge.total_duration
first_clip.text_content = merge.merged_text
first_clip.config = merge.merged_config
# 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
for cid in rest_ids:
self._clip_repo.delete(cid)
# 后面的片段 order 前移 (len - 1) 位
all_clips = self._clip_repo.list_by_plan(plan_id)
shifts = _calc_shift_orders(
all_clips,
threshold_order=first_order,
shift=-merge.shift_amount,
excluded_ids={merged_clip.id},
id_attr="id",
order_attr="order",
)
for c, new_order in shifts:
c.order = new_order
self._clip_repo.update(c)
logger.info(
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id,
len(clips),
merge.total_duration,
)
return merged_clip
# ── 渲染生成流程 ──────────────────────────────────────────────────────── # ── 渲染生成流程 ────────────────────────────────────────────────────────
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
plan = self.get_plan_or_raise(plan_id)
clips = self._clip_repo.list_by_plan(plan_id)
return {
"plan": plan,
"clips": clips,
}
def get_generation_status(self, plan_id: str) -> Dict[str, Any]: def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
"""获取渲染进度状态 """获取渲染进度状态
@@ -536,8 +431,6 @@ class EditPlanService:
"clips": List[EditPlanClip], "clips": List[EditPlanClip],
"generation_task_id": Optional[str], "generation_task_id": Optional[str],
"generation_task_status": Optional[str], "generation_task_status": Optional[str],
"progress": float,
"error_message": str,
} }
Raises: Raises:
@@ -549,23 +442,17 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id # 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id") generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id: if generation_task_id:
task = self._generation_task_repo.get(generation_task_id) task = self._generation_task_repo.get(generation_task_id)
if task: if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
progress = getattr(task, "progress", 0.0) or 0.0
error_message = getattr(task, "error_message", "") or ""
return { return {
"plan": plan, "plan": plan,
"clips": clips, "clips": clips,
"generation_task_id": generation_task_id, "generation_task_id": generation_task_id,
"generation_task_status": generation_task_status, "generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
} }
def can_generate(self, plan_id: str) -> tuple[bool, str]: def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -616,9 +503,6 @@ class EditPlanService:
更新后的计划 更新后的计划
""" """
plan = self.get_plan_or_raise(plan_id) plan = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
plan = self.get_plan_or_raise(plan_id)
new_config = {**plan.config, **config_updates} new_config = {**plan.config, **config_updates}
updated = EditPlan( updated = EditPlan(
+3 -420
View File
@@ -12,8 +12,6 @@ from typing import Any, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import ( from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyEditTemplateRepository, SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository, SQLAlchemyTemplateClipConfigRepository,
) )
@@ -23,13 +21,6 @@ from packages.domain.template_clip_config import (
TemplateClipConfig, TemplateClipConfig,
TransitionEffect, TransitionEffect,
) )
from packages.domain.template_clip_converter import (
clip_configs_to_snapshots,
clips_to_template_clip_configs,
filter_plan_config_to_template,
snapshots_to_template_clip_configs,
validate_template_name,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,14 +37,6 @@ class EditTemplateService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self._template_repo = SQLAlchemyEditTemplateRepository(db) self._template_repo = SQLAlchemyEditTemplateRepository(db)
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
from packages.adapters.sqlalchemy_impl.template_version_repository import (
SQLAlchemyTemplateVersionRepository,
)
self._version_repo = SQLAlchemyTemplateVersionRepository(db)
self._db = db
# ── 模板 CRUD ────────────────────────────────────────────────────────── # ── 模板 CRUD ──────────────────────────────────────────────────────────
@@ -128,7 +111,9 @@ class EditTemplateService:
ValueError: 名称为空或重复 ValueError: 名称为空或重复
""" """
# 名称校验 # 名称校验
clean_name = validate_template_name(name) clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查 # 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000) existing = self._template_repo.list_all(skip=0, limit=1000)
@@ -409,405 +394,3 @@ class EditTemplateService:
"template": template, "template": template,
"clip_configs": clip_configs, "clip_configs": clip_configs,
} }
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
def save_plan_as_template(
self,
plan_id: str,
name: str,
*,
description: str = "",
template_type: str = "custom",
preview_url: str = "",
) -> dict[str, Any]:
"""将剪辑计划保存为模板
将指定剪辑计划的配置和片段结构另存为一个新模板
方便后续基于该模板快速创建新的剪辑计划
转换规则:
- 计划名称 模板名称调用方传入支持自定义
- 计划 config 模板 config整体迁移
- 计划 editing_mode config 中提取默认 one_take
- 每个片段转换为模板片段配置:
- clip_type 直接映射
- order 保持不变
- duration min_duration = max_duration = duration固定时长
- text_content text_template
- transition_effect 直接映射
- playback_speed 等播放参数存入 config
- 不保留 asset_id模板不绑定具体素材
Args:
plan_id: 源剪辑计划 ID
name: 新模板名称
description: 模板描述
template_type: 模板类型默认 custom用户自定义
preview_url: 预览图 URL
Returns:
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
Raises:
ValueError: 计划不存在或名称为空/重复
"""
# 1. 读取源计划
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
# 2. 读取所有片段(按 order 排序)
clips = self._plan_clip_repo.list_by_plan(plan_id)
clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000)
for t in existing:
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
raise ValueError(f"模板名称已存在: {clean_name}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
template_config = filter_plan_config_to_template(plan.config)
template = EditTemplate.create(
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=template_config,
preview_url=preview_url,
)
created_template = self._template_repo.create(template)
logger.info(
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
plan_id,
created_template.id,
clean_name,
len(clips),
)
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
return {
"template": created_template,
"clip_configs": created_configs,
}
# ── 模板草稿(编辑器)相关 ──────────────────────────────────────────────────
def get_template_draft(self, template_id: str) -> Optional[Any]:
"""获取模板的草稿剪辑计划
通过 template_id + config.is_template_draft=True 标记查找
每个模板有且仅有一个草稿计划
Args:
template_id: 模板 ID
Returns:
EditPlan | None: 草稿剪辑计划不存在则返回 None
"""
from packages.domain.edit_plan import EditPlan # noqa: F401
plans = self._plan_repo.list_by_template(template_id, limit=50)
for plan in plans:
config = plan.config or {}
if config.get("is_template_draft") is True:
return plan
return None
def create_template_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""基于模板创建草稿剪辑计划
草稿与普通剪辑计划的区别
- config.is_template_draft = True
- 不绑定具体素材空素材列表
- 用于模板编辑器的编辑上下文
Args:
template_id: 模板 ID
user_id: 创建者用户 ID
project_id: 所属项目 ID可选
Returns:
EditPlan: 创建的草稿剪辑计划
Raises:
ValueError: 模板不存在或草稿已存在
"""
from .plan_generator_service import PlanGeneratorService
# 检查模板是否存在
template = self.get_template_or_raise(template_id)
# 检查草稿是否已存在
existing = self.get_template_draft(template_id)
if existing is not None:
raise ValueError(f"模板草稿已存在: {template_id}")
# 读取模板片段配置
clip_configs = self.list_clip_configs(template_id)
# 基于模板生成计划(空素材)
generator = PlanGeneratorService(self._db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=[],
project_id=project_id,
created_by_user_id=user_id,
name=f"{template.name} - 草稿",
)
plan = result["plan"]
# 标记为模板草稿
plan_config = plan.config or {}
plan_config["is_template_draft"] = True
plan.config = plan_config
plan = self._plan_repo.update(plan)
logger.info(
"创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s",
template_id,
plan.id,
user_id,
)
return plan
def get_or_create_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""获取或创建模板草稿
首次访问模板编辑器时自动创建草稿
Args:
template_id: 模板 ID
user_id: 操作用户 ID
project_id: 所属项目 ID可选
Returns:
EditPlan: 草稿剪辑计划
"""
draft = self.get_template_draft(template_id)
if draft is not None:
return draft
return self.create_template_draft(template_id, user_id, project_id=project_id)
def publish_template_from_draft(
self,
template_id: str,
draft_plan_id: str,
*,
change_note: str = "",
published_by: str = "",
) -> Any:
"""将草稿剪辑计划的内容发布(同步)到模板
将草稿的配置和片段结构同步到模板相当于"保存"编辑结果
使用事务保证一致性失败则回滚
同步规则:
- 草稿 plan.config template.config过滤掉草稿特有字段
- 草稿 clips template_clip_configs先删后插
- 草稿 editing_mode template.editing_mode
- 不更新模板名称描述等元信息由专门的接口处理
Args:
template_id: 模板 ID
draft_plan_id: 草稿剪辑计划 ID
Returns:
EditTemplate: 更新后的模板
Raises:
ValueError: 模板/草稿不存在或草稿不属于该模板
"""
# 1. 校验模板和草稿
template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id)
if draft is None:
raise ValueError(f"草稿计划不存在: {draft_plan_id}")
if draft.template_id != template_id:
raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}")
config = draft.config or {}
if config.get("is_template_draft") is not True:
raise ValueError("指定的计划不是模板草稿")
# 2. 读取草稿片段
draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id)
draft_clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段)
template_config = filter_plan_config_to_template(draft.config)
# 5. 事务更新
try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
from packages.domain.template_version import EditTemplateVersion
old_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=old_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{old_version} 快照(发布前)",
published_by=published_by,
)
self._version_repo.create(old_snapshot)
# 更新模板元信息
template.config = template_config
template.editing_mode = editing_mode
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 批量删除旧的片段配置(外层事务统一提交)
self._clip_config_repo.delete_by_template(template_id, commit=False)
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
self._db.commit()
logger.info(
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
template_id,
draft_plan_id,
len(created_configs),
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
template_id,
draft_plan_id,
exc,
)
raise
# ── 版本历史与回滚 ────────────────────────────────────────────────────
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
"""列出模板的发布版本历史(按版本号倒序)"""
self.get_template_or_raise(template_id) # 校验存在性
return self._version_repo.list_by_template(template_id, limit=limit)
def rollback_to_version(self, template_id: str, version: int) -> Any:
"""回滚模板到指定历史版本
流程
1. 校验目标版本存在
2. 保存当前状态为新版本快照当前版本号
3. 用目标版本的快照覆盖模板 config + clip_configs
4. 版本号 +1回滚本身也是一次发布
Returns:
EditTemplate: 回滚后的模板
Raises:
ValueError: 模板/版本不存在
"""
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
target_version = self._version_repo.get_by_version(template_id, version)
if target_version is None:
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
current_version = template.version or 1
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
from packages.domain.template_version import EditTemplateVersion
current_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=current_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
published_by="rollback",
)
self._version_repo.create(current_snapshot)
# 3. 覆盖模板配置 + editing_mode + name + preview_url
template.config = dict(target_version.config)
template.editing_mode = target_version.editing_mode
if target_version.name:
template.name = target_version.name
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 4. 先删后插 clip_configs(批量删除避免N+1
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
)
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
synchronize_session=False
)
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
self._clip_config_repo.create(config_obj)
self._db.commit()
logger.info(
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
template_id,
current_version,
version,
updated_template.version,
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"模板回滚失败: template_id=%s target_version=%d error=%s",
template_id,
version,
exc,
)
raise
+213 -29
View File
@@ -26,13 +26,7 @@ from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import ( from packages.domain.template_clip_config import ClipType, TemplateClipConfig
create_clips_from_configs,
distribute_assets,
generate_default_clips,
map_clip_types_for_mode,
)
from packages.domain.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -105,11 +99,6 @@ class PlanGeneratorService:
# 3. 生成片段列表 # 3. 生成片段列表
if clip_configs: if clip_configs:
clips = self._create_clips_from_configs(plan.id, clip_configs) clips = self._create_clips_from_configs(plan.id, clip_configs)
# 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等),
# 但 PIP / VOICE_PIP 模式需要特定的 clip_typeoverlay/background/corner_voice/b_roll
# 才能让素材分配和渲染分层正确工作。
# 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。
self._map_clip_types_for_mode(clips, editing_mode)
else: else:
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids)) clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
@@ -157,8 +146,8 @@ class PlanGeneratorService:
plan_config: dict[str, Any] = { plan_config: dict[str, Any] = {
"editing_mode": editing_mode, "editing_mode": editing_mode,
} }
# 继承模板的 cover/title/subtitle/bgm/export/filter 配置 # 继承模板的 cover/title/subtitle/bgm 配置
for key in ("cover", "title", "subtitle", "bgm", "export", "filter"): for key in ("cover", "title", "subtitle", "bgm"):
if key in template_config: if key in template_config:
plan_config[key] = template_config[key] plan_config[key] = template_config[key]
@@ -169,18 +158,42 @@ class PlanGeneratorService:
plan_id: str, plan_id: str,
clip_configs: List[TemplateClipConfig], clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]: ) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化). """从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
委托给 plan_generator_utils.create_clips_from_configs 纯函数 for cfg in sorted_configs:
""" # 计算时长:取 min_duration 和 max_duration 的中间值
return create_clips_from_configs(plan_id, clip_configs) if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None: # clip_type 可能是枚举或字符串
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型. clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数 # transition_effect 可能是枚举或字符串
""" transition = (
map_clip_types_for_mode(clips, editing_mode) cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
)
clips.append(clip)
return clips
def _generate_default_clips( def _generate_default_clips(
self, self,
@@ -188,11 +201,101 @@ class PlanGeneratorService:
editing_mode: str, editing_mode: str,
asset_count: int, asset_count: int,
) -> List[EditPlanClip]: ) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构. """无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
委托给 plan_generator_utils.generate_default_clips 纯函数 - ONE_TAKE: N main clipsN = asset_count至少1个
- PIP: 1 main + (N-1) overlayN = asset_count
- VOICE_OVER: N main clips + 标记需要配音
- VOICE_PIP: 1 background + 1 corner_voice + (N-2) b_roll
""" """
return generate_default_clips(plan_id, editing_mode, asset_count) n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for i in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for i in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
def _distribute_assets( def _distribute_assets(
self, self,
@@ -200,8 +303,89 @@ class PlanGeneratorService:
asset_ids: List[str], asset_ids: List[str],
editing_mode: str, editing_mode: str,
) -> None: ) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化). """按 editing_mode 将素材分配到 clips(就地修改,未持久化)
委托给 plan_generator_utils.distribute_assets 纯函数 分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材main(全屏背景)其余交替分配给 overlay clips
- VOICE_OVER: 素材main clips (B-roll)
- VOICE_PIP: 第1个background, 第2个corner_voice, 其余b_roll
""" """
distribute_assets(clips, asset_ids, editing_mode) if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
@@ -1,164 +0,0 @@
"""SmartAssetSelector — 智能素材选择服务.
根据多维度评分从素材库中自动选择最优视频素材
用于一键生成等需要自动选取素材的场景
评分维度加权求和总分 0-1
- 质量分quality_score权重 0.5 来自人工或AI的质量评分
- 分辨率适配权重 0.2 分辨率越接近 1080p 得分越高
- 时长合理性权重 0.2 3-30 秒区间最佳过短/过长扣分
- 码率质量权重 0.1 用文件大小/时长估算码率适中得分高
特性
- 最低质量分门槛自动过滤低质量素材
- 时长多样性保证选出的素材时长分布均匀//长各占一定比例
- 兼容全部模式素材库模式和项目模式都可用
纯逻辑部分已抽离到 packages.domain.asset_scoring
"""
from __future__ import annotations
import logging
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
from packages.domain.asset_scoring import (
AssetScoreDetail,
SmartSelectResult,
diverse_selection,
filter_candidates,
score_asset_detail,
)
logger = logging.getLogger(__name__)
class SmartAssetSelector:
"""智能素材选择器.
从一组素材中按综合评分选择最优的 N
同时保证时长分布的多样性
"""
def __init__(
self,
min_quality_score: float = 30.0,
target_width: int = 1920,
target_height: int = 1080,
):
self.min_quality_score = min_quality_score
self.target_width = target_width
self.target_height = target_height
# ── 公开方法 ──────────────────────────────────────────────────────────────
def select(
self,
assets: list,
count: int = 0,
*,
ensure_diversity: bool = True,
) -> SmartSelectResult:
"""从素材列表中智能选择最优素材.
Args:
assets: Asset 实体列表需要有 id/quality_score/width/height/duration/file_size 属性
count: 选取数量0 表示全部符合条件的
ensure_diversity: 是否保证时长多样性默认开启
Returns:
SmartSelectResult 选择结果
"""
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
if not candidates:
return SmartSelectResult(
selected_ids=[],
total_candidates=0,
filtered_out=filtered_out,
avg_score=0.0,
details=[],
)
# 2. 对每个候选素材评分
scored: list[AssetScoreDetail] = []
for asset in candidates:
detail = score_asset_detail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
)
scored.append(detail)
# 3. 按总分降序排列
scored.sort(key=lambda d: d.total_score, reverse=True)
# 4. 多样性选择(如果需要且数量有限制)
if ensure_diversity and count > 0 and len(scored) > count:
selected = diverse_selection(scored, count)
else:
# 无数量限制或不要求多样性,直接按排名取
selected = scored if count <= 0 else scored[:count]
avg_score = sum(d.total_score for d in selected) / len(selected) if selected else 0.0
result = SmartSelectResult(
selected_ids=[d.asset_id for d in selected],
total_candidates=len(candidates),
filtered_out=filtered_out,
avg_score=avg_score,
details=selected,
)
logger.info(
"智能素材选择完成: 候选=%d, 过滤=%d, 选中=%d, 平均分=%.3f",
result.total_candidates,
result.filtered_out,
len(result.selected_ids),
result.avg_score,
)
return result
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
def _score_asset(self, asset) -> AssetScoreDetail:
"""对单个素材进行多维度评分(向后兼容)."""
return score_asset_detail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
)
def _score_resolution(self, width: int | None, height: int | None) -> float:
"""分辨率评分(向后兼容)."""
from packages.domain.asset_scoring import score_resolution
return score_resolution(width, height, self.target_width, self.target_height)
def _score_duration(self, duration: float | None) -> float:
"""时长评分(向后兼容)."""
from packages.domain.asset_scoring import score_duration
return score_duration(duration)
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
"""码率评分(向后兼容)."""
from packages.domain.asset_scoring import score_bitrate
return score_bitrate(file_size, duration)
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
"""多样性选择(向后兼容)."""
return diverse_selection(scored, count)
+221 -31
View File
@@ -29,33 +29,47 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
) )
from packages.domain.edit_plan import EditPlanStatus from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.video_filter_builder import ( from packages.domain.template_clip_config import TransitionEffect
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
ClipFilterChain,
build_clip_filter,
)
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
from packages.domain.video_filter_builder import has_audio as _has_audio_func
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── 常量(向后兼容别名) ────────────────────────────────────────────────────── # ── 常量 ──────────────────────────────────────────────────────────────────────
# 实际定义已迁移至 packages/domain/video_filter_builder.py
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
DEFAULT_CODEC = "libx264" DEFAULT_CODEC = "libx264"
DEFAULT_CRF = 23 DEFAULT_CRF = 23
DEFAULT_PRESET = "medium" DEFAULT_PRESET = "medium"
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
_XFADE_TRANSITION_MAP: dict[str, str] = {
TransitionEffect.FADE: "fade",
TransitionEffect.SLIDE_LEFT: "slideleft",
TransitionEffect.SLIDE_RIGHT: "slideright",
TransitionEffect.DISSOLVE: "dissolve",
TransitionEffect.WIPE: "wipeleft",
}
# 转场默认时长(秒)
DEFAULT_TRANSITION_DURATION = 0.5
# ── 数据结构 ────────────────────────────────────────────────────────────────── # ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ClipFilterChain:
"""单个片段的滤镜链描述。"""
clip_id: str
input_index: int
video_label: str
audio_label: str | None
filters: list[str]
duration: float
@dataclass(frozen=True) @dataclass(frozen=True)
class ComposeCommand: class ComposeCommand:
"""完整的 FFmpeg 合成命令描述。""" """完整的 FFmpeg 合成命令描述。"""
@@ -205,7 +219,7 @@ class VideoComposeService:
根据 EditPlan 的所有 ready 片段生成完整的 filter_complex 命令 根据 EditPlan 的所有 ready 片段生成完整的 filter_complex 命令
滤镜链逻辑 滤镜链逻辑
- 每个片段scale crop fps setpts trim atrim - 每个片段scale crop setpts trim atrim
- 多片段之间concat 滤镜 xfade 转场 - 多片段之间concat 滤镜 xfade 转场
- 最终输出-map '[outv]' -map '[outa]'如有音频 - 最终输出-map '[outv]' -map '[outa]'如有音频
""" """
@@ -387,8 +401,52 @@ class VideoComposeService:
output_height: int, output_height: int,
fps: int, fps: int,
) -> ClipFilterChain: ) -> ClipFilterChain:
"""向后兼容:委托给 video_filter_builder.build_clip_filter。""" """为单个片段构建滤镜链。
return build_clip_filter(clip, input_index, output_width, output_height, fps)
滤镜顺序
1. scale 等比缩放到目标分辨率保证覆盖
2. crop 居中裁剪到目标分辨率
3. setpts 重置时间戳 + 偏移
4. trim 视频时长裁剪
5. atrim 音频时长裁剪如有音频流
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
filters: list[str] = []
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
# 2. crop: 居中裁剪
filters.append(f"crop={output_width}:{output_height}")
# 3. setpts: 重置时间戳
if start > 0:
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
else:
filters.append("setpts=PTS-STARTPTS")
# 4. trim: 视频时长
filters.append(f"trim=0:{duration}")
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
video_label = f"v{input_index}"
# 5. 音频标签:仅当片段类型可能有音频时才设置
# title/subtitle 是纯文字/图片卡片,没有音频流
clip_type = clip.clip_type.lower() if clip.clip_type else ""
has_audio_stream = clip_type not in ("title", "subtitle")
audio_label = f"a{input_index}" if has_audio_stream else None
return ClipFilterChain(
clip_id=clip.id,
input_index=input_index,
video_label=video_label,
audio_label=audio_label,
filters=filters,
duration=duration,
)
@staticmethod @staticmethod
def _build_filter_complex( def _build_filter_complex(
@@ -398,30 +456,96 @@ class VideoComposeService:
transition_duration: float, transition_duration: float,
transitions: list[str], transitions: list[str],
) -> tuple[str, float]: ) -> tuple[str, float]:
"""向后兼容:委托给 video_filter_builder.build_filter_complex。""" """构建完整的 filter_complex 字符串
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
策略
- 单片段直接输出
- 多片段 + cut使用 concat 滤镜高效
- 多片段 + 有转场使用 xfade 滤镜链
返回 (filter_complex_string, estimated_total_duration)
"""
n = len(clip_chains)
if n == 0:
return "", 0.0
# ── 单片段 ─────────────────────────────────────────────────────
if n == 1:
chain = clip_chains[0]
filter_str = _chain_filters(chain.filters, chain.video_label)
# 音频
if chain.audio_label:
filter_str += f";[0:a]{chain.audio_label}"
total_duration = chain.duration
return filter_str, total_duration
# ── 检查是否有转场 ─────────────────────────────────────────────
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
if not has_transitions:
return _build_concat_filter(clip_chains)
# ── 有转场:使用 xfade ─────────────────────────────────────────
return _build_xfade_filter(
clip_chains=clip_chains,
transition_duration=transition_duration,
transitions=transitions,
)
@staticmethod @staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool: def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
"""向后兼容:委托给 video_filter_builder.has_audio""" """是否有任何片段包含音频流"""
return _has_audio_func(clip_chains) return any(c.audio_label is not None for c in clip_chains)
# ── 模块级辅助函数(向后兼容别名) ────────────────────────────────────────── # ── 模块级辅助函数 ────────────────────────────────────────────────────────────
# 实际实现已迁移至 packages/domain/video_filter_builder.py
# 保留此处别名以兼容现有测试与调用方
def _chain_filters(filters: list[str], output_label: str) -> str: def _chain_filters(filters: list[str], output_label: str) -> str:
"""向后兼容:委托给 video_filter_builder.chain_filters""" """将滤镜列表串联为 FFmpeg 滤镜字符串"""
return _chain_filters_func(filters, output_label) filter_body = ",".join(filters)
return f"[0:v]{filter_body}[{output_label}]"
def _build_concat_filter( def _build_concat_filter(
clip_chains: list[ClipFilterChain], clip_chains: list[ClipFilterChain],
) -> tuple[str, float]: ) -> tuple[str, float]:
"""向后兼容:委托给 video_filter_builder.build_concat_filter。""" """构建 concat 滤镜(无转场,高效拼接)。
return _build_concat_filter_func(clip_chains)
格式
[0:v]filters[v0]; [1:v]filters[v1]; ...
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# concat 滤镜
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter)
# 音频 concat(如果有)
audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains):
if chain.audio_label:
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
if audio_parts:
parts.extend(audio_parts)
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
audio_count = sum(1 for c in clip_chains if c.audio_label)
if audio_count > 0:
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
return ";".join(parts), total_duration
def _build_xfade_filter( def _build_xfade_filter(
@@ -429,5 +553,71 @@ def _build_xfade_filter(
transition_duration: float, transition_duration: float,
transitions: list[str], transitions: list[str],
) -> tuple[str, float]: ) -> tuple[str, float]:
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。""" """构建 xfade 转场滤镜链。
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
每两个相邻片段之间插入 xfade 转场
offset = 前一个片段的累积时长 - 转场时长
格式2 片段
[0:v]filters[v0]; [1:v]filters[v1];
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
格式3+ 片段
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# xfade 链
if n == 1:
# 单片段不需要 xfade
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
return ";".join(parts), total_duration
# 计算每个转场的 offset
cumulative = 0.0
prev_label = clip_chains[0].video_label
for i in range(1, n):
cumulative += clip_chains[i - 1].duration
offset = max(0.0, cumulative - transition_duration * i)
# 获取转场类型
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
if i == n - 1:
# 最后一个转场,输出到 [outv]
out_label = "outv"
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_chains[i].video_label}]"
f"xfade=transition={xfade_transition}"
f":duration={transition_duration}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
# 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1)
# 音频 crossfade(简化处理:使用 adelay + amix
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
if len(audio_labels) >= 2:
# 简单拼接音频(不做 crossfade)
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
elif len(audio_labels) == 1:
parts.append(f"[{audio_labels[0]}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health():
return {"ok": True, "service": "api"}
Executable → Regular
+6 -4
View File
@@ -8,14 +8,16 @@ module.exports = {
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"prettier", // 关掉与 Prettier 冲突的 ESLint 规则
], ],
ignorePatterns: ["dist", ".eslintrc.cjs"], ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
plugins: ["react-refresh"], plugins: ["react-refresh"],
rules: { rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
}, },
} };
-12
View File
@@ -1,12 +0,0 @@
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-12
View File
@@ -1,12 +0,0 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false
}
+4 -4
View File
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
使用 Zustand 创建 Store 使用 Zustand 创建 Store
```typescript ```typescript
import { create } from "zustand" import { create } from "zustand";
interface MyStore { interface MyStore {
data: any data: any;
setData: (data: any) => void setData: (data: any) => void;
} }
export const useMyStore = create<MyStore>((set) => ({ export const useMyStore = create<MyStore>((set) => ({
data: null, data: null,
setData: (data) => set({ data }), setData: (data) => set({ data }),
})) }));
``` ```
## 部署 ## 部署
+399 -240
View File
@@ -5,29 +5,33 @@
* *
* test_asset.spec.ts API CRUD UI * test_asset.spec.ts API CRUD UI
*/ */
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
const PASSWORD = "SmokePass123!" const PASSWORD = "SmokePass123!";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const apiOrigin = apiBase.endsWith("/api/v1")
? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
function uniqueEmail(prefix: string): string { function uniqueEmail(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
} }
function uniqueUsername(prefix: string): string { function uniqueUsername(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
} }
/** 登录操作,遇到 429 限流自动等待重试 */ /** 登录操作,遇到 429 限流自动等待重试 */
@@ -40,30 +44,30 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label);
const username = uniqueUsername(label) const username = uniqueUsername(label);
const reg = await request.post(`${apiBase}/auth/register`, { const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
}) });
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
const regData = await reg.json() const regData = await reg.json();
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
const loginData = await login.json() const loginData = await login.json();
return { return {
headers: { Authorization: `Bearer ${loginData.access_token}` }, headers: { Authorization: `Bearer ${loginData.access_token}` },
@@ -71,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
username, username,
userId: regData.user_id, userId: regData.user_id,
accessToken: loginData.access_token, accessToken: loginData.access_token,
} };
} }
/** 创建项目 */ /** 创建项目 */
@@ -86,10 +90,10 @@ async function createProject(
name: `Assets Test Proj ${suffix}`, name: `Assets Test Proj ${suffix}`,
description: "E2E assets test", description: "E2E assets test",
}, },
}) });
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
/** 创建素材库 */ /** 创建素材库 */
@@ -103,10 +107,10 @@ async function createLibrary(
const resp = await request.post(`${apiBase}/asset-libraries`, { const resp = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { project_id: projectId, name, kind }, data: { project_id: projectId, name, kind },
}) });
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
/** 创建素材记录 */ /** 创建素材记录 */
@@ -132,10 +136,10 @@ async function createAsset(
uploaded_by_user_id: userId, uploaded_by_user_id: userId,
metadata: { duration: 15.5, resolution: "1080p" }, metadata: { duration: 15.5, resolution: "1080p" },
}, },
}) });
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy() expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
const data = await resp.json() const data = await resp.json();
return data.id return data.id;
} }
/** 在浏览器中设置登录态 */ /** 在浏览器中设置登录态 */
@@ -146,14 +150,14 @@ async function setupAuthInBrowser(
) { ) {
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token, token,
@@ -167,400 +171,551 @@ async function setupAuthInBrowser(
email_verified: true, email_verified: true,
}, },
}, },
) );
} }
test.describe("素材库页面 - 完整交互测试", () => { test.describe("素材库页面 - 完整交互测试", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
// ─── 页面加载 ────────────────────────────────────── // ─── 页面加载 ──────────────────────────────────────
test("素材库列表页面加载", async ({ page, request }) => { test("素材库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-load");
const projectId = await createProject(
request, request,
"assets-load", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
await createLibrary(request, headers, projectId, "默认视频库", "video") await createLibrary(request, headers, projectId, "默认视频库", "video");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
// 页面布局容器 // 页面布局容器
await expect(page.locator(".xx-assets-page")).toBeVisible({ await expect(page.locator(".xx-assets-page")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 左侧素材库列表 // 左侧素材库列表
await expect(page.locator(".xx-asset-library-list")).toBeVisible() await expect(page.locator(".xx-asset-library-list")).toBeVisible();
// 右侧内容区(上传区 + 筛选 + 素材网格) // 右侧内容区(上传区 + 筛选 + 素材网格)
await expect(page.locator(".xx-assets-content")).toBeVisible() await expect(page.locator(".xx-assets-content")).toBeVisible();
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible() await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
await expect(page.locator(".xx-assets-filters")).toBeVisible() await expect(page.locator(".xx-assets-filters")).toBeVisible();
// 无错误提示 // 无错误提示
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, { await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}) });
}) });
// ─── 创建素材库 ──────────────────────────────────── // ─── 创建素材库 ────────────────────────────────────
test("创建新素材库 - 通过 UI", async ({ page, request }) => { test("创建新素材库 - 通过 UI", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-create");
const projectId = await createProject(
request, request,
"assets-create", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
await createLibrary(request, headers, projectId, "初始库", "video") await createLibrary(request, headers, projectId, "初始库", "video");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 点击新建素材库 // 点击新建素材库
await page.locator(".xx-asset-library-add").click() await page.locator(".xx-asset-library-add").click();
// 弹窗出现 // 弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" }) const modal = page
await expect(modal).toBeVisible() .locator(".ant-modal-content")
.filter({ hasText: "新建素材库" });
await expect(modal).toBeVisible();
// 填写表单 // 填写表单
const newLibName = `E2E 新建库 ${Date.now()}` const newLibName = `E2E 新建库 ${Date.now()}`;
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName) await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
// 类型选择默认是 video,保持即可 // 类型选择默认是 video,保持即可
// 监听创建请求 // 监听创建请求
const createPromise = page.waitForResponse( const createPromise = page.waitForResponse(
(resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST", (resp) =>
resp.url().includes("/asset-libraries") &&
resp.request().method() === "POST",
{ timeout: 10_000 }, { timeout: 10_000 },
) );
// 点击创建 // 点击创建
await modal.getByRole("button", { name: "创建" }).click() await modal.getByRole("button", { name: "创建" }).click();
const resp = await createPromise const resp = await createPromise;
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy() expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
// 新素材库应出现在列表中 // 新素材库应出现在列表中
await expect( await expect(
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }), page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
).toBeVisible({ timeout: 10_000 }) ).toBeVisible({ timeout: 10_000 });
}) });
// ─── 切换素材库 ──────────────────────────────────── // ─── 切换素材库 ────────────────────────────────────
test("切换不同素材库", async ({ page, request }) => { test("切换不同素材库", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-switch");
const projectId = await createProject(
request, request,
"assets-switch", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
const videoLibName = "视频素材库 A" const videoLibName = "视频素材库 A";
const imageLibName = "图片素材库 B" const imageLibName = "图片素材库 B";
const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video") const videoLibId = await createLibrary(
request,
headers,
projectId,
videoLibName,
"video",
);
const imageLibId = await createLibrary(
request,
headers,
projectId,
imageLibName,
"image",
);
// 在视频库里创建一个素材 // 在视频库里创建一个素材
await createAsset(request, headers, projectId, videoLibId, userId, "demo_video.mp4") await createAsset(
request,
headers,
projectId,
videoLibId,
userId,
"demo_video.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 点击视频库,应显示素材 // 点击视频库,应显示素材
const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName }) const videoLibItem = page
await videoLibItem.click({ force: true }) .locator(".xx-asset-library-item")
await expect(videoLibItem).toHaveClass(/active/) .filter({ hasText: videoLibName });
await videoLibItem.click({ force: true });
await expect(videoLibItem).toHaveClass(/active/);
// 验证视频素材出现 // 验证视频素材出现
await expect(page.getByText("demo_video.mp4")).toBeVisible({ await expect(page.getByText("demo_video.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
// 点击图片库,应切换且不显示视频 // 点击图片库,应切换且不显示视频
const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName }) const imageLibItem = page
await imageLibItem.click({ force: true }) .locator(".xx-asset-library-item")
await expect(imageLibItem).toHaveClass(/active/) .filter({ hasText: imageLibName });
await imageLibItem.click({ force: true });
await expect(imageLibItem).toHaveClass(/active/);
// 空状态或图片库内容 // 空状态或图片库内容
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
timeout: 5_000, timeout: 5_000,
}) });
}) });
// ─── 素材搜索 ────────────────────────────────────── // ─── 素材搜索 ──────────────────────────────────────
test("素材搜索功能", async ({ page, request }) => { test("素材搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-search");
const projectId = await createProject(
request, request,
"assets-search", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video") const libraryId = await createLibrary(
request,
headers,
projectId,
"搜索测试库",
"video",
);
// 创建两个不同名称的素材 // 创建两个不同名称的素材
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4") await createAsset(
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4") request,
headers,
projectId,
libraryId,
userId,
"apple_clip.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"banana_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// 确保在测试库中 // 确保在测试库中
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "搜索测试库" });
await libItem.click({ force: true });
// 两个素材都应可见 // 两个素材都应可见
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
await expect(page.getByText("banana_clip.mp4")).toBeVisible() await expect(page.getByText("banana_clip.mp4")).toBeVisible();
// 搜索 apple,只显示 apple // 搜索 apple,只显示 apple
await page.getByPlaceholder("搜索素材名称...").fill("apple") await page.getByPlaceholder("搜索素材名称...").fill("apple");
await expect(page.getByText("apple_clip.mp4")).toBeVisible() await expect(page.getByText("apple_clip.mp4")).toBeVisible();
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0) await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
// 清空搜索,两个都显示 // 清空搜索,两个都显示
await page.getByPlaceholder("搜索素材名称...").fill("") await page.getByPlaceholder("搜索素材名称...").fill("");
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ await expect(page.getByText("apple_clip.mp4")).toBeVisible({
timeout: 5_000, timeout: 5_000,
}) });
await expect(page.getByText("banana_clip.mp4")).toBeVisible() await expect(page.getByText("banana_clip.mp4")).toBeVisible();
}) });
// ─── 筛选类型 ────────────────────────────────────── // ─── 筛选类型 ──────────────────────────────────────
test("素材类型筛选", async ({ page, request }) => { test("素材类型筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-filter");
const projectId = await createProject(
request, request,
"assets-filter", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video") const libraryId = await createLibrary(
request,
headers,
projectId,
"筛选测试库",
"video",
);
// 创建视频素材 // 创建视频素材
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4") await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"video_clip.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "筛选测试库" });
await libItem.click({ force: true });
// 素材应可见 // 素材应可见
await expect(page.getByText("video_clip.mp4")).toBeVisible({ await expect(page.getByText("video_clip.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
// 筛选类型下拉存在 // 筛选类型下拉存在
const filterSelect = page.locator(".xx-assets-filters-left select").first() const filterSelect = page.locator(".xx-assets-filters-left select").first();
await expect(filterSelect).toBeVisible() await expect(filterSelect).toBeVisible();
}) });
// ─── 素材详情/播放 ──────────────────────────────── // ─── 素材详情/播放 ────────────────────────────────
test("素材详情查看 - 播放弹窗", async ({ page, request }) => { test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-detail");
const projectId = await createProject(
request, request,
"assets-detail", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video") const libraryId = await createLibrary(
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4") request,
headers,
projectId,
"详情测试库",
"video",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"play_test.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "详情测试库" });
await libItem.click({ force: true });
// 找到素材卡片并点击播放按钮 // 找到素材卡片并点击播放按钮
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" }) const assetCard = page
await expect(assetCard).toBeVisible({ timeout: 10_000 }) .locator(".xx-asset-card")
.filter({ hasText: "play_test.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 点击播放按钮 // 点击播放按钮
await assetCard.locator(".xx-asset-play").click({ force: true }) await assetCard.locator(".xx-asset-play").click({ force: true });
// 播放弹窗出现 // 播放弹窗出现
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" }) const modal = page
await expect(modal).toBeVisible() .locator(".ant-modal-content")
.filter({ hasText: "播放" });
await expect(modal).toBeVisible();
// 关闭弹窗 // 关闭弹窗
await modal.locator(".ant-modal-close").click() await modal.locator(".ant-modal-close").click();
await expect(modal).not.toBeVisible({ timeout: 5_000 }) await expect(modal).not.toBeVisible({ timeout: 5_000 });
}) });
// ─── 删除素材 ────────────────────────────────────── // ─── 删除素材 ──────────────────────────────────────
test("删除素材 - 带确认对话框", async ({ page, request }) => { test("删除素材 - 带确认对话框", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-delete");
const projectId = await createProject(
request, request,
"assets-delete", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video") const libraryId = await createLibrary(
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4") request,
headers,
projectId,
"删除测试库",
"video",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"to_delete.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "删除测试库" });
await libItem.click({ force: true });
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" }) const assetCard = page
await expect(assetCard).toBeVisible({ timeout: 10_000 }) .locator(".xx-asset-card")
.filter({ hasText: "to_delete.mp4" });
await expect(assetCard).toBeVisible({ timeout: 10_000 });
// 悬停显示删除按钮 // 悬停显示删除按钮
await assetCard.hover() await assetCard.hover();
// 点击删除 // 点击删除
const deleteBtn = assetCard.locator(".xx-asset-delete") const deleteBtn = assetCard.locator(".xx-asset-delete");
await expect(deleteBtn).toBeVisible() await expect(deleteBtn).toBeVisible();
await deleteBtn.click({ force: true }) await deleteBtn.click({ force: true });
// 确认对话框出现 // 确认对话框出现
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" }) const confirmModal = page
await expect(confirmModal).toBeVisible() .locator(".ant-popover")
.filter({ hasText: "确认删除" });
await expect(confirmModal).toBeVisible();
// 监听删除请求 // 监听删除请求
const deletePromise = page.waitForResponse( const deletePromise = page.waitForResponse(
(resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE", (resp) =>
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
{ timeout: 10_000 }, { timeout: 10_000 },
) );
// 点击确认删除 // 点击确认删除
await confirmModal.getByRole("button", { name: "删除" }).click() await confirmModal.getByRole("button", { name: "删除" }).click();
const resp = await deletePromise const resp = await deletePromise;
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy() expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
// 素材应从列表中消失 // 素材应从列表中消失
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, { await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
timeout: 10_000, timeout: 10_000,
}) });
}) });
// ─── 批量删除素材 ────────────────────────────────── // ─── 批量删除素材 ──────────────────────────────────
test("批量删除素材", async ({ page, request }) => { test("批量删除素材", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-batch");
const projectId = await createProject(
request, request,
"assets-batch", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video") const libraryId = await createLibrary(
request,
headers,
projectId,
"批量删除库",
"video",
);
// 创建多个素材 // 创建多个素材
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4") await createAsset(
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4") request,
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4") headers,
projectId,
libraryId,
userId,
"batch_1.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_2.mp4",
);
await createAsset(
request,
headers,
projectId,
libraryId,
userId,
"batch_3.mp4",
);
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "批量删除库" });
await libItem.click({ force: true });
// 所有素材应可见 // 所有素材应可见
await expect(page.getByText("batch_1.mp4")).toBeVisible({ await expect(page.getByText("batch_1.mp4")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
await expect(page.getByText("batch_2.mp4")).toBeVisible() await expect(page.getByText("batch_2.mp4")).toBeVisible();
await expect(page.getByText("batch_3.mp4")).toBeVisible() await expect(page.getByText("batch_3.mp4")).toBeVisible();
// 点击全选 // 点击全选
const selectAllBtn = page.getByRole("button", { name: "全选" }) const selectAllBtn = page.getByRole("button", { name: "全选" });
await expect(selectAllBtn).toBeVisible() await expect(selectAllBtn).toBeVisible();
await selectAllBtn.click() await selectAllBtn.click();
// 批量操作栏出现 // 批量操作栏出现
const batchBar = page.locator(".xx-assets-batch-bar") const batchBar = page.locator(".xx-assets-batch-bar");
await expect(batchBar).toBeVisible() await expect(batchBar).toBeVisible();
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible() await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
// 点击批量删除 // 点击批量删除
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" }) const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
await expect(batchDeleteBtn).toBeVisible() await expect(batchDeleteBtn).toBeVisible();
await batchDeleteBtn.click() await batchDeleteBtn.click();
// 确认对话框 // 确认对话框
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" }) const confirmPop = page
await expect(confirmPop).toBeVisible() .locator(".ant-popover")
.filter({ hasText: "确定删除" });
await expect(confirmPop).toBeVisible();
// 确认删除 // 确认删除
await confirmPop.getByRole("button", { name: "删除" }).click() await confirmPop.getByRole("button", { name: "删除" }).click();
// 验证素材已删除(通过 API 确认) // 验证素材已删除(通过 API 确认)
await expect await expect
@@ -569,53 +724,57 @@ test.describe("素材库页面 - 完整交互测试", () => {
const resp = await request.get(`${apiBase}/assets`, { const resp = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryId }, params: { library_id: libraryId },
}) });
if (!resp.ok()) return "error" if (!resp.ok()) return "error";
const data = await resp.json() const data = await resp.json();
const items = data.items || [] const items = data.items || [];
return items.length return items.length;
}, },
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
) )
.toBe(0) .toBe(0);
}) });
// ─── 空状态 ──────────────────────────────────────── // ─── 空状态 ────────────────────────────────────────
test("空素材库展示空状态", async ({ page, request }) => { test("空素材库展示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const { headers, userId, accessToken, email, username } = await createAuthedUser( const { headers, userId, accessToken, email, username } =
await createAuthedUser(request, "assets-empty");
const projectId = await createProject(
request, request,
"assets-empty", headers,
) Date.now().toString(),
const projectId = await createProject(request, headers, Date.now().toString()) );
await createLibrary(request, headers, projectId, "空素材库", "video") await createLibrary(request, headers, projectId, "空素材库", "video");
await setupAuthInBrowser(page, accessToken, { await setupAuthInBrowser(page, accessToken, {
id: userId, id: userId,
email, email,
username, username,
}) });
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page.locator(".xx-assets-layout")).toBeVisible({ await expect(page.locator(".xx-assets-layout")).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" }) const libItem = page
await libItem.click({ force: true }) .locator(".xx-asset-library-item")
.filter({ hasText: "空素材库" });
await libItem.click({ force: true });
// 空状态应显示 // 空状态应显示
await expect(page.locator(".xx-assets-empty")).toBeVisible({ await expect(page.locator(".xx-assets-empty")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible() await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
}) });
// ─── 未登录访问 ──────────────────────────────────── // ─── 未登录访问 ────────────────────────────────────
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => { test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/assets") await page.goto("/app/assets");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
+5 -5
View File
@@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test";
test.describe("App route guard", () => { test.describe("App route guard", () => {
test("redirects anonymous users to login", async ({ page }) => { test("redirects anonymous users to login", async ({ page }) => {
await page.goto("/app/dashboard") await page.goto("/app/dashboard");
await expect(page).toHaveURL(/\/login/) await expect(page).toHaveURL(/\/login/);
}) });
}) });
+7 -7
View File
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test";
test.describe("Authentication page", () => { test.describe("Authentication page", () => {
test("renders login form", async ({ page }) => { test("renders login form", async ({ page }) => {
await page.goto("/login") await page.goto("/login");
await expect(page.getByLabel("邮箱")).toBeVisible() await expect(page.getByLabel("邮箱")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible() await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible() await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
}) });
}) });
+134 -147
View File
@@ -1,23 +1,23 @@
import { expect, test, type APIRequestContext } from "@playwright/test" import { expect, test, type APIRequestContext } from "@playwright/test";
import * as fs from "node:fs"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PASSWORD = "SmokePass123!";
const PASSWORD = "SmokePass123!" const apiBase = process.env.E2E_API_BASE || "/api/v1";
const apiBase = process.env.E2E_API_BASE || "/api/v1" const apiOrigin = apiBase.endsWith("/api/v1")
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" ? apiBase.slice(0, -"/api/v1".length)
: "";
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { const routeBrowserApiToTestApi = async (
if (!apiOrigin) return page: import("@playwright/test").Page,
) => {
if (!apiOrigin) return;
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const sourceUrl = new URL(route.request().url()) const sourceUrl = new URL(route.request().url());
const response = await route.fetch({ const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
}) });
await route.fulfill({ response }) await route.fulfill({ response });
}) });
} };
async function loginWithRetry( async function loginWithRetry(
request: APIRequestContext, request: APIRequestContext,
@@ -28,72 +28,73 @@ async function loginWithRetry(
for (let i = 0; i <= maxRetries; i++) { for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/login`, { const response = await request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
if (response.status() !== 429) return response if (response.status() !== 429) return response;
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
await new Promise((r) => setTimeout(r, 65000)) await new Promise((r) => setTimeout(r, 65000));
} }
return request.post(`${apiBase}/auth/login`, { return request.post(`${apiBase}/auth/login`, {
data: { email, password }, data: { email, password },
}) });
} }
type ProjectResponse = { id: string } type ProjectResponse = { id: string };
type LibraryResponse = { id: string } type LibraryResponse = { id: string };
type TemplateResponse = { id: string } type TemplateResponse = { id: string };
type AssetListResponse = { type AssetListResponse = {
items: Array<{ items: Array<{
id: string id: string;
name: string name: string;
status: string status: string;
}> }>;
} };
test.describe("Core generation flow", () => { test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 180_000 }) test.describe.configure({ timeout: 180_000 });
test("walks through 7-step wizard and starts generation", async ({ page, request }) => { test("walks through 5-step wizard and starts generation", async ({
test.setTimeout(180_000) page,
request,
}) => {
test.setTimeout(180_000);
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page);
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const email = `e2e-gen-${suffix}@example.com` const email = `e2e-gen-${suffix}@example.com`;
const username = `e2e_gen_${suffix}` const username = `e2e_gen_${suffix}`;
const libraryName = `E2E Gen Lib ${suffix}` const libraryName = `E2E Gen Lib ${suffix}`;
// Register // Register
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}) });
expect(register.status()).toBe(201) expect(register.status()).toBe(201);
const registerData = (await register.json()) as { user_id: string } const registerData = (await register.json()) as { user_id: string };
// Login // Login
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status()).toBe(200) expect(login.status()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
// Create project // Create project
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `E2E Gen Proj ${suffix}` }, data: { name: `E2E Gen Proj ${suffix}` },
}) });
expect(project.status()).toBe(200) expect(project.status()).toBe(200);
const projectData = (await project.json()) as ProjectResponse const projectData = (await project.json()) as ProjectResponse;
// Create asset library // Create asset library
const library = await request.post(`${apiBase}/asset-libraries`, { const library = await request.post(`${apiBase}/asset-libraries`, {
headers, headers,
data: { project_id: projectData.id, name: libraryName, kind: "video" }, data: { project_id: projectData.id, name: libraryName, kind: "video" },
}) });
expect(library.status()).toBe(200) expect(library.status()).toBe(200);
const libraryData = (await library.json()) as LibraryResponse const libraryData = (await library.json()) as LibraryResponse;
// Upload source video // Upload source video
const sourceFileName = "e2e-gen-source.mp4" const sourceFileName = "e2e-gen-source.mp4";
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
const upload = await request.post(`${apiBase}/upload`, { const upload = await request.post(`${apiBase}/upload`, {
headers, headers,
multipart: { multipart: {
@@ -102,11 +103,11 @@ test.describe("Core generation flow", () => {
file: { file: {
name: sourceFileName, name: sourceFileName,
mimeType: "video/mp4", mimeType: "video/mp4",
buffer: sampleVideoBuffer, buffer: Buffer.from("e2e source data"),
}, },
}, },
}) });
expect(upload.status()).toBe(200) expect(upload.status()).toBe(200);
// Wait for asset to be ready // Wait for asset to be ready
await expect await expect
@@ -115,16 +116,16 @@ test.describe("Core generation flow", () => {
const assets = await request.get(`${apiBase}/assets`, { const assets = await request.get(`${apiBase}/assets`, {
headers, headers,
params: { library_id: libraryData.id }, params: { library_id: libraryData.id },
}) });
if (!assets.ok()) return `http_${assets.status()}` if (!assets.ok()) return `http_${assets.status()}`;
const data = (await assets.json()) as AssetListResponse const data = (await assets.json()) as AssetListResponse;
const asset = data.items.find((a) => a.name === sourceFileName) const asset = data.items.find((a) => a.name === sourceFileName);
if (!asset) return "missing" if (!asset) return "missing";
return asset.status return asset.status;
}, },
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
) )
.toBe("ready") .toBe("ready");
// Create an editing template so the generate page has at least one template // Create an editing template so the generate page has at least one template
// (templates are now loaded from API; new users have none by default) // (templates are now loaded from API; new users have none by default)
@@ -144,22 +145,22 @@ test.describe("Core generation flow", () => {
], ],
tags: ["e2e"], tags: ["e2e"],
}, },
}) });
expect(template.status(), await template.text()).toBe(201) expect(template.status(), await template.text()).toBe(201);
const templateData = (await template.json()) as TemplateResponse const templateData = (await template.json()) as TemplateResponse;
expect(templateData.id).toBeTruthy() expect(templateData.id).toBeTruthy();
// Set auth in localStorage // Set auth in localStorage
await page.addInitScript( await page.addInitScript(
({ token, user }) => { ({ token, user }) => {
localStorage.setItem("access_token", token) localStorage.setItem("access_token", token);
localStorage.setItem( localStorage.setItem(
"auth-storage", "auth-storage",
JSON.stringify({ JSON.stringify({
state: { user, isAuthenticated: true }, state: { user, isAuthenticated: true },
version: 0, version: 0,
}), }),
) );
}, },
{ {
token: loginData.access_token, token: loginData.access_token,
@@ -173,127 +174,113 @@ test.describe("Core generation flow", () => {
email_verified: true, email_verified: true,
}, },
}, },
) );
// Navigate to generate page // Navigate to generate page
await page.goto("/app/generate") await page.goto("/app/generate");
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
timeout: 20_000, timeout: 20_000,
}) });
// Step 1: template - default selected, click next // Step 1: template - default selected, click next
await expect(page.locator(".xx-choice-item.selected")).toBeVisible() await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click();
// Step 2: select material // Step 2: select material
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
const librarySelect = page.locator("select").first() const librarySelect = page.locator("select").first();
await librarySelect.selectOption({ label: libraryName }) await librarySelect.selectOption({ label: libraryName });
const materialLabel = page.getByText(sourceFileName).locator("..") const materialLabel = page.getByText(sourceFileName).locator("..");
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({ await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) });
await materialLabel.locator("input[type='checkbox']").check() await materialLabel.locator("input[type='checkbox']").check();
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click();
// Step 3: preview (纯展示页,AI 智能匹配预览) // Step 3: title
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
await page.getByRole("button", { name: "下一步" }).click() const titleText = `E2E Test ${suffix}`;
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
await page.getByRole("button", { name: "下一步" }).click();
// Step 4: title // Step 4: voice
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
const titleText = `E2E Test ${suffix}` const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
await page.getByPlaceholder("输入自定义标题…").fill(titleText) await firstVoiceCard.click();
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click();
// Step 5: voice // Step 5: confirm and generate
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible() await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
await firstVoiceCard.click()
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步) // Wait for plan creation API to be called
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible() const createPlanPromise = page.waitForResponse(
await page.getByRole("button", { name: "下一步" }).click() (response) =>
response.url().includes("/edit-plans") &&
// Step 7: confirm and generate response.request().method() === "POST" &&
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible() !response.url().includes("/generate"),
// Wait for generation API to be called
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
// 等 generate 接口返回,确认生成流程启动
const generatePromise = page.waitForResponse(
(response) => {
const url = response.url()
const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/editor/generate")
},
{ timeout: 30_000 }, { timeout: 30_000 },
) );
// Click generate button // Click generate button
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click() await page
.locator(".xx-btn-primary")
.filter({ hasText: "确认生成" })
.first()
.click();
// Verify generation was triggered successfully // Verify plan was created successfully
const genResp = await generatePromise const planResp = await createPlanPromise;
if (!genResp.ok()) { expect(planResp.ok()).toBeTruthy();
const body = await genResp.text() const planData = (await planResp.json()) as { id: string };
console.error( expect(planData.id).toBeTruthy();
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
)
}
expect(genResp.ok()).toBeTruthy()
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
expect(genData.plan_id).toBeTruthy()
expect(genData.generation_task_id).toBeTruthy()
// Generation may fail in test env (no worker), that's OK // Generation may fail in test env (no worker), that's OK
// Just verify the flow started - check page shows generation-related UI // Just verify the flow started - check page shows generation-related UI
await page await page
.getByText(/生成中|生成完成|生成失败/) .getByText(/生成中|生成完成|生成失败/)
.isVisible({ timeout: 15_000 }) .isVisible({ timeout: 15_000 })
.catch(() => false) .catch(() => false);
// If we see progress or result, great; if not, flow still reached the end // If we see progress or result, great; if not, flow still reached the end
// which is sufficient for an E2E smoke test // which is sufficient for an E2E smoke test
// Verify product library page loads (smoke: just verify page renders) // Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products") await page.goto("/app/products");
await expect(page).toHaveURL(/\/app\/products/) await expect(page).toHaveURL(/\/app\/products/);
// Verify page container exists = page rendered correctly // Verify page container exists = page rendered correctly
// (works in all states: loading/error/success - more reliable than checking search input) // (works in all states: loading/error/success - more reliable than checking search input)
await expect(page.locator(".xx-products-page")).toBeVisible({ await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 15_000, timeout: 15_000,
}) });
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错 // 清理所有路由,避免页面关闭时飞地API请求导致测试报错
await page.unrouteAll({ behavior: "ignoreErrors" }) await page.unrouteAll({ behavior: "ignoreErrors" });
}) });
test("generation task API creates and lists tasks", async ({ request }) => { test("generation task API creates and lists tasks", async ({ request }) => {
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36);
const email = `e2e-gen-api-${suffix}@example.com` const email = `e2e-gen-api-${suffix}@example.com`;
const username = `e2e_gen_api_${suffix}` const username = `e2e_gen_api_${suffix}`;
const register = await request.post(`${apiBase}/auth/register`, { const register = await request.post(`${apiBase}/auth/register`, {
data: { email, username, password: PASSWORD, display_name: username }, data: { email, username, password: PASSWORD, display_name: username },
}) });
expect(register.status()).toBe(201) expect(register.status()).toBe(201);
const login = await loginWithRetry(request, email, PASSWORD) const login = await loginWithRetry(request, email, PASSWORD);
expect(login.status()).toBe(200) expect(login.status()).toBe(200);
const loginData = (await login.json()) as { access_token: string } const loginData = (await login.json()) as { access_token: string };
const headers = { Authorization: `Bearer ${loginData.access_token}` } const headers = { Authorization: `Bearer ${loginData.access_token}` };
const project = await request.post(`${apiBase}/projects`, { const project = await request.post(`${apiBase}/projects`, {
headers, headers,
data: { name: `E2E API Proj ${suffix}` }, data: { name: `E2E API Proj ${suffix}` },
}) });
expect(project.status()).toBe(200) expect(project.status()).toBe(200);
// List generation tasks via task center API // List generation tasks via task center API
const tasks = await request.get(`${apiBase}/tasks`, { headers }) const tasks = await request.get(`${apiBase}/tasks`, { headers });
expect(tasks.status()).toBe(200) expect(tasks.status()).toBe(200);
const tasksData = await tasks.json() const tasksData = await tasks.json();
expect(Array.isArray(tasksData.items)).toBe(true) expect(Array.isArray(tasksData.items)).toBe(true);
}) });
}) });

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