Compare commits

..

1 Commits

Author SHA1 Message Date
CI Bot 077f7710df test: cleanup workflow验证用临时PR
Preview Cleanup / Cleanup Preview Environment (pull_request) Failing after 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 54s
AI Code Review / AI Code Review (pull_request) Successful in 2m34s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m10s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Failing after 3m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m43s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 34m5s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1441h10m5s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1441h10m7s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1441h10m8s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1441h10m8s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1441h10m9s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1441h10m11s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1441h10m11s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1441h10m11s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1441h42m7s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1441h42m9s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1441h42m10s
2026-07-18 14:19:15 +08:00
1780 changed files with 73252 additions and 313921 deletions
-1
View File
@@ -1 +0,0 @@
CI re-trigger after runner add-host/DNS fix. This file is harmless and not referenced.
+1 -2
View File
@@ -1,2 +1 @@
CI trigger file - safe to delete
retrigger at 2026-09-15 20:31:24 UTC
trigger: 1784009947
+27 -199
View File
@@ -1,232 +1,60 @@
# ============================================================
# 小虾 SaaS 环境变量完整配置
# ============================================================
# 本文件列出所有可配置的环境变量及默认值。
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
#
# 配置读取规则(pydantic-settings,大小写不敏感):
# 1. 系统环境变量(最高优先级)
# 2. .env.{APP_ENV} 文件(如 .env.staging
# 3. .env 文件
# 4. 代码中的默认值(最低优先级)
# ============================================================
# 小虾 SaaS 环境变量配置
# ==================== 应用基本配置 ====================
# 应用名称
APP_NAME=xiaoxia-saas
# 应用版本号(展示用,代码中已内置默认)
APP_VERSION=0.1.61
# 环境标识:development / staging / production
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
# ==================== 应用配置 ====================
APP_NAME=小虾 SaaS
APP_BASE_URL=http://localhost:3000
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
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas
# 连接池大小(常驻连接数)
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=true
# 生产环境:使用 PostgreSQL
# USE_IN_MEMORY_DB=false
# ==================== Redis 配置 ====================
# Redis 连接 URL(格式:redis://[:password@]host:port/db
REDIS_URL=redis://localhost:6379/0
# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储)
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 配置 ====================
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
# JWT 签名算法
JWT_ALGORITHM=HS256
# Access Token 过期时间(分钟)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
# Refresh Token 过期时间(天)
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ==================== 邮件配置 ====================
# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用)
ENABLE_EMAIL_DELIVERY=false
# SMTP 服务器地址
SMTP_HOST=smtp.gmail.com
# SMTP 端口
SMTP_PORT=587
# SMTP 用户名
SMTP_USER=your-email@gmail.com
# SMTP 密码 / 应用专用密码
SMTP_PASSWORD=your-app-specific-password
# 发件人邮箱
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
# 发件人显示名称
SMTP_FROM_NAME=小虾 SaaS
# 是否启用 TLS
SMTP_USE_TLS=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
# ==================== 环境配置 ====================
ENVIRONMENT=development
DEBUG=true
# ==================== CORS 配置 ====================
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
# 允许跨域的前端域名列表,逗号分隔
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000
# ==================== 渲染引擎配置 ====================
# 渲染引擎选择:
# 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
# ==================== 阿里云 OSS 配置 ====================
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
# ==================== 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_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
COSYVOICE_VOICE=longxiaochun_v3
COSYVOICE_SAMPLE_RATE=22050
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
# ==================== 积分/会员系统 (#1895) ====================
# 积分扣点总开关:默认 false(对现有用户零影响)。
# P2 阶段各业务路由逐个接入 @points_gate 时,用
# `if settings.points_enabled: ...`
# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。
POINTS_ENABLED=false
# ==================== 抖音解析多源轮询 (#1963) ====================
# 无需配置 Key 也可使用(P0 免费源可用),配置 Key 可增加兜底能力
# TikHub API Key (https://tikhub.io) — $0.001/次起,注册送$0.05
TIKHUB_API_KEY=
# apizero.cn API Key (https://v1.apizero.cn) — 国内抖音解析服务
APIZERO_API_KEY=
# ==================== GPU MuseTalk Worker(反向轮询口型同步)====================
# GPU Worker 长期鉴权 TokenWorker 端 .env 的 GPU_WORKER_TOKEN 必须与此一致
# 留空时 development 环境允许匿名访问(仅本地调试),staging/production 必须配置
GPU_WORKER_TOKEN=
# 单任务超时(秒),processing 超过此时长无任务心跳才回退 pending 或标记 failed
# #1970RTX2060 6G 推理 720p 长视频需 5 分钟以上,默认 900
GPU_TASK_TIMEOUT_SECONDS=900
# 是否启用 GPU 口型同步(开关)。开启后需同时有 Worker 在心跳窗口内(5分钟)才会走 GPU 路径;
# 开关关闭 / 无可用 Worker / GPU 任务失败或超时 → 自动回退现有 MediaKit 云端 lipsync
USE_GPU_LIPSYNC=false
# 业务侧轮询 GPU 任务结果的间隔(秒)
GPU_LIPSYNC_POLL_INTERVAL=5
# 业务侧等待 GPU 任务总超时(秒);超时回退 MediaKit
GPU_LIPSYNC_WAIT_TIMEOUT=1200
# Worker 心跳新鲜度窗口(秒),last_heartbeat_at 在此窗口内视为在线
GPU_WORKER_STALE_SECONDS=300
-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
-84
View File
@@ -1,84 +0,0 @@
name: API Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements.txt'
- 'infra/docker/api-base.Dockerfile'
workflow_dispatch:
jobs:
build-api-base:
name: Build API Base Image
runs-on: runtime-builder
timeout-minutes: 45
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: Build and push API base image
shell: sh
run: |
set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest"
echo "=== Building API base image ==="
# 使用普通 docker build(单平台不需要 buildx
docker build \
-f infra/docker/api-base.Dockerfile \
-t "${ACR_IMAGE}" \
.
echo ""
echo "✅ Image built successfully"
# 推送到 ACR
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
if: always()
shell: sh
run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
echo "Cleanup done"
+174
View File
@@ -0,0 +1,174 @@
name: Auto Approve CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review]
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: 20
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- 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: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
echo "等待30秒让CI启动..."
sleep 30
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
ANY_PENDING=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING reviewGitea API需要先创建再提交)
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
# 还有CI在跑(pending状态)→ 继续等
if [ "$ANY_PENDING" = "true" ]; then
echo "⏳ CI仍在运行中,继续等待(第${attempt}/120次轮询)..."
sleep 10
continue
fi
# 所有CI都跑完了但有失败 → 退出
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
# 其他情况继续等
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
+170
View File
@@ -0,0 +1,170 @@
name: Auto Merge CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
jobs:
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: 30
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- 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: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合develop分支
if [ "$BASE_REF" != "develop" ]; then
echo "Skip: 目标分支不是develop"
exit 0
fi
# 判断是否纯前端改动
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
echo "纯前端改动,只检查Frontend Lint"
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / Integration Tests (pull_request)"
)
echo "检查全部四门禁"
fi
echo
# 初始等待30秒,给CI启动写status的时间,避免checkout太快导致全找不到context误判
echo "等待30秒让CI启动..."
sleep 30
# 405连续计数器:连续多次合并返回405才放弃
MERGE_405_COUNT=0
MAX_405_RETRIES=6
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
ANY_PENDING=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
if [ "$STATE" = "pending" ]; then
ANY_PENDING=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行squash merge
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
cat /tmp/merge_resp.json 2>/dev/null || true
echo
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
echo "❌ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge failed after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
else
# 本轮不满足合并条件,重置405计数器
MERGE_405_COUNT=0
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
-105
View File
@@ -1,105 +0,0 @@
name: CI Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements-dev.txt'
- 'infra/docker/ci.Dockerfile'
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - ci-base 镜像重建"
concurrency:
group: ci-base-image-build
cancel-in-progress: false
jobs:
build-ci-base:
name: Build CI Base Image
runs-on: runtime-builder
timeout-minutes: 60
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 Gitea Registry
shell: sh
env:
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 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: Build and push CI base image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
docker build --progress=plain \
-f infra/docker/ci.Dockerfile \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${VERSION_TAG}" \
.
echo "✅ Image built successfully"
echo "=== Pushing ${VERSION_TAG} ==="
docker push "${IMAGE}:${VERSION_TAG}"
echo "=== Pushing latest ==="
docker push "${IMAGE}:latest"
echo "✅ Pushed to Gitea Registry"
- name: Verify image
shell: sh
run: |
set -eu
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
echo "=== Verifying pinned deps in fresh image ==="
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
- name: Notify result
if: always()
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ "${{ job.status }}" = "success" ]; then
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
else
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
fi
- name: Cleanup
if: always()
shell: sh
run: |
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
docker rmi "${IMAGE}:latest" 2>/dev/null || true
echo "Cleanup done"
File diff suppressed because one or more lines are too long
-51
View File
@@ -1,51 +0,0 @@
name: CI Canary Check
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
jobs:
canary:
runs-on: ci-l2
timeout-minutes: 10
steps:
- name: Canary (runner -> docker -> network -> gitea)
run: |
set -e
echo "== runner/container basic =="
date; hostname; whoami
echo "== gitea api reachability =="
code=$(curl -s -o /tmp/v.json -w '%{http_code}' -m 15 "$GITHUB_API_URL/version")
echo "gitea api http_code=$code"
[ "$code" = "200" ] || { echo "::error::Gitea API unreachable, http_code=$code"; exit 1; }
cat /tmp/v.json; echo
echo "== external egress =="
ext=$(curl -s -o /dev/null -w '%{http_code}' -m 15 https://www.baidu.com || echo 000)
echo "external http_code=$ext"
echo "== gitea domain resolves NOT to loopback =="
set -o pipefail
ip=$(getent hosts git.xiaoxiajianji.com | awk '{print $1}' | head -1)
echo "git.xiaoxiajianji.com -> $ip"
if [ -z "$ip" ]; then
echo "::error::DNS resolution failed, git.xiaoxiajianji.com unresolvable"; exit 1
fi
if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
echo "::error::Gitea domain resolves to loopback inside job container (hosts/DNS leak)"; exit 1
fi
echo "CANARY OK"
- name: Notify failure
if: failure()
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
if [ -n "$CI_NOTIFY_WEBHOOK" ]; then
MSG="🚨 CI 金丝雀失败:runner->docker->网络->Gitea 链路异常,时间 $(date '+%Y-%m-%d %H:%M:%S'),请立即检查构建服务器"
python3 - "$CI_NOTIFY_WEBHOOK" "$MSG" <<'PY'
import json,sys,urllib.request
hook,msg=sys.argv[1],sys.argv[2]
data=json.dumps({"msg_type":"text","content":{"text":msg}}).encode()
urllib.request.urlopen(urllib.request.Request(hook,data=data,headers={"Content-Type":"application/json"}),timeout=10)
PY
fi
exit 0
+589
View File
@@ -0,0 +1,589 @@
name: CI/CD Pipeline
on:
push:
branches:
- main
- develop
tags:
- v*
pull_request:
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - CI漏触发补跑"
permissions:
contents: read
concurrency:
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
check-frontend-only:
name: Check if frontend-only change
runs-on: ci-check
if: github.event_name == 'pull_request'
outputs:
skip_backend: ${{ steps.check.outputs.skip_backend }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Check changed files
id: check
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "🔧 包含后端/公共变更,运行完整CI"
fi
validate:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Validate Code Quality And Tests
runs-on: ci-check
timeout-minutes: 10
permissions:
contents: write
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Verify CI environment
shell: sh
run: 'set -eu
python3 --version
python3 -m pip --version
echo "CI environment is ready"
'
- name: Install dependencies
shell: sh
run: 'set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
# Force source install of black/isort to ensure consistent formatting
# across compiled/source installations on different machines
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
python3 -m black --version
python3 -m isort --version-number
python3 -m ruff --version
bandit --version
pytest --version
'
- name: Secret detection (detect-secrets)
shell: sh
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n > /tmp/secrets-scan.json 2>&1\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
- name: Calculate changed Python files (incremental scan)
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
- name: Run code quality checks
shell: sh
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
- name: Auto-fix formatting (black + isort)
if: failure()
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/ci/auto_fix_formatting.py
- name: Type check (mypy, hard gate)
shell: sh
run: "bash scripts/ci/mypy_check.sh"
- name: Run security scan (bandit)
shell: sh
run: 'set -eu
bandit -r apps packages -q -ll
'
- name: Python dependency vulnerability scan (pip-audit)
shell: sh
run: "set -eu\necho \"=== Installing pip-audit ===\"\npython3 -m pip install -q pip-audit\npip-audit --version\necho \"\"\necho \"=== Scanning Python dependencies ===\"\nEXIT_CODE=0\nfor req_file in requirements.txt requirements-base.txt requirements-dev.txt; do\n if [ -f \"$req_file\" ]; then\n echo \"--- Scanning $req_file ---\"\n pip-audit -r \"$req_file\" --desc on 2>&1 | head -40 || EXIT_CODE=$?\n echo \"\"\n fi\ndone\necho \"pip-audit scan completed (advisory mode - warnings only, not blocking CI)\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"WARNING: Potential vulnerabilities found in dependencies.\"\nfi\nexit 0\n"
- name: Dead code detection (vulture)
if: always()
shell: sh
run: "set +e\necho \"=== Installing vulture ===\"\npython3 -m pip install -q vulture\nvulture --version\necho \"\"\necho \"=== Running vulture dead code scan (confidence >= 70%) ===\"\necho \"告警模式,不阻断CI。置信度>=90%建议尽快确认。\"\necho \"\"\n# 按置信度从高到低输出,便于优先查看高价值条目\nvulture apps packages scripts \\\n --exclude \"tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py\" \\\n --min-confidence 70 \\\n 2>&1 | sort -t'(' -k2 -rn | head -80\nEXIT_CODE=$?\necho \"\"\necho \"=== vulture scan summary ===\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"发现潜在死代码(可能包含框架装饰器注册的函数,为误报)\"\n echo \"建议:定期人工审查高置信度(>=90%)条目\"\nelse\n echo \"未发现明显死代码 ✅\"\nfi\nexit 0\n"
- name: Validate release scripts syntax
shell: sh
run: 'set -eu
bash -n scripts/backup_postgres.sh
bash -n scripts/restore_postgres_plan.sh
bash -n scripts/init_production_env.sh
'
- name: Validate Alembic migrations (with isolated PG)
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$}
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
--shm-size=256m \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=xiaoxia_saas \
-P \
--health-cmd "pg_isready -U postgres" \
--health-interval 3s \
--health-timeout 3s \
--health-retries 20 \
postgres:16-alpine
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
echo "PostgreSQL port: $PG_PORT"
export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "PostgreSQL is ready on port $PG_PORT"
break
fi
echo "Waiting for PostgreSQL... ($i/30)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
test -s /tmp/alembic-upgrade.sql
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
python3 scripts/check_schema_metadata.py
# Initialize git for migration safety diff (CI checkout is tar.gz without .git)
git init > /dev/null 2>&1
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1
git add -A > /dev/null 2>&1
git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1
python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
echo "PostgreSQL container cleaned up"
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- 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="Validate Code Quality And Tests" python3 scripts/ci_notify.py
'
unit-tests:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Unit Tests
runs-on: ci-l2
timeout-minutes: 8
env:
USE_IN_MEMORY_DB: 'true'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Install ffmpeg
shell: sh
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
- name: Install dependencies
shell: sh
run: 'set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
pytest --version
'
- name: Select incremental test files
if: github.event_name == 'pull_request'
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set +e
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
if [ $SELECT_EXIT -eq 0 ]; then
echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV
echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV
echo "全量模式"
fi
- name: Run unit tests with coverage
shell: sh
run: |
set -eu
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
echo "=== 增量测试模式 ==="
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest $SELECTED_TEST_FILES -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
# 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验)
python3 -m coverage report --fail-under=10 > /dev/null || true
else
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest tests/unit -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=65 > /dev/null
fi
- name: Diff coverage check (增量行覆盖率)
if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true'
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
# 获取base分支
BASE_BRANCH="${{ github.base_ref }}"
echo "Base branch: $BASE_BRANCH"
# 初始化git (CI tarball checkout没有.git目录)
# 先备份PR代码,再基于base分支建分支,确保HEAD与base有共同祖先
PR_CODE_DIR="/tmp/pr-code-$$"
mkdir -p "$PR_CODE_DIR"
# 排除隐藏文件(如.env)和后续生成的coverage文件,只备份源码
find . -maxdepth 1 -mindepth 1 ! -name 'coverage.xml' ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
rm -rf .git
git init > /dev/null 2>&1
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
git config user.email "ci@local"
git config user.name "CI"
# 拉取base分支用于对比
git fetch origin $BASE_BRANCH --depth=200
# 基于base分支创建当前分支,确保有共同祖先
git checkout -b ci-pr-branch "origin/$BASE_BRANCH" > /dev/null 2>&1
# 清除base分支的源码,用PR代码覆盖
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
cp -r "$PR_CODE_DIR"/. .
rm -rf "$PR_CODE_DIR"
# 提交当前代码
git add -A > /dev/null 2>&1
git commit -m "ci-tmp" > /dev/null 2>&1
# 根据模式设置门槛
if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then
# 增量测试模式覆盖不全,门槛设低一些
THRESHOLD=40
echo "增量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
else
THRESHOLD=60
echo "全量测试模式,增量覆盖率门槛: ${THRESHOLD}%"
fi
# 运行diff-cover
set +e
python3 -m diff_cover.diff_cover_tool coverage.xml \
--compare-branch="origin/$BASE_BRANCH" \
--fail-under=$THRESHOLD \
--html-report diff_coverage.html \
2>&1
DIFF_EXIT=$?
set -e
if [ $DIFF_EXIT -ne 0 ]; then
echo ""
echo "❌ 增量覆盖率未达到门槛 (${THRESHOLD}%)"
echo " 请为改动的代码添加单元测试后再提交"
echo ""
echo "=== 覆盖率报告 ==="
python3 -m diff_cover.diff_cover_tool coverage.xml \
--compare-branch="origin/$BASE_BRANCH" 2>&1 | tail -30
exit 1
fi
echo "✅ 增量覆盖率达标"
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: 'set +e
FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py
'
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- 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="Unit Tests" python3 scripts/ci_notify.py
'
integration-tests:
name: Integration Tests
runs-on: ci-l2
timeout-minutes: 30
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
needs:
- check-frontend-only
- validate
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Verify CI environment
shell: sh
run: 'set -eu
python3 --version
python3 -m pip --version
echo "CI environment is ready"
'
- name: Install dependencies
shell: sh
run: 'set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
pytest --version
'
- name: Install ffmpeg
shell: sh
run: "set +e\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg already installed: $(ffmpeg -version | head -1)\"\n exit 0\nfi\nif command -v apt-get > /dev/null 2>&1; then\n apt-get update -qq && apt-get install -y -qq ffmpeg\nelif command -v yum > /dev/null 2>&1; then\n yum install -y -q epel-release 2>/dev/null\n yum install -y -q ffmpeg 2>/dev/null\n if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then\n dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null\n dnf install -y -q ffmpeg 2>/dev/null\n fi\nelif command -v dnf > /dev/null 2>&1; then\n dnf install -y -q ffmpeg 2>/dev/null\nfi\nif command -v ffmpeg > /dev/null 2>&1; then\n echo \"ffmpeg installed successfully: $(ffmpeg -version | head -1)\"\nelse\n echo \"Warning: ffmpeg installation failed or not available, some tests may be skipped\"\nfi\n"
- name: Start Redis
shell: sh
run: "set -eu\nREDIS_CONTAINER=\"ci-redis-${GITHUB_RUN_ID:-$$}\"\necho \"REDIS_CONTAINER=$REDIS_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$REDIS_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$REDIS_CONTAINER\" \\\n -P \\\n --health-cmd \"redis-cli ping\" \\\n --health-interval 2s \\\n --health-timeout 2s \\\n --health-retries 10 \\\n redis:7-alpine\nREDIS_PORT=$(docker port \"$REDIS_CONTAINER\" 6379/tcp | cut -d: -f2)\necho \"Redis port: $REDIS_PORT\"\necho \"REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 15); do\n if docker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"Redis is ready on port $REDIS_PORT\"\n break\n fi\n echo \"Waiting for Redis... ($i/15)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}' \"$REDIS_CONTAINER\" | grep -q healthy\n"
- name: Start PostgreSQL for integration tests
shell: sh
run: "set -eu\nPG_CONTAINER=\"ci-pg-${GITHUB_RUN_ID:-$$}\"\necho \"PG_CONTAINER=$PG_CONTAINER\" >> \"$GITHUB_ENV\"\ndocker rm -f \"$PG_CONTAINER\" 2>/dev/null || true\ndocker run -d --name \"$PG_CONTAINER\" \\\n --shm-size=256m \\\n -e POSTGRES_USER=postgres \\\n -e POSTGRES_PASSWORD=postgres \\\n -e POSTGRES_DB=xiaoxia_saas \\\n -P \\\n --health-cmd \"pg_isready -U postgres\" \\\n --health-interval 5s \\\n --health-timeout 5s \\\n --health-retries 12 \\\n postgres:16\n# 获取随机映射的端口\nPG_PORT=$(docker port \"$PG_CONTAINER\" 5432/tcp | cut -d: -f2)\necho \"PostgreSQL port: $PG_PORT\"\necho \"DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas\" >> \"$GITHUB_ENV\"\nfor i in $(seq 1 30); do\n if docker inspect --format='{{.State.Health.Status}}' \"$PG_CONTAINER\" 2>/dev/null | grep -q healthy; then\n echo \"PostgreSQL is ready on port $PG_PORT\"\n break\n fi\n echo \"Waiting for PostgreSQL... ($i/30)\"\n sleep 2\ndone\ndocker inspect --format='{{.State.Health.Status}}'\
\ \"$PG_CONTAINER\" | grep -q healthy\n"
- name: Apply migrations for integration tests
shell: sh
run: 'set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
'
- name: Run integration tests
shell: sh
run: "set -eu\npython3 -m pip install -q pytest-rerunfailures\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run --append \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m \"not performance\"\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证\n"
- name: Run API performance baseline tests
shell: sh
continue-on-error: true
run: "set +e\necho \"=== API 性能基线测试 ===\"\nPERF_OUTPUT=$(mktemp)\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m pytest tests/integration/test_api_performance.py \\\n -v --timeout=120 -p no:cacheprovider 2>&1 | tee \"$PERF_OUTPUT\"\nPERF_EXIT=$?\n\n# 提取性能统计\necho \"\"\necho \"=== 性能测试摘要 ===\"\ngrep \"PERF_STATS:\" \"$PERF_OUTPUT\" || echo \"PERF_STATS: 未找到统计数据\"\ngrep \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo \"PERF_RESULT: 未找到详细结果\"\n\n# 统计通过率\nTOTAL=$(grep -c \"PERF_RESULT:\" \"$PERF_OUTPUT\" || echo 0)\nPASSED=$(grep \"PERF_RESULT: PASS\" \"$PERF_OUTPUT\" | wc -l)\nFAILED=$(grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | wc -l)\n\necho \"\"\necho \"性能测试结果: $PASSED/$TOTAL 通过, $FAILED 未达标\"\n\nif [ \"$FAILED\" -gt 0 ]; then\n echo \"\"\n echo \"⚠️ 警告: $FAILED 个接口性能未达标,请关注以下接口:\"\n grep \"PERF_RESULT: FAIL\" \"$PERF_OUTPUT\" | while read line; do\n echo \" $line\"\n done\n echo \"\"\n echo \"性能测试失败不阻塞主流水线,但建议尽快优化。\"\nelse\n echo \"✅ 所有接口性能达标!\"\nfi\n\nrm -f \"$PERF_OUTPUT\"\
\n# 始终返回 0,不阻塞流水线\nexit 0\n"
- name: Cleanup PostgreSQL & Redis
if: always()
shell: sh
run: 'docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true
echo "PostgreSQL container cleaned up"
echo "Redis container cleaned up"
'
- name: Coverage summary
if: always()
shell: sh
env:
COVERAGE_THRESHOLD: '40'
run: 'set +e
echo "=== 覆盖率汇总 ==="
python3 scripts/ci_coverage_summary.py
'
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- 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="Integration Tests" python3 scripts/ci_notify.py
'
frontend-lint:
name: Frontend Lint
runs-on: ci-check
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record job start time
shell: sh
run: 'set -eu
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
echo "Job started at $(date)"
'
- name: Install dependencies
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d\" \" -f1)\nCACHE_HASH_FILE=\"node_modules/.package-lock-hash\"\nCACHE_VALID=false\nif [ -f \"$CACHE_HASH_FILE\" ] && [ \"$(cat \"$CACHE_HASH_FILE\")\" = \"$PACKAGE_LOCK_HASH\" ] && [ -x \"node_modules/.bin/eslint\" ] && [ -x \"node_modules/.bin/tsc\" ] && [ -x \"node_modules/.bin/prettier\" ] && [ -x \"node_modules/.bin/vitest\" ]; then\n CACHE_VALID=true\n echo \"Cache hit: dependencies valid, skipping npm ci\"\nfi\nif [ \"$CACHE_VALID\" = \"false\" ]; then\n echo \"Cache miss or invalid: running npm ci...\"\n if ! npm ci --include=dev; then\n echo \"npm ci failed, cleaning node_modules and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n # Post-install integrity check: verify all critical tools exist\n if [ ! -x \"node_modules/.bin/eslint\" ] || [ ! -x \"node_modules/.bin/tsc\" ] || [ ! -x \"node_modules/.bin/prettier\" ] || [ ! -x \"node_modules/.bin/vitest\" ]; then\n echo \"Post-install check failed: critical binaries missing, cleaning and retrying...\"\n rm -rf node_modules\n mkdir -p node_modules\n npm ci --include=dev\n fi\n echo \"$PACKAGE_LOCK_HASH\" > \"$CACHE_HASH_FILE\"\n echo \"Dependencies installed, cache updated\"\nfi'\n"
- name: Run ESLint
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install eslint src --ext .ts,.tsx --max-warnings 0'\n"
- name: Run TypeScript type check
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install tsc --noEmit'\n"
- name: Run Prettier check
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
- name: Run Vitest tests
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx --no-install vitest run src/test'\n"
- name: Job duration summary
if: always()
shell: sh
run: "set +eu\nif [ -n \"$JOB_START_TIME\" ]; then\n END_TIME=$(date +%s)\n DURATION=$((END_TIME - JOB_START_TIME))\n MINS=$((DURATION / 60))\n SECS=$((DURATION % 60))\n echo \"JOB_DURATION_SECONDS=$DURATION\" >> $GITHUB_ENV\n echo \"=== Job Duration: ${MINS}m${SECS}s ===\"\nelse\n echo \"JOB_DURATION_SECONDS=0\" >> $GITHUB_ENV\n echo \"=== Job Duration: unknown ===\"\nfi\n"
- 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="Frontend Lint" python3 scripts/ci_notify.py
'
-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
+4 -20
View File
@@ -2,13 +2,13 @@ name: CI Trigger Monitor
on:
schedule:
- cron: '*/10 * * * *' # 每10分钟检查一次(与pr-auto-scan同步降频)
- cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch:
inputs:
stale_threshold:
description: 'CI未触发告警阈值(分钟)'
required: false
default: '10'
default: '5'
permissions:
contents: read
@@ -16,15 +16,11 @@ permissions:
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ci-l2
runs-on: ubuntu-latest
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
uses: actions/checkout@v3
- name: Check CI trigger status for all open PRs
env:
@@ -38,15 +34,3 @@ jobs:
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
+7 -33
View File
@@ -15,29 +15,18 @@ concurrency:
jobs:
code-review:
name: AI Code Review
runs-on: ci-l2
runs-on: ubuntu-latest
# 跳过草稿 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: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- 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
@@ -48,7 +37,6 @@ jobs:
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
@@ -61,19 +49,5 @@ jobs:
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
# 审查脚本异常不影响 CI 通过
continue-on-error: true
+136 -94
View File
@@ -1,5 +1,4 @@
name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on:
schedule:
@@ -13,7 +12,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -24,9 +23,47 @@ jobs:
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
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: Production health check & smoke test
id: smoke
shell: sh
@@ -69,25 +106,13 @@ jobs:
echo "======================================"
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 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: ci-l2
runs-on: saas
timeout-minutes: 10
outputs:
report: ${{ steps.report.outputs.report }}
report: ${{ steps.smoke.outputs.report }}
steps:
- name: Checkout code
@@ -95,15 +120,50 @@ jobs:
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
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: Run API smoke test on staging
id: smoke
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -111,8 +171,8 @@ jobs:
docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER="$STAGING_TEST_USER" \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
-e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \
@@ -183,25 +243,13 @@ jobs:
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
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 ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
outputs:
report: ${{ steps.e2e.outputs.report }}
report: ${{ steps.smoke.outputs.report }}
steps:
- name: Checkout code
@@ -209,9 +257,47 @@ jobs:
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
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: Run Playwright E2E on staging
id: e2e
shell: sh
@@ -246,22 +332,10 @@ jobs:
echo "=========================================="
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. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
@@ -270,9 +344,6 @@ jobs:
- name: Run performance baseline checks
id: perf
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -308,10 +379,9 @@ jobs:
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -341,7 +411,7 @@ jobs:
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
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
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -389,9 +459,6 @@ jobs:
- name: Generate performance report
id: report
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
echo ""
@@ -406,11 +473,10 @@ jobs:
RESULTS=""
START_TIME=$(date +%s)
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&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"
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
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -514,22 +580,10 @@ jobs:
exit 0
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. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: ci-l2
runs-on: saas
timeout-minutes: 2
if: always()
needs:
@@ -602,15 +656,3 @@ jobs:
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败
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
-60
View File
@@ -1,60 +0,0 @@
name: "Debug: Web container v2 (mount conflict)"
on:
push:
branches: [debug/web-crash-v2]
workflow_dispatch:
jobs:
web-diag:
runs-on: runtime-builder
timeout-minutes: 10
steps:
- name: Setup SSH and diagnose
shell: bash
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -x
which ssh || (apt-get update -qq && apt-get install -y -qq openssh-client)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf "%s" "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
H=47.98.113.167; P=22222
ssh-keyscan -p $P -H $H >> ~/.ssh/known_hosts 2>/dev/null
ssh -p $P -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no root@$H 'bash -s' <<'REMOTE'
set -x
echo "=== Current staging containers ==="
docker ps -a --filter name=xiaoxia-*-staging --format "table {{.Names}}\t{{.Status}}\t{{.Image}}"
echo ""
echo "=== Web container logs (current/current-rolledback) ==="
docker logs xiaoxia-web-staging 2>&1 | tail -40
echo ""
echo "=== Web inspect: env & mounts ==="
docker inspect xiaoxia-web-staging --format 'Entrypoint: {{.Config.Entrypoint}} Cmd: {{.Config.Cmd}}'
docker inspect xiaoxia-web-staging --format '{{range .Config.Env}}{{.}}{{"\n"}}{{end}}' | grep -E "APP_ENV|VERSION"
echo "Mounts:"
docker inspect xiaoxia-web-staging --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} (rw={{.RW}}){{"\n"}}{{end}}'
echo ""
echo "=== Reproduce: rm on read-only bind mount ==="
docker run --rm --name nginx-ro-test \
-v /var/lib/xiaoxia-saas-staging/nginx-staging.conf:/etc/nginx/conf.d/default.conf:ro \
git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/xiaoxia-saas-web:387514c \
sh -c '
set -x
echo "Before:"
ls -la /etc/nginx/conf.d/
echo "Try rm (as entrypoint does):"
rm -f /etc/nginx/conf.d/default.conf
echo "rm exitcode=$?"
echo "After rm:"
ls -la /etc/nginx/conf.d/
echo "Test ln:"
ln -s /etc/nginx/nginx-staging.conf /etc/nginx/conf.d/default.conf
echo "ln exitcode=$?"
ls -la /etc/nginx/conf.d/
echo "nginx -t:"
nginx -t 2>&1
' 2>&1
echo ""
echo "=== Also test with NEW fixed image (9c0d4b1 if present) ==="
docker images | grep xiaoxia-saas-web | head -5
REMOTE
@@ -1,59 +0,0 @@
name: Playwright Base Image Build
on:
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "构建 playwright 基础镜像"
jobs:
build-playwright:
name: Build Playwright Base Image
runs-on: runtime-builder
timeout-minutes: 30
steps:
- name: Docker login to Gitea Registry
shell: sh
env:
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login attempt $i/3 ==="
if printf '%s' "${GITEA_REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" --password-stdin; then
echo "Docker login successful"
break
fi
echo "Docker login failed (attempt $i/3), retrying in 5s..."
sleep 5
[ $i -eq 3 ] && exit 1
done
- name: Pull, retag and push Playwright image
shell: sh
run: |
set -eu
OFFICIAL_IMAGE="mcr.microsoft.com/playwright:v1.45.0-jammy"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy"
echo "=== Pulling official Playwright image ==="
docker pull "${OFFICIAL_IMAGE}"
echo "=== Tagging ==="
docker tag "${OFFICIAL_IMAGE}" "${GITEA_IMAGE}"
echo "=== Pushing to Gitea Registry ==="
docker push "${GITEA_IMAGE}"
echo "Done: ${GITEA_IMAGE}"
- name: Cleanup
if: always()
shell: sh
run: |
docker rmi "mcr.microsoft.com/playwright:v1.45.0-jammy" 2>/dev/null || true
docker rmi "git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy" 2>/dev/null || true
echo "Cleanup done"
-56
View File
@@ -1,56 +0,0 @@
name: PR Auto Scan
# 定时扫描所有open PR,对CI全绿的触发审批/合并
# 作为短作业模式的兜底,防止事件驱动遗漏
on:
schedule:
# - cron: "*/15 * * * *" # DISABLED: temporarily to stop failure spam (2026-09-02) # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
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
-119
View File
@@ -1,119 +0,0 @@
name: PR Automation
on:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pr-automation-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
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: 10 # 等待CI全绿+审批,需要充足时间
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'
needs: [auto-approve] # 修复竞态:必须等审批完成后再尝试合并
timeout-minutes: 15 # 等待审批+CI就绪+合并,需要充足时间
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
+8 -66
View File
@@ -15,52 +15,6 @@ jobs:
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: |
@@ -120,26 +74,26 @@ jobs:
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
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
# 查找可用的SSH密钥
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
if [ -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)"
elif [ -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"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
@@ -193,15 +147,3 @@ jobs:
> /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
+46 -52
View File
@@ -93,28 +93,44 @@ jobs:
shell: sh
run: |
set -eu
cd apps/web
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
# Install dependencies with retry
for i in 1 2 3; do
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
echo "npm install failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
done
# TypeScript check
echo "=== TypeScript check ==="
./node_modules/.bin/tsc --noEmit
# Vite build
echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
./node_modules/.bin/vite build
echo "=== Build completed ==="
ls -la dist/
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci --include=dev; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci --include=dev
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
@@ -141,26 +157,26 @@ jobs:
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_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
# 查找可用的SSH密钥
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
if [ -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)"
elif [ -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"
else
echo "ERROR: No SSH key available"
ls -la ~/.ssh/ 2>/dev/null || true
@@ -168,16 +184,6 @@ jobs:
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"
@@ -266,15 +272,3 @@ jobs:
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
-86
View File
@@ -1,86 +0,0 @@
name: Worker Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements.txt'
- 'requirements-worker.txt'
- 'infra/docker/worker-base.Dockerfile'
workflow_dispatch:
jobs:
build-worker-base:
name: Build Worker Base Image
runs-on: runtime-builder
timeout-minutes: 45
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: Build and push Worker base image
shell: sh
run: |
set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest"
echo "=== Building Worker base image ==="
# 使用普通 docker build(单平台不需要 buildx
docker build \
-f infra/docker/worker-base.Dockerfile \
-t "${ACR_IMAGE}" \
.
echo ""
echo "✅ Image built successfully"
# 推送到 ACR
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
if: always()
shell: sh
run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
docker image prune -f 2>/dev/null || true
echo "Cleanup done"
-7
View File
@@ -24,11 +24,6 @@ ruff_cache/
.env.production
.env.staging
!.env.example
# 配置模板不受忽略规则限制
!deploy/configs/.env.staging
!deploy/configs/.env.production
# 渲染后的 env 文件包含真实密钥,绝不能提交
.env.rendered
# OS / editor
.DS_Store
@@ -58,5 +53,3 @@ frontend-v21-ui-prototype-final.html
!.vscode/
!.vscode/settings.json
.vscode/extensions.json
.coverage
.env.current
-9
View File
@@ -18,12 +18,3 @@ repos:
- 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
Vendored Executable → Regular
-37
View File
@@ -6,43 +6,6 @@
"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"
-222
View File
@@ -1,222 +0,0 @@
---
AIGC:
Label: "1"
ContentProducer: 001191110102MACQD9K64018705
ProduceID: 15868733686388_0/project_7655981463858544923-files/docs/1197_preview_generation_proposal.md
ReservedCode1: ""
ContentPropagator: 001191110102MACQD9K64028705
PropagateID: 15868733686388#1785468313901
ReservedCode2: ""
---
# #1197 预览生成接口方案评估
## 背景
智能剪辑「一键生成」流程中,第3步预览生成当前被跳过,直接进入下一步。需要实现真正的预览生成功能,让用户在正式生成前能看到效果预览。
## 现状分析
### 现有生成链路
```
API 触发生成 → GenerationTask入库 → Celery异步任务 → UnifiedRenderService渲染 → OSS上传 → 更新状态
```
**关键节点:**
1. **API层**`POST /generation-tasks``POST /templates/{id}/generate` 触发生成
2. **任务调度**Celery task `worker.generate_video`
3. **渲染引擎**`UnifiedRenderService`(统一渲染引擎,已接入9个效果层)
4. **输出配置**:默认 720p (1280x720),支持 `resolution` 字段自定义
5. **产物存储**`GeneratedVideo` 表记录,OSS 存储视频文件
### 已有可复用能力
| 能力 | 位置 | 是否可复用 |
|------|------|-----------|
| 任务创建与状态管理 | `GenerationTask` + `CreateGenerationTaskUseCase` | ✅ 是 |
| 素材下载与预处理 | `_download_video_assets` / `_download_voice_asset` | ✅ 是 |
| 统一渲染引擎 | `UnifiedRenderService` | ✅ 是 |
| 分辨率配置 | `resolution` 字段已支持 | ✅ 是 |
| 混音与后处理 | `_render_video` 内流程 | ✅ 是 |
| OSS 上传与查重 | `_upload_and_dedup` | ✅ 是 |
| 进度追踪 | `append_log` / `progress` 字段 | ✅ 是 |
## 方案对比
### 方案A:复用现有生成链路 + is_preview 标记(推荐)
**思路**:在现有 GenerationTask 上加 `is_preview` 标记,预览生成走完整链路但参数降级。
**改动点:**
1. **数据模型**`GenerationTask``is_preview: bool` 字段(默认 false);`GeneratedVideo``is_preview: bool`
2. **API 层**:生成接口加 `is_preview` 参数,预览任务不计入配额
3. **渲染参数**:预览模式下自动调整
- 分辨率:480p (854x480)
- 时长:限制前 15 秒(或模板第一个片段)
- 码率:降低至 1.5Mbps(正式 4Mbps
- 效果层:跳过高级转场/粒子特效等耗时效果
4. **任务调度**:预览任务走低优先级队列(或复用现有队列,标记优先级)
5. **前端对接**:预览生成结果带 `is_preview=true` 标记,前端展示"预览"标签
**优点:**
- 代码复用率 90%+,改动最小
- 与正式生成逻辑一致,预览效果真实可信
- 进度查询、结果展示等功能直接复用
- 后续可平滑升级:预览满意后一键转正式生成
**缺点:**
- 需要区分预览和正式任务,避免数据混淆
- 预览任务和正式任务竞争同一队列资源(可后续优化为独立队列)
**开发量估算**2-3 天
- 数据模型 + 迁移:0.5 天
- API 层改造:0.5 天
- 渲染参数降级:1 天
- 测试 + 联调:1 天
---
### 方案B:新建独立预览接口 + 轻量渲染逻辑
**思路**:新建独立的预览生成接口,使用简化的渲染逻辑(如只拼接素材+基础配音,跳过大部分效果)。
**改动点:**
1. 新增 `PreviewTask` 数据模型
2. 新增 `POST /api/v1/preview/generate` 接口
3. 新增独立的 Celery task `worker.generate_preview`
4. 简化渲染流程:只做素材裁剪+拼接+配音,跳过转场/滤镜/字幕特效等
**优点:**
- 完全隔离,不影响正式生成链路
- 可以做极致优化,预览生成速度快
- 数据模型清晰,不会混淆
**缺点:**
- 代码重复率高,两套生成逻辑维护成本翻倍
- 预览效果与正式生成可能不一致(效果层差异)
- 前端需要对接两套接口
- 无法从预览升级为正式生成(需重新走完整流程)
**开发量估算**4-5 天
- 数据模型 + 接口:1 天
- 简化渲染逻辑:2 天
- 测试 + 联调:1-2 天
---
### 方案C:图片预览(首帧/关键帧截图)
**思路**:不生成视频,只生成几张关键帧的预览图片。
**优点:**
- 生成速度极快(秒级)
- 资源消耗小
**缺点:**
- 预览效果差,用户无法感知动态效果
- 无法验证配音、转场、节奏等时间维度的效果
- 用户体验不佳,不如"真预览"有说服力
**开发量估算**1-2 天
---
## 推荐方案:方案A(复用现有生成链路)
### 核心理由
1. **效果保真**:预览和正式生成用同一套渲染引擎,效果一致,用户信任度高
2. **开发效率**90% 代码复用,2-3 天可上线
3. **可扩展性强**:后续可加「预览转正式」「低分辨率快速预览」等增强功能
4. **维护成本低**:一套生成逻辑,bug 修复和新功能同时生效
### 详细设计
#### 1. 数据模型变更
```python
# GenerationTask 新增字段
is_preview: bool = False
"""是否为预览生成"""
preview_of: str = ""
"""预览对应的正式任务 ID(或反向关联)"""
# GeneratedVideo 新增字段
is_preview: bool = False
"""是否为预览视频"""
```
**迁移**alembic 新增 migration,两个表各加 1-2 个字段。
#### 2. API 层
```
POST /api/v1/generation-tasks
Body 增加 is_preview: bool = false
POST /api/v1/templates/{id}/generate
Query 增加 is_preview: bool = false
```
**配额处理**:预览生成不计入用户配额,不占用生成次数限制。
#### 3. 渲染参数降级
| 参数 | 正式生成 | 预览生成 |
|------|---------|---------|
| 分辨率 | 720p (1280x720) | 480p (854x480) |
| 码率 | 4 Mbps | 1.5 Mbps |
| 时长 | 完整时长 | 前 15 秒(或第一段) |
| 帧率 | 30 fps | 24 fps |
| 转场效果 | 完整转场 | 仅淡入淡出(或简单切) |
| 特效滤镜 | 全部启用 | 跳过粒子/光效等高级效果 |
| 字幕 | 完整渲染 | 正常渲染(字幕是核心信息) |
| 配音 | 完整混音 | 正常混音(配音是核心信息) |
**实现方式**:在 `_render_video` 或 UnifiedRenderService 入口处,根据 `is_preview` 标记调整渲染配置。
#### 4. 任务调度
- 初期复用现有队列,预览任务正常排队
- 后续如需优化,可拆分独立预览队列(低优先级)
- 预览任务可设置较短超时时间
#### 5. 前端对接
- 调用生成接口时传 `is_preview=true`
- 结果列表中预览视频带「预览」标签
- 预览满意后可一键「升级为正式生成」(重新触发全分辨率生成,可复用素材下载缓存)
### 实施步骤
**Phase 1MVP2天):**
1. 数据模型 + 迁移
2. API 层支持 is_preview 参数
3. 渲染分辨率降级(480p
4. 不计入配额
5. 基础测试
**Phase 2(优化,1-2天):**
1. 时长限制(前15秒)
2. 效果层降级(跳高级效果)
3. 预览任务低优先级队列
4. 预览转正式生成功能
## 与前端对齐点
1. 预览生成的触发时机(第3步自动生成?用户点击才生成?)
2. 预览时长是固定15秒还是完整但低清?
3. 是否需要「预览转正式生成」功能
4. 预览视频的展示形态(和正式视频一样还是有特殊UI)
## 风险与注意事项
1. **数据混淆**:确保统计、计费、列表展示时正确区分预览和正式任务
2. **存储成本**:预览视频也占 OSS 空间,可设置自动清理(7天后自动删除)
3. **用户预期**:要明确告诉用户这是预览,效果和正式生成一致但清晰度低
4. **并发压力**:如果用户频繁生成预览,可能增加系统负载,需要限流
---
> 本内容由 Coze AI 生成,请遵循相关法律法规及《人工智能生成合成内容标识办法》使用与传播。
-382
View File
@@ -1,382 +0,0 @@
# #1197 预览生成接口技术方案(v2)
> 更新说明:v2 新增「多版本预览生成」能力,支持一个模板生成多个不重复的预览视频,左侧列表展示,用户可挑选满意的版本转正式生成。
## 1. 背景与目标
**现状**:智能剪辑「一键生成」第3步预览生成被跳过,用户直接进入正式生成,缺少效果预览环节。
**目标**
1. ✅ 实现真正的预览生成(低分辨率快速出片)
2.**支持生成 1~N 个不重复的预览版本**(默认 3 个),左侧列表展示
3. ✅ 预览满意后可一键转正式生成(复用素材下载缓存)
4. ✅ 不计入用户配额,不占用正式生成次数
---
## 2. 现有生成链路分析
### 2.1 链路总览
```
API 触发生成 → GenerationTask入库 → Celery异步任务
→ 下载素材 → 构建plan/clips → UnifiedRenderService渲染
→ 混音后处理 → OSS上传 + 查重 → 更新状态
```
### 2.2 决定视频差异的变量
要做"多个不重复版本",先分析哪些环节可以引入变化:
| 变量 | 当前行为 | 能否引入变化 | 影响程度 |
|------|---------|------------|---------|
| 素材选择 | 按 asset_ids 顺序全用 | ✅ 可随机选择子集/不同组合 | 大 |
| 素材排序 | 按 asset_ids 顺序 | ✅ 可 shuffle 重排 | 大 |
| 配音选择 | 固定 voice_library_id | ✅ 可选不同音色 | 中 |
| 标题选择 | 固定 title_ids 或随机选 | ✅ 可选不同标题 | 中 |
| BGM | 固定 bgm_config | ✅ 可选不同BGM | 小 |
| 转场效果 | 模板固定 | ✅ 可随机化转场类型 | 小 |
| 播放速度 | 模板固定 | ✅ 可微调速度 | 小 |
| 分辨率/码率 | 固定 | ✅ 预览可降级 | 不影响内容 |
### 2.3 可复用能力
- 任务创建与状态管理:`GenerationTask` + `CreateGenerationTaskUseCase`
- 素材下载与预处理:`_download_all_assets`
- 统一渲染引擎:`UnifiedRenderService`
- 分辨率配置:`resolution` 字段已支持
- 批量任务:`batch_id` 字段已存在(可用于预览组)
---
## 3. 总体方案:复用现有链路 + 多变体引擎
**核心思路**:沿用 v1 的"复用现有生成链路 + is_preview 标记"方案,在此基础上增加「多版本生成」能力。
**架构**
```
预览生成请求(count=N
创建预览批次(preview_batch
变体引擎生成 N 个变体参数(variation seed + 参数组合)
为每个变体创建 1 个 GenerationTaskis_preview=true
N 个 Celery 任务并行执行(走现有生成链路,参数降级)
N 个结果汇聚,前端左侧列表展示
```
---
## 4. 详细设计
### 4.1 数据模型变更
#### 4.1.1 GenerationTask 新增字段
```python
# 现有字段保留,新增:
is_preview: bool = False
"""是否为预览生成"""
preview_batch_id: str = ""
"""预览批次 ID(同批次的 N 个预览共享一个 batch)"""
variant_seed: int = 0
"""变体种子,用于控制随机化行为(素材选择、排序、转场等)"""
variant_params: dict = field(default_factory=dict)
"""变体参数快照(记录本次使用了哪些素材、标题、配音等,可追溯)
{
"asset_ids": [...], # 实际选用的素材子集
"title_id": "", # 选用的标题
"voice_id": "", # 选用的配音
"transition_style": "", # 转场风格
"bgm_track": "", # BGM 音轨
}
"""
```
#### 4.1.2 GeneratedVideo 新增字段
```python
is_preview: bool = False
"""是否为预览视频"""
preview_batch_id: str = ""
"""所属预览批次"""
variant_index: int = 0
"""在批次中的序号(0, 1, 2..."""
```
#### 4.1.3 迁移方案
alembic 新增 migration,两个表各加 4 个字段,默认值为空/false,无数据回填成本。
---
### 4.2 变体引擎(Variant Engine
**核心组件**:根据 count 和 seed,生成 N 组互不相同的生成参数。
#### 4.2.1 变纬度设计
| 维度 | 策略 | 说明 |
|------|------|------|
| **素材子集选择** | 从素材池中随机选 M 个(M=min(素材数, 模板clip数*2)) | 版本差异最大的来源 |
| **素材排序** | 随机打乱顺序 | 影响叙事节奏 |
| **标题选择** | 从 title_ids 中随机选 1 个 | 影响文案内容 |
| **配音选择** | 从 voice_ids 中随机选 1 个(如有多个) | 影响听觉体验 |
| **转场风格** | 从预设转场池中随机选 1 种 | 影响视觉过渡 |
| **BGM 选择** | 从 bgm 列表中随机选 1 首(如有配置) | 影响氛围 |
#### 4.2.2 去重机制
- 同一批次内,变体参数必须两两不同(至少素材组合或排序不同)
- 使用 `variant_seed` 保证可复现(相同 seed → 相同变体)
- 如果素材数量不足导致无法生成 N 个不同版本,按实际能生成的数量返回
#### 4.2.3 接口设计
```python
def generate_variants(
count: int,
seed: int,
asset_pool: list[str], # 可用素材 ID 列表
title_pool: list[str] = [], # 可用标题 ID 列表
voice_pool: list[str] = [], # 可用配音 ID 列表
template_id: str = "",
) -> list[dict]:
"""
生成 count 组变体参数。
每组参数包含:asset_ids(选用的素材+排序)、title_id、voice_id、
transition_style 等,确保两两不同。
"""
```
---
### 4.3 API 层设计
#### 4.3.1 预览生成接口
```
POST /api/v1/templates/{template_id}/generate-preview
```
**请求体**
```json
{
"asset_library_id": "lib_xxx",
"asset_ids": ["asset_1", "asset_2", ...],
"title_ids": ["title_1", "title_2"],
"voice_ids": ["voice_1", "voice_2"],
"bgm_config": {},
"count": 3,
"seed": 0
}
```
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| template_id | path | ✅ | - | 模板 ID |
| asset_library_id | body | ✅ | - | 素材库 ID |
| asset_ids | body | ✅ | - | 素材池(从中选子集/排序) |
| title_ids | body | - | [] | 标题池(可选,不传则不用标题) |
| voice_ids | body | - | [] | 配音池(可选) |
| bgm_config | body | - | {} | BGM 配置 |
| count | body | - | 3 | 生成几个预览版本(1~10) |
| seed | body | - | 0 | 随机种子,0 表示随机 |
**响应**
```json
{
"preview_batch_id": "pb_xxx",
"count": 3,
"tasks": [
{
"task_id": "gen_xxx_0",
"variant_index": 0,
"status": "processing"
},
{
"task_id": "gen_xxx_1",
"variant_index": 1,
"status": "processing"
},
...
]
}
```
#### 4.3.2 预览批次查询接口
```
GET /api/v1/preview-batches/{batch_id}
```
返回批次内所有预览任务的状态、结果(已完成的带 video_url)。
**响应**
```json
{
"preview_batch_id": "pb_xxx",
"count": 3,
"completed_count": 2,
"tasks": [
{
"task_id": "gen_xxx_0",
"variant_index": 0,
"status": "completed",
"video_url": "https://oss.xxx/preview/xxx.mp4",
"duration": 15.5,
"thumbnail_url": "https://oss.xxx/preview/xxx.jpg"
},
...
]
}
```
#### 4.3.3 预览转正式生成
```
POST /api/v1/preview-batches/{batch_id}/tasks/{task_id}/promote
```
将某个预览版本升级为正式生成(复用素材缓存,重新全分辨率渲染)。
---
### 4.4 渲染参数降级
预览模式下自动调整以下参数:
| 参数 | 正式生成 | 预览生成 |
|------|---------|---------|
| 分辨率 | 720p (1280x720) | 480p (854x480) |
| 码率 | 4 Mbps | 1.5 Mbps |
| 帧率 | 30 fps | 24 fps |
| 时长 | 完整时长 | 前 15 秒(或第一段完整clip) |
| 转场效果 | 完整转场 | 仅淡入淡出 |
| 高级特效 | 全部启用 | 跳过粒子/光效等 |
| 字幕 | 完整渲染 | 正常渲染 |
| 配音 | 完整混音 | 正常混音 |
| 输出质量 | high | medium |
**实现位置**`_render_video` 函数入口处,根据 `is_preview` 标记调整渲染配置。
---
### 4.5 任务调度
- **并行执行**:N 个预览任务并行提交到 Celery,不排队等待
- **低优先级**:预览任务走独立队列(`preview_queue`),不抢占正式生成资源
- **超时控制**:预览任务超时时间 5 分钟(正式 30 分钟)
- **自动清理**:预览视频 7 天后自动从 OSS 删除,任务记录标记为 archived
---
## 5. 前端对接要点
### 5.1 交互流程
```
第2步选素材 → 第3步点击"生成预览"
→ 显示 loading + 进度
→ 预览陆续完成,左侧列表逐张出现
→ 用户点击左侧不同版本,右侧预览区切换
→ 用户选中满意版本 → 点击"正式生成"
```
### 5.2 需要对齐的接口
1. **预览创建**`POST /templates/{id}/generate-preview`
2. **批次状态轮询**`GET /preview-batches/{id}`(建议 2s 轮询,或走 SSE
3. **预览转正式**`POST /preview-batches/{id}/tasks/{task_id}/promote`
### 5.3 数据格式对齐
预览视频条目结构:
```json
{
"id": "gen_xxx",
"variant_index": 0,
"status": "completed",
"video_url": "https://...",
"duration": 15.5,
"file_size": 2850000,
"thumbnail_url": "https://...",
"is_preview": true
}
```
---
## 6. 配额与计费
- 预览生成**不计入**用户配额
- 同一模板 + 同一素材池,每天最多生成 3 次多版本预览(防滥用)
- 单个预览批次最多 10 个版本
---
## 7. 实施步骤
### Phase 1:单版本预览(MVP2 天)
1. 数据模型 + 迁移(is_preview 字段)
2. API 层支持 is_preview 参数
3. 渲染分辨率降级(480p
4. 不计入配额
5. 基础测试
### Phase 2:多版本预览(3 天)
1. 变体引擎实现(素材随机选择 + 排序 + 去重)
2. preview_batch 批次管理
3. 批量创建 N 个预览任务
4. 批次查询接口
5. 前端联调
### Phase 3:预览转正式 + 优化(2 天)
1. 预览转正式生成接口(promote)
2. 素材下载缓存复用
3. 独立预览队列(低优先级)
4. 自动清理机制
5. 完整测试 + 压测
---
## 8. 风险与注意事项
| 风险 | 影响 | 应对 |
|------|------|------|
| 并发预览任务过多打满 worker | 正式生成被阻塞 | 独立预览队列 + 限流 |
| 变体生成的视频差异不够大 | 用户觉得"都一样" | 优先素材子集+排序差异,保证视觉差异 |
| 预览视频占用 OSS 存储 | 存储成本上升 | 7 天自动清理 + 低码率 |
| N 个版本同时下载重复素材 | 带宽浪费 | 批次内共享一次下载(Phase 3 优化) |
| 用户预期管理 | 以为预览就是最终效果 | 明确标注"预览版",说明分辨率差异 |
---
## 9. 开发量估算
| 阶段 | 后端 | 前端 | 合计 |
|------|------|------|------|
| Phase 1 单版本预览 | 2 天 | 1 天 | 3 天 |
| Phase 2 多版本预览 | 3 天 | 2 天 | 5 天 |
| Phase 3 转正式+优化 | 2 天 | 1 天 | 3 天 |
| **总计** | **7 天** | **4 天** | **~7 天(并行)** |
---
## 10. 与 v1 方案的差异总结
1. **新增多版本能力**:从"生成1个预览"升级为"生成N个不重复预览"
2. **新增变体引擎**:负责素材选择/排序/配音/标题的随机化
3. **新增批次概念**preview_batch 管理一组预览任务
4. **新增 promote 接口**:预览转正式生成
5. **独立队列**:预览不抢占正式生成资源
6. **开发量**:从 2-3 天增加到约 7 天(后端)
-2
View File
@@ -494,5 +494,3 @@
- [Fixed] Bug 修复
- [Security] 安全相关更新
- [Performance] 性能优化
---
- 2026-09-16: fix extract-from-douyin 异常路径全部返回业务码(消除500) #1963
-1
View File
@@ -1 +0,0 @@
retrigger3
-1
View File
@@ -263,4 +263,3 @@ pytest --cov=packages --cov-report=html
---
**License**: MIT
<!-- CI trigger: 1788229339 -->
Executable → Regular
+4 -7
View File
@@ -1,3 +1,4 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
@@ -7,17 +8,13 @@ from alembic import context
# Import your models' Base here
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
# access to the values within the .ini file in use.
config = context.config
# 从统一配置系统获取 database_url,确保与应用使用同一配置源
settings = get_shared_settings()
if settings.database_url:
config.set_main_option("sqlalchemy.url", settings.database_url)
database_url = os.getenv("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
@@ -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")
@@ -1,61 +0,0 @@
"""#1197 - 预览生成:generation_tasks 表新增 is_preview 字段
Revision ID: 053
Revises: 052
Create Date: 2026-08-15
Changes:
1. generation_tasks 表新增 is_preview 字段,标记是否为预览生成任务(低清 480p)
2. 默认 False,与现有正式生成任务兼容
3. 加索引以支持按预览/正式任务筛选
"""
import sqlalchemy as sa
from alembic import context, op
revision = "053_generation_task_is_preview"
down_revision = "052_generation_task_bgm_config"
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 = 'is_preview'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column("is_preview", sa.Boolean, nullable=False, server_default=sa.text("false")),
)
# 加索引
op.create_index(
"ix_generation_tasks_is_preview",
"generation_tasks",
["is_preview"],
)
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 = 'is_preview'"
)
)
if result.scalar() is None:
return
op.drop_index("ix_generation_tasks_is_preview", table_name="generation_tasks")
op.drop_column("generation_tasks", "is_preview")
@@ -1,82 +0,0 @@
"""确认生成 API 改造:为 generation_tasks 表添加 source_task_id、output_width、output_height、cover_url、custom_title 字段
Revision ID: 054_confirm_gen_fields
Revises: 053_generation_task_is_preview
Create Date: 2026-08-16
Changes:
1. generation_tasks 表新增 source_task_id(来源预览任务 ID,带索引)
2. generation_tasks 表新增 output_width / output_height(动态输出分辨率)
3. generation_tasks 表新增 cover_url / custom_title(自定义封面和标题)
"""
import sqlalchemy as sa
from alembic import op
revision = "054_confirm_gen_fields"
down_revision = "053_generation_task_is_preview"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
is_pg = conn.dialect.name == "postgresql"
if is_pg:
# 幂等检查:source_task_id 列是否已存在
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'source_task_id'"
)
)
if result.scalar() is not None:
return
# source_task_id
op.add_column(
"generation_tasks",
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
)
# output_width
op.add_column(
"generation_tasks",
sa.Column("output_width", sa.Integer, nullable=False, server_default=sa.text("1280")),
)
# output_height
op.add_column(
"generation_tasks",
sa.Column("output_height", sa.Integer, nullable=False, server_default=sa.text("720")),
)
# cover_url
op.add_column(
"generation_tasks",
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
)
# custom_title
op.add_column(
"generation_tasks",
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
)
# 索引
op.create_index(
"ix_generation_tasks_source_task_id",
"generation_tasks",
["source_task_id"],
)
def downgrade() -> None:
op.drop_index("ix_generation_tasks_source_task_id", table_name="generation_tasks")
op.drop_column("generation_tasks", "custom_title")
op.drop_column("generation_tasks", "cover_url")
op.drop_column("generation_tasks", "output_height")
op.drop_column("generation_tasks", "output_width")
op.drop_column("generation_tasks", "source_task_id")
-82
View File
@@ -1,82 +0,0 @@
"""封面模板表 cover_templates
Revision ID: 055_cover_templates
Revises: 054_confirm_gen_fields
Create Date: 2026-08-09
Changes:
1. 新建 cover_templates 表,支持系统预置和用户自定义封面模板
2. user_id 为 NULL 表示系统模板,is_system 标记区分
3. config 为 JSON 字段,存储封面配置信息
"""
import sqlalchemy as sa
from alembic import context, op
revision = "055_cover_templates"
down_revision = "054_confirm_gen_fields"
branch_labels = None
depends_on = None
SYSTEM_TEMPLATES = [
("a8b0120fd98e44788f5a6590f983d327", "默认模板", {}),
("6d8c501b11424432b3df3a45ae89b1a9", "大胆红", {"background_color": "#ef4444"}),
("04937fb57fea4bad95e7883e71a6b246", "优雅黑", {"background_color": "#111827"}),
("3ff9cc821174437ca53931073e7f536e", "渐变蓝", {"background_color": "#3b82f6"}),
("db51b3ea8f1a4f4caa94bf2d51f27d11", "渐变紫", {"background_color": "#8b5cf6"}),
("5027d113432a4f798a3b4ee1644d66af", "暖橙", {"background_color": "#f97316"}),
("0e10def2b5a148d686416494474726c2", "清新绿", {"background_color": "#22c55e"}),
("38ea98ac00c04bada064006d880546f0", "科技蓝", {"background_color": "#06b6d4"}),
]
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
result = conn.execute(sa.text("SELECT to_regclass('public.cover_templates')"))
if result.scalar() is not None:
return
op.create_table(
"cover_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=True, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("is_system", sa.Boolean, nullable=False, server_default=sa.false(), index=True),
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
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()),
)
# 预置系统模板 seed 数据
cover_templates = sa.table(
"cover_templates",
sa.column("id", sa.String),
sa.column("user_id", sa.String),
sa.column("name", sa.String),
sa.column("thumbnail_url", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("config", sa.JSON),
sa.column("created_at", sa.DateTime),
sa.column("updated_at", sa.DateTime),
)
for tid, name, config in SYSTEM_TEMPLATES:
conn.execute(
cover_templates.insert().values(
id=tid,
user_id=None,
name=name,
thumbnail_url="",
is_system=True,
config=config,
created_at=sa.func.now(),
updated_at=sa.func.now(),
)
)
def downgrade() -> None:
op.drop_table("cover_templates")
@@ -1,39 +0,0 @@
"""修复 cover_templates.config 双重序列化
Revision ID: 056_fix_cover_templates_config
Revises: 055_cover_templates
Create Date: 2026-08-13
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
"""
import sqlalchemy as sa
from alembic import op
revision = "056_fix_cover_templates_config"
down_revision = "055_cover_templates"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
if conn.dialect.name == "postgresql":
conn.execute(
sa.text(
"UPDATE cover_templates SET config = (config#>>'{}')::json "
"WHERE jsonb_typeof(config::jsonb) = 'string'"
)
)
def downgrade() -> None:
# No safe rollback — the original data was incorrect
pass
@@ -1,26 +0,0 @@
"""Add title_config to generation_tasks
Revision ID: 057_title_config
Revises: 056_fix_cover_templates_config
Create Date: 2026-08-23
"""
import sqlalchemy as sa
from alembic import op
revision = "057_title_config"
down_revision = "056_fix_cover_templates_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "title_config")
@@ -1,49 +0,0 @@
"""Add unique index on asset_libraries(project_id, kind)
Revision ID: 058_uq_asset_lib_project_kind
Revises: 057_title_config
Create Date: 2026-08-30
同一项目下同 kind 的素材库业务上唯一(前端 getOrCreate 语义、TTS 保存自动建库)。
加唯一索引兜底并发创建竞态,避免重复素材库。
"""
import sqlalchemy as sa
from alembic import op
revision = "058_uq_asset_lib_project_kind"
down_revision = "057_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 建唯一索引前清洗历史重复:同 (project_id, kind) 只保留 created_at 最新的一条。
# project_id 为 NULL 的系统级行不参与去重(NULL 在唯一索引中互不冲突)。
op.execute("""
DELETE FROM asset_libraries
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY project_id, kind
ORDER BY created_at DESC, id DESC
) AS rn
FROM asset_libraries
WHERE project_id IS NOT NULL
) t
WHERE t.rn > 1
)
""")
# 与 model 的 UniqueConstraint 定义保持一致(pg_constraint + pg_index 同时注册),
# 避免 Alembic autogenerate 检测到 schema drift
op.create_unique_constraint(
"uq_asset_libraries_project_kind",
"asset_libraries",
["project_id", "kind"],
)
def downgrade() -> None:
op.drop_constraint("uq_asset_libraries_project_kind", "asset_libraries", type_="unique")
@@ -1,23 +0,0 @@
"""add duplicate_rate to generated_videos
Revision ID: 059_duplicate_rate
Revises: 058_uq_asset_lib_project_kind
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "059_duplicate_rate"
down_revision = "058_uq_asset_lib_project_kind"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("generated_videos", "duplicate_rate")
@@ -1,57 +0,0 @@
"""migrate template_segments data to template_clip_configs
Revision ID: 060_migrate_segments
Revises: 059_duplicate_rate
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "060_migrate_segments"
down_revision = "059_duplicate_rate"
branch_labels = None
depends_on = None
def upgrade() -> None:
dialect = op.get_bind().dialect.name
if dialect == "postgresql":
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN json_build_object('material_type', s.material_type)::jsonb "
"ELSE '{}'::jsonb END"
)
empty_json = "'{}'::jsonb"
else:
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN JSON_OBJECT('material_type', s.material_type) "
"ELSE '{}' END"
)
empty_json = "'{}'"
sql_str = (
"INSERT INTO template_clip_configs "
'(id, template_id, clip_type, "order", min_duration, max_duration, '
"text_template, material_requirements, transition_effect, config, "
"created_at, updated_at) "
"SELECT "
"s.id, s.template_id, 'main', s.segment_order, "
"s.duration_min, s.duration_max, "
"'', " + empty_json + ", "
"'cut', " + config_expr + ", "
"s.created_at, s.updated_at "
"FROM template_segments s "
"WHERE NOT EXISTS ("
" SELECT 1 FROM template_clip_configs c "
" WHERE c.template_id = s.template_id"
")"
)
op.execute(sa.text(sql_str))
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""add sort_order to template_categories
Revision ID: 061_sort_order
Revises: 060_migrate_segments
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
revision = "061_sort_order"
down_revision = "060_migrate_segments"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"template_categories",
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("template_categories", "sort_order")
@@ -1,28 +0,0 @@
"""re-add edit_plan_id to generation_tasks (align staging with production)
Revision ID: 062_edit_plan_id
Revises: 061_sort_order
Create Date: 2026-09-02
"""
import sqlalchemy as sa
from alembic import op
revision = "062_edit_plan_id"
down_revision = "061_sort_order"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("edit_plan_id", sa.String(36), nullable=True),
)
op.create_index("ix_generation_tasks_edit_plan_id_2", "generation_tasks", ["edit_plan_id"])
def downgrade() -> None:
op.drop_index("ix_generation_tasks_edit_plan_id_2", table_name="generation_tasks")
op.drop_column("generation_tasks", "edit_plan_id")
@@ -1,46 +0,0 @@
"""add video_fingerprint_chunks table for per-chunk fingerprint storage
Revision ID: 063_fingerprint_chunks
Revises: 062_edit_plan_id
Create Date: 2026-09-03
"""
import sqlalchemy as sa
from alembic import op
revision = "063_fingerprint_chunks"
down_revision = "062_edit_plan_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"video_fingerprint_chunks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("video_id", sa.String(36), nullable=False),
sa.Column("project_id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False, server_default=""),
sa.Column("start_time_ms", sa.Integer, nullable=False),
sa.Column("end_time_ms", sa.Integer, nullable=False),
sa.Column("phash_binary", sa.String(16), nullable=False),
sa.Column("color_histogram", sa.JSON, nullable=False),
sa.Column("frame_count", sa.Integer, nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_vfc_video_id", "video_fingerprint_chunks", ["video_id"])
op.create_index("ix_vfc_project_id", "video_fingerprint_chunks", ["project_id"])
op.create_index("ix_vfc_user_id", "video_fingerprint_chunks", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_vfc_user_id", table_name="video_fingerprint_chunks")
op.drop_index("ix_vfc_project_id", table_name="video_fingerprint_chunks")
op.drop_index("ix_vfc_video_id", table_name="video_fingerprint_chunks")
op.drop_table("video_fingerprint_chunks")
@@ -1,25 +0,0 @@
"""add match_count and visual_similarity to generated_videos
Revision ID: 064_match_count_visual_sim
Revises: 063_fingerprint_chunks
Create Date: 2026-09-03
"""
import sqlalchemy as sa
from alembic import op
revision = "064_match_count_visual_sim"
down_revision = "063_fingerprint_chunks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("match_count", sa.Integer(), nullable=True, server_default="0"))
op.add_column("generated_videos", sa.Column("visual_similarity", sa.Float(), nullable=True, server_default="0.0"))
def downgrade() -> None:
op.drop_column("generated_videos", "visual_similarity")
op.drop_column("generated_videos", "match_count")
@@ -1,25 +0,0 @@
"""add visual_similarity and match_count to duplication_records
Revision ID: 065_dup_record_sim_match
Revises: 064_match_count_visual_sim
Create Date: 2026-09-04
"""
import sqlalchemy as sa
from alembic import op
revision = "065_dup_record_sim_match"
down_revision = "064_match_count_visual_sim"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("duplication_records", sa.Column("visual_similarity", sa.Float(), nullable=True))
op.add_column("duplication_records", sa.Column("match_count", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("duplication_records", "match_count")
op.drop_column("duplication_records", "visual_similarity")
@@ -1,34 +0,0 @@
"""add client_upload_id to assets and asset_id to ingest_jobs
Issue #1714:上传 complete 幂等 + worker 转码回写关联。
- assets.client_upload_id:客户端幂等 tokencomplete 去重)
- ingest_jobs.asset_idcomplete 阶段创建的占位 asset idworker 回写关联,
防止 HEVC 转码改写 storage_key 后找不到占位而兜底新建 READY 记录)
Revision ID: 066_upload_idempotency
Revises: 065_dup_record_sim_match
Create Date: 2026-09-05
"""
import sqlalchemy as sa
from alembic import op
revision = "066_upload_idempotency"
down_revision = "065_dup_record_sim_match"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("assets", sa.Column("client_upload_id", sa.String(64), nullable=True))
op.create_index("ix_assets_client_upload_id", "assets", ["client_upload_id"])
op.add_column("ingest_jobs", sa.Column("asset_id", sa.String(36), nullable=False, server_default=""))
op.create_index("ix_ingest_jobs_asset_id", "ingest_jobs", ["asset_id"])
def downgrade() -> None:
op.drop_index("ix_ingest_jobs_asset_id", table_name="ingest_jobs")
op.drop_column("ingest_jobs", "asset_id")
op.drop_index("ix_assets_client_upload_id", table_name="assets")
op.drop_column("assets", "client_upload_id")
@@ -1,35 +0,0 @@
"""add celery_task_id to generation_tasks and ingest_jobs
Issue #1714:孤儿恢复/超时清理撤销队列消息。
- generation_tasks.celery_task_id:入队时记录的 Celery 消息 ID,清理时 revoke
- ingest_jobs.celery_task_id:同上(素材转码任务)
Revision ID: 067_celery_task_id
Revises: 066_upload_idempotency
Create Date: 2026-09-05
"""
import sqlalchemy as sa
from alembic import op
revision = "067_celery_task_id"
down_revision = "066_upload_idempotency"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
)
op.add_column(
"ingest_jobs",
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("ingest_jobs", "celery_task_id")
op.drop_column("generation_tasks", "celery_task_id")
@@ -1,26 +0,0 @@
"""add profile_completed to users
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
微信新建用户在应用层置 False。
"""
import sqlalchemy as sa
from alembic import op
revision = "068_user_profile_completed"
down_revision = "067_celery_task_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
def downgrade() -> None:
op.drop_column("users", "profile_completed")
@@ -1,72 +0,0 @@
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
Revision ID: 069_project_is_default
Revises: 068_user_profile_completed
Create Date: 2026-09-08
背景:
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
改动:
1. projects 表新增 is_default 布尔列(默认 false
2. 部分唯一索引 uq_projects_owner_default(owner_user_id) WHERE is_default = true
—— 保证每个用户至多一个默认项目
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
(只标记不删除;存量重复项目的清理另行确认后单独执行)
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
"""
import sqlalchemy as sa
from alembic import op
revision = "069_project_is_default"
down_revision = "068_user_profile_completed"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. 新增 is_default 列
op.add_column(
"projects",
sa.Column(
"is_default",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
op.execute("""
UPDATE projects p
SET is_default = true
WHERE p.id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY owner_user_id
ORDER BY created_at ASC, id ASC
) AS rn
FROM projects
WHERE name = '默认项目'
) t
WHERE t.rn = 1
)
""")
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
op.execute("""
CREATE UNIQUE INDEX uq_projects_owner_default
ON projects (owner_user_id)
WHERE is_default = true
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
op.drop_column("projects", "is_default")
-48
View File
@@ -1,48 +0,0 @@
"""Add scripts table for oral broadcast script library (Issue #1795)
Revision ID: 070_add_scripts
Revises: 069_project_is_default
Create Date: 2026-09-08
新建 scripts 表,支持口播文案 CRUD + 分段存储。
"""
import sqlalchemy as sa
from alembic import op
revision = "070_add_scripts"
down_revision = "069_project_is_default"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"scripts",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("segments", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"),
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(),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_scripts_user_id", "scripts", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_scripts_user_id", table_name="scripts")
op.drop_table("scripts")
@@ -1,47 +0,0 @@
"""add lipsync jobs table
Revision ID: 071_add_lipsync_jobs
Revises: 070_add_scripts
Create Date: 2026-09-08
"""
import sqlalchemy as sa
from alembic import op
revision = "071_add_lipsync_jobs"
down_revision = "070_add_scripts"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"lipsync_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# 复合索引:用户 + 状态(列表查询常用)
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
# 项目 + 用户(项目维度查询)
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
def downgrade() -> None:
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
op.drop_table("lipsync_jobs")
@@ -1,48 +0,0 @@
"""add ai avatar render jobs table
Revision ID: 072_add_ai_avatar_render
Revises: 071_add_lipsync_jobs
Create Date: 2026-09-09
"""
import sqlalchemy as sa
from alembic import op
revision = "072_add_ai_avatar_render"
down_revision = "071_add_lipsync_jobs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ai_avatar_render_jobs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
sa.Column("lipsync_job_id", sa.String(36), nullable=False),
sa.Column("script_id", sa.String(36), nullable=False),
sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""),
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"])
op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"])
def downgrade() -> None:
op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs")
op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs")
op.drop_table("ai_avatar_render_jobs")
@@ -1,45 +0,0 @@
"""lipsync_jobs 增加 TTS 直生字段(voice_id/script_text/speed/emotion
Revision ID: 073_add_lipsync_tts_fields
Revises: 072_add_ai_avatar_render
Create Date: 2026-09-09
"""
import sqlalchemy as sa
from alembic import op
revision = "073_add_lipsync_tts_fields"
down_revision = "072_add_ai_avatar_render"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 对口型支持「传音色 + 文案直接生成」:后端内部先 TTS 合成音频再提交对口型
op.add_column(
"lipsync_jobs",
sa.Column("voice_id", sa.String(200), nullable=False, server_default=""),
)
op.add_column(
"lipsync_jobs",
sa.Column("script_text", sa.Text(), nullable=False, server_default=""),
)
op.add_column(
"lipsync_jobs",
sa.Column("speed", sa.Float(), nullable=False, server_default=sa.text("1.0")),
)
op.add_column(
"lipsync_jobs",
sa.Column("emotion", sa.String(20), nullable=False, server_default=""),
)
# audio_url 改为可空:直生模式下音频由后端 TTS 合成后回填
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=True)
def downgrade() -> None:
op.alter_column("lipsync_jobs", "audio_url", existing_type=sa.Text(), nullable=False)
op.drop_column("lipsync_jobs", "emotion")
op.drop_column("lipsync_jobs", "speed")
op.drop_column("lipsync_jobs", "script_text")
op.drop_column("lipsync_jobs", "voice_id")
@@ -1,36 +0,0 @@
"""ai_avatar_render_jobs.script_id 放宽为可空串(手动文案直生场景不关联文案库)
Revision ID: 074_render_script_id_optional
Revises: 073_add_lipsync_tts_fields
Create Date: 2026-09-09
"""
import sqlalchemy as sa
from alembic import op
revision = "074_render_script_id_optional"
down_revision = "073_add_lipsync_tts_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 列保持 NOT NULL(空串占位),仅应用层允许不传;这里显式补 server_default 防止历史约束歧义
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
batch.alter_column(
"script_id",
existing_type=sa.String(length=36),
nullable=False,
server_default="",
)
def downgrade() -> None:
with op.batch_alter_table("ai_avatar_render_jobs") as batch:
batch.alter_column(
"script_id",
existing_type=sa.String(length=36),
nullable=False,
server_default=None,
)
@@ -1,27 +0,0 @@
"""add sentence_timings to lipsync_jobs
Revision ID: 075_add_sentence_timings
Revises: 074_ai_avatar_render_script_id_optional
Create Date: 2026-09-12
"""
import sqlalchemy as sa
from alembic import op
revision = "075_add_sentence_timings"
down_revision = "074_render_script_id_optional"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.add_column(
sa.Column("sentence_timings", sa.JSON(), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("lipsync_jobs") as batch:
batch.drop_column("sentence_timings")
-133
View File
@@ -1,133 +0,0 @@
"""add membership & points system
Revision ID: 076_membership_points
Revises: 075_add_sentence_timings
Create Date: 2026-09-15
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "076_membership_points"
down_revision = "075_add_sentence_timings"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. users 表新增字段
with op.batch_alter_table("users") as batch:
batch.add_column(
sa.Column("is_member", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
batch.add_column(
sa.Column("member_type", sa.String(20), nullable=True),
)
batch.add_column(
sa.Column("member_expires_at", sa.DateTime(), nullable=True),
)
batch.add_column(
sa.Column("points_balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
)
# 2. points_accounts 积分账户表
op.create_table(
"points_accounts",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, unique=True, index=True),
sa.Column("balance", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_earned", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_spent", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 3. points_transactions 积分流水表
op.create_table(
"points_transactions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("account_id", sa.String(36), nullable=False, index=True),
sa.Column("type", sa.String(20), nullable=False, index=True),
sa.Column("source", sa.String(50), nullable=False, index=True),
sa.Column("amount", sa.Integer(), nullable=False),
sa.Column("balance_after", sa.Integer(), nullable=False),
sa.Column("description", sa.String(255), nullable=False, server_default=""),
sa.Column("ref_id", sa.String(100), nullable=False, server_default=""),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 4. points_orders 积分/会员订单表
op.create_table(
"points_orders",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("order_type", sa.String(20), nullable=False),
sa.Column("product_code", sa.String(50), nullable=False),
sa.Column("amount_cents", sa.Integer(), nullable=False),
sa.Column("original_amount_cents", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("discount", sa.Float(), nullable=False, server_default=sa.text("1.0")),
sa.Column("points_amount", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("payment_method", sa.String(50), nullable=True),
sa.Column("payment_id", sa.String(100), nullable=True),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 5. daily_usage_records 每日使用记录表
op.create_table(
"daily_usage_records",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False, index=True),
sa.Column("usage_date", sa.DateTime(), nullable=False),
sa.Column("usage_type", sa.String(50), nullable=False, server_default="free_clip"),
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.UniqueConstraint(
"user_id",
"usage_date",
"usage_type",
name="uq_daily_usage_user_date_type",
),
)
def downgrade() -> None:
op.drop_table("daily_usage_records")
op.drop_table("points_orders")
op.drop_table("points_transactions")
op.drop_table("points_accounts")
with op.batch_alter_table("users") as batch:
batch.drop_column("points_balance")
batch.drop_column("member_expires_at")
batch.drop_column("member_type")
batch.drop_column("is_member")
-67
View File
@@ -1,67 +0,0 @@
"""#1894: merge title_libraries into scripts — add title_text/title_category/title_config
Revision ID: 077_merge_title_libs
Revises: 076_membership_points
Create Date: 2026-09-15
"""
import sqlalchemy as sa
from alembic import context, op
revision = "077_merge_title_libs"
down_revision = "076_membership_points"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.add_column(
sa.Column("title_text", sa.String(500), nullable=False, server_default=""),
)
batch.add_column(
sa.Column("title_category", sa.String(50), nullable=False, server_default=""),
)
batch.add_column(
sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"),
)
if context.get_context().dialect.name == "postgresql":
conn = op.get_bind()
result = conn.execute(sa.text("SELECT to_regclass('public.title_libraries')"))
if result.scalar() is not None:
conn.execute(sa.text("""
INSERT INTO scripts
(id, user_id, title, content, segments, tags,
title_text, title_category, title_config,
created_at, updated_at)
SELECT
gen_random_uuid()::TEXT,
tl.user_id,
COALESCE(tl.name, '迁移标题'),
COALESCE(tl.text, ''),
'[]'::JSONB,
COALESCE(tl.tags, '[]'::JSONB),
COALESCE(tl.text, ''),
COALESCE(tl.category, ''),
COALESCE(tl."metadata", '{}'::JSONB),
tl.created_at,
tl.updated_at
FROM title_libraries tl
WHERE tl.is_active = true
AND NOT EXISTS (
SELECT 1 FROM scripts s
WHERE s.user_id = tl.user_id
AND s.title_text = COALESCE(tl.text, '')
AND s.title_category = COALESCE(tl.category, '')
AND s.created_at = tl.created_at
)
"""))
def downgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.drop_column("title_config")
batch.drop_column("title_category")
batch.drop_column("title_text")
@@ -1,33 +0,0 @@
"""#1894: drop obsolete script title fields (title_text/title_category/title_config)
Revision ID: 078_drop_script_title_fields
Revises: 077_merge_title_libs
Create Date: 2026-09-16
口播文案(scripts)不再自带配套标题、标题分类和标题样式字段。
智能剪辑 / AI 数字人等生成场景各自通过入参配置标题,不再从文案读取。
保留字段:title(名称)、content(正文)、segments(分段)、tags(标签)。
"""
import sqlalchemy as sa
from alembic import op
revision = "078_drop_script_title_fields"
down_revision = "077_merge_title_libs"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.drop_column("title_config")
batch.drop_column("title_category")
batch.drop_column("title_text")
def downgrade() -> None:
with op.batch_alter_table("scripts") as batch:
batch.add_column(sa.Column("title_text", sa.String(500), nullable=False, server_default=""))
batch.add_column(sa.Column("title_category", sa.String(50), nullable=False, server_default=""))
batch.add_column(sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"))
-58
View File
@@ -1,58 +0,0 @@
"""add asset_atom_clips table
Revision ID: 079_asset_atom_clips
Revises: 078_drop_script_title_fields
Create Date: 2026-09-17
"""
import sqlalchemy as sa
from alembic import op
revision = "079_asset_atom_clips"
down_revision = "078_drop_script_title_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"asset_atom_clips",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"asset_id",
sa.String(36),
sa.ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("start_time", sa.Float(), nullable=False),
sa.Column("end_time", sa.Float(), nullable=False),
sa.Column("duration", sa.Float(), nullable=False),
sa.Column("clip_index", sa.Integer(), nullable=False),
sa.Column("tags", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
sa.Column("scene_change_at", sa.Float(), nullable=True),
sa.Column(
"is_fallback",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
)
# 按素材查片段并按索引排序(复合索引前缀可独立用于 asset_id 过滤)
op.create_index(
"ix_asset_atom_clips_asset_index",
"asset_atom_clips",
["asset_id", "clip_index"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_asset_atom_clips_asset_index", table_name="asset_atom_clips")
op.drop_table("asset_atom_clips")
@@ -1,37 +0,0 @@
"""add edit_plan_clips.atom_clip_id for #1970
Revision ID: 080_edit_plan_clips_atom_clip_id
Revises: 079_asset_atom_clips
Create Date: 2026-09-17
"""
import sqlalchemy as sa
from alembic import op
revision = "080_edit_plan_clips_atom_clip_id"
down_revision = "079_asset_atom_clips"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column(
"atom_clip_id",
sa.String(36),
nullable=False,
server_default=sa.text("''"),
),
)
op.create_index(
"ix_edit_plan_clips_atom_clip_id",
"edit_plan_clips",
["atom_clip_id"],
)
def downgrade() -> None:
op.drop_index("ix_edit_plan_clips_atom_clip_id", table_name="edit_plan_clips")
op.drop_column("edit_plan_clips", "atom_clip_id")
@@ -1,58 +0,0 @@
"""add gpu_lipsync_tasks and gpu_workers tables for MuseTalk reverse-poll worker
Revision ID: 081_add_gpu_lipsync
Revises: 080_edit_plan_clips_atom_clip_id
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "081_add_gpu_lipsync"
down_revision = "080_edit_plan_clips_atom_clip_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
# GPU Worker 注册表
op.create_table(
"gpu_workers",
sa.Column("worker_id", sa.String(100), primary_key=True),
sa.Column("hostname", sa.String(200), nullable=False, server_default=""),
sa.Column("gpu_name", sa.String(200), nullable=False, server_default=""),
sa.Column("free_vram_mb", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("capabilities", sa.String(500), nullable=False, server_default=""),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True, index=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# GPU 口型同步任务表
op.create_table(
"gpu_lipsync_tasks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("lipsync_job_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("user_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("project_id", sa.String(36), nullable=False, server_default="", index=True),
sa.Column("video_url", sa.Text(), nullable=False),
sa.Column("audio_url", sa.Text(), nullable=False),
sa.Column("result_url", sa.Text(), nullable=False, server_default=""),
sa.Column("result_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("worker_id", sa.String(100), nullable=False, server_default="", index=True),
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("error_msg", sa.Text(), nullable=False, server_default=""),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_gpu_lipsync_status_created", "gpu_lipsync_tasks", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_gpu_lipsync_status_created", table_name="gpu_lipsync_tasks")
op.drop_table("gpu_lipsync_tasks")
op.drop_table("gpu_workers")
-26
View File
@@ -1,26 +0,0 @@
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
Revision ID: 082_atom_clip_ai_tags
Revises: 081_add_gpu_lipsync
Create Date: 2026-09-18
"""
import sqlalchemy as sa
from alembic import op
revision = "082_atom_clip_ai_tags"
down_revision = "081_add_gpu_lipsync"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"asset_atom_clips",
sa.Column("ai_tags", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("asset_atom_clips", "ai_tags")
BIN
View File
Binary file not shown.
View File
View File
+4 -84
View File
@@ -1,34 +1,21 @@
from app.api.routes.ai import router as ai_router
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
from app.api.routes.asset_libraries import router as asset_libraries_router
from app.api.routes.assets import router as assets_router
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.classification_jobs import router as classification_jobs_router
from app.api.routes.clips_standalone import router as clips_standalone_router
from app.api.routes.cover_templates import router as cover_templates_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.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.generation_variant_plans import router as generation_variant_plans_router
from app.api.routes.gpu_lipsync import router as gpu_lipsync_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.internal_render import router as internal_render_router
from app.api.routes.lipsync import router as lipsync_router
from app.api.routes.points import points_router, usage_router
from app.api.routes.projects import router as projects_router
from app.api.routes.scripts import router as scripts_router
from app.api.routes.scripts_ai import router as scripts_ai_router
from app.api.routes.share import router as share_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
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.tts import router as tts_router
from app.api.routes.upload import router as upload_router
@@ -45,11 +32,6 @@ api_router.include_router(
auth_router,
tags=["Auth"],
)
api_router.include_router(
lipsync_router,
prefix="/lipsync",
tags=["Lipsync"],
)
api_router.include_router(
projects_router,
prefix="/projects",
@@ -60,10 +42,6 @@ api_router.include_router(
prefix="/tags",
tags=["Tag"],
)
api_router.include_router(
cover_templates_router,
tags=["CoverTemplate"],
)
api_router.include_router(
task_center_router,
tags=["TaskCenter"],
@@ -107,21 +85,6 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_preview_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_variant_plans_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
titles_router,
prefix="/titles",
@@ -141,10 +104,6 @@ api_router.include_router(
videos_router,
tags=["VideoCenter"],
)
api_router.include_router(
share_router,
tags=["Share"],
)
api_router.include_router(
duplication_router,
prefix="/duplication",
@@ -161,24 +120,15 @@ api_router.include_router(
tags=["Template"],
)
api_router.include_router(
clips_standalone_router,
tags=["Clips"],
)
api_router.include_router(
templates_editor_router,
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
edit_plans_router,
prefix="/edit-plans",
tags=["EditPlan"],
)
api_router.include_router(
tts_router,
prefix="/tts",
tags=["TTS"],
)
api_router.include_router(
ai_router,
prefix="/ai",
tags=["AI"],
)
api_router.include_router(
feature_flags_router,
tags=["Internal"],
@@ -187,33 +137,3 @@ api_router.include_router(
internal_render_router,
tags=["Internal"],
)
api_router.include_router(
scripts_router,
prefix="/scripts",
tags=["ScriptLibrary"],
)
api_router.include_router(
scripts_ai_router,
prefix="/scripts",
tags=["ScriptLibrary AI"],
)
api_router.include_router(
ai_avatar_render_router,
prefix="/ai-avatar/render",
tags=["AI Avatar Render"],
)
api_router.include_router(
points_router,
prefix="/points",
tags=["Points"],
)
api_router.include_router(
usage_router,
prefix="/usage",
tags=["Usage"],
)
api_router.include_router(
gpu_lipsync_router,
prefix="/gpu",
tags=["GPU Worker"],
)
@@ -1,91 +0,0 @@
"""默认模板兜底共享逻辑(P0 #1922).
提供 get_or_create_default_template_id(db, user_id) 共享函数,
供 templates.py 列表查询、clips_standalone.py 独立端点、dependencies.py
resolve_draft_plan_id 三处复用,避免三处各写一套兜底逻辑产生分叉。
根因:PR#1918 清理模板管理 API 时误删了 GET /templates 自动创建默认模板
兜底,前端 PR#1913 去掉空 tid 拦截后首次进入生成页拼出
/templates//editor/clips/from-assets(双斜杠)→ FastAPI 404,阻断新用户首次
生成。
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]:
"""获取或自动创建默认配音模板的 id。
判定逻辑(不做异常降级,只有确实创建失败时才回滚重查):
1. 查用户名下 is_active=True 且有 TemplateClipConfig 的模板 → 返回其 id
2. 无则调用 CreateTemplateUseCase 创建一条默认 voice_over 模板;
3. 创建异常时 rollback 再重查一次(防并发唯一键冲突),重查仍无返回 None。
"""
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
TemplateModel,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.application.template.commands import (
CreateTemplateCommand,
SegmentCommand,
)
from packages.application.template.use_cases import CreateTemplateUseCase
existing = (
db.query(TemplateModel)
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
.order_by(TemplateModel.created_at.asc())
.first()
)
if existing is not None:
has_seg = (
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
)
if has_seg:
return existing.id
try:
repo = SQLAlchemyTemplateRepository(db)
cmd = CreateTemplateCommand(
user_id=user_id,
name="默认配音模板",
mode="voice_over",
category="default",
tags=[],
title_config={},
subtitle_config={},
bgm_config={},
estimated_duration=0.0,
segments=[SegmentCommand(segment_order=0, duration_min=1.0, duration_max=30.0)],
)
tpl = CreateTemplateUseCase(repo).execute(cmd)
db.commit()
logger.info("auto-created default voice_over template: id=%s user=%s", tpl.id, user_id)
return tpl.id
except Exception:
db.rollback()
# 重查:可能并发请求已建好
existing = (
db.query(TemplateModel)
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
.order_by(TemplateModel.created_at.asc())
.first()
)
if existing is not None:
has_seg = (
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
)
if has_seg:
return existing.id
logger.exception("failed to auto-create default template user=%s", user_id)
return None
+22 -36
View File
@@ -1,6 +1,5 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
@@ -25,27 +24,12 @@ def check_project_access(project_id: str, user_id: str, project_repository) -> N
raise HTTPException(status_code=403, detail="无权访问该项目")
_LEGACY_PLANS = {"standard", "pro", "enterprise", "basic", "premium"}
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
"""获取用户的会员类型,兼容旧档位值。
旧档位 standard/pro/enterprise/basic/premium 统一映射到当前体系:
- standard/basic → monthly
- pro/premium/enterprise → quarterly
"""
"""获取用户的订阅计划名称。"""
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
plan = getattr(user, "subscription_plan", "free") or "free"
if plan in {"standard", "basic"}:
return "monthly"
if plan in {"pro", "premium", "enterprise"}:
return "quarterly"
if plan not in {"free", "monthly", "quarterly", "yearly"}:
return "free"
return plan
return getattr(user, "subscription_plan", "free") or "free"
def require_project_and_library(
@@ -82,6 +66,9 @@ def auto_select_video_assets(
Returns:
选中的素材 ID 列表,无可用素材时返回空列表
"""
# 明确不支持的视频编码(会导致渲染失败)
UNSUPPORTED_CODECS = {"hevc", "h265", "hev1", "hvc1", "vp9", "vp09", "av1", "av01"}
if not project_id:
return []
@@ -109,12 +96,27 @@ def auto_select_video_assets(
and a.mime_type.startswith("video")
]
# 过滤不支持的编码格式(HEVC/VP9/AV1 等会导致渲染失败)
# 优先读 asset.codec 字段,其次从 metadata 里取(兼容存量数据)
codec_filtered = []
skipped_codec = 0
for a in ready_videos:
codec = (a.codec or "").lower()
if not codec and a.metadata and isinstance(a.metadata, dict):
codec = str(a.metadata.get("codec", "")).lower()
if codec and codec in UNSUPPORTED_CODECS:
skipped_codec += 1
continue
codec_filtered.append(a)
if skipped_codec and logger:
logger.warning("自动选素材: 跳过 %d 个不支持编码的素材", skipped_codec)
# 过滤横屏素材(只保留竖屏/正方形)
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
# 注意:这只是选素材优化,渲染引擎本身支持任何分辨率的素材
filtered_videos = []
skipped_landscape = 0
for a in ready_videos:
for a in codec_filtered:
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:
@@ -138,19 +140,3 @@ def auto_select_video_assets(
# 按创建时间降序(新素材在前)
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(UTC).isoformat().replace("+00:00", "Z")
-136
View File
@@ -1,136 +0,0 @@
"""AI 相关接口 — 智能标题、智能素材匹配等.
基于豆包大模型的 AI 能力接口,未配置 API Key 时自动降级为本地模拟。
"""
from __future__ import annotations
from typing import 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"],
)
-328
View File
@@ -1,328 +0,0 @@
"""AI数字人渲染合成 API 路由 — #1798.
接口:
POST /api/v1/ai-avatar/render 提交渲染任务
GET /api/v1/ai-avatar/render/jobs 任务列表
GET /api/v1/ai-avatar/render/{job_id} 任务详情
POST /api/v1/ai-avatar/render/{job_id}/cancel 取消任务
POST /api/v1/ai-avatar/render/{job_id}/retry 重试失败任务
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.ai_avatar_render import (
AiAvatarRenderJobResponse,
CreateAiAvatarRenderRequest,
FinalizeRenderResponse,
SmartCoverResponse,
)
from app.services.ai_avatar_cover_service import generate_smart_cover
from app.services.ai_avatar_render_service import (
AiAvatarRenderError,
AiAvatarRenderService,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from packages.middleware.points_gate import points_gate
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService:
return AiAvatarRenderService(db)
# ── POST / — 提交渲染任务 ────────────────────────────────────────────────
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
@points_gate("ai_digital_human", per_unit=15)
def create_render_job(
body: CreateAiAvatarRenderRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
db: Session = Depends(get_db_session),
):
"""提交 AI 数字人渲染任务.
将对口型视频 + B-roll 素材 + 标题叠加 + 封面提取合成最终输出视频。
"""
try:
job = svc.create_render_job(
user_id=current_user.user.id,
lipsync_job_id=body.lipsync_job_id,
script_id=body.script_id,
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
title_config=body.title_config,
cover_config=body.cover_config,
project_id=body.project_id,
)
except AiAvatarRenderError as exc:
status_map = {
"LipsyncJobNotFound": 404,
"LipsyncJobNotCompleted": 400,
"LipsyncJobNoOutput": 400,
"ScriptNotFound": 404,
}
raise HTTPException(
status_code=status_map.get(exc.code, 400),
detail={"code": exc.code, "message": str(exc)},
) from exc
# 异步触发渲染
try:
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id)
except Exception as exc:
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
job.status = "failed"
job.error_message = f"任务提交失败:{exc}"
job.updated_at = datetime.now(UTC)
svc.db.commit()
svc.db.refresh(job)
return AiAvatarRenderJobResponse.model_validate(job)
return AiAvatarRenderJobResponse.model_validate(job)
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
@router.get("/jobs", response_model=dict)
def list_render_jobs(
project_id: str = Query("", description="项目 ID 过滤"),
status: str = Query("", description="状态过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""获取 AI 数字人渲染任务列表."""
items, total = svc.list_render_jobs(
user_id=current_user.user.id,
project_id=project_id,
status=status,
offset=offset,
limit=limit,
)
return {
"items": [AiAvatarRenderJobResponse.model_validate(j) for j in items],
"total": total,
"offset": offset,
"limit": limit,
}
# ── GET /{job_id} — 任务详情 ─────────────────────────────────────────────
@router.get("/{job_id}", response_model=AiAvatarRenderJobResponse)
def get_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""获取渲染任务详情."""
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
return job
# ── POST /{job_id}/cancel — 取消任务 ─────────────────────────────────────
@router.post("/{job_id}/cancel", response_model=AiAvatarRenderJobResponse)
def cancel_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""取消渲染任务(仅 pending 状态可取消)."""
job = svc.cancel_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "cancelled":
raise HTTPException(
status_code=400,
detail=f"任务状态 {job.status} 不可取消,仅 pending 可取消",
)
return job
# ── POST /{job_id}/retry — 重试失败任务 ──────────────────────────────────
@router.post("/{job_id}/retry", response_model=AiAvatarRenderJobResponse)
def retry_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
svc: AiAvatarRenderService = Depends(_get_service),
):
"""重试失败的渲染任务."""
job = svc.retry_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "pending":
raise HTTPException(
status_code=400,
detail=f"仅 failed 状态的任务可重试,当前状态: {job.status}",
)
# 重新触发渲染
try:
from app.tasks.ai_avatar_render import execute_ai_avatar_render
execute_ai_avatar_render.delay(job.id)
except Exception as exc:
logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc)
job.status = "failed"
job.error_message = f"任务提交失败:{exc}"
job.updated_at = datetime.now(UTC)
svc.db.commit()
svc.db.refresh(job)
return AiAvatarRenderJobResponse.model_validate(job)
return AiAvatarRenderJobResponse.model_validate(job)
# ── POST /{job_id}/smart-cover — 从最终成片智能抽封面(步骤②)────────
@router.post("/{job_id}/smart-cover", response_model=SmartCoverResponse)
def generate_render_smart_cover(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""从最终渲染成片智能抽帧生成封面(MediaKit 抽帧 + 评分选最佳帧 + 转存 OSS).
- 必须等渲染任务 completed 后才可调用(否则返回 400)
- 生成成功后自动更新 render_job 的 cover_config 与 output_cover_url
"""
from app.services.ai_avatar_render_service import AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
video_url = (job.output_video_url or "").strip()
if not video_url:
raise HTTPException(status_code=400, detail="渲染成片视频 URL 为空")
try:
# 从最终成片抽帧,帧本身已含标题/B-roll,直接转存 OSS
cover_url = generate_smart_cover(video_url, job_id=job_id, max_frames=5)
except Exception as exc:
logger.error(
"渲染成片智能封面生成异常: user=%s render_id=%s video_url=%s err=%s",
current_user.user.id,
job_id,
video_url[:80],
exc,
exc_info=True,
)
cover_url = ""
if not cover_url:
return SmartCoverResponse(
cover_url="",
status="fallback_failed",
message="智能抽帧失败(MediaKit 不可用或抽帧异常),请稍后重试",
)
# 更新 render_job 的封面字段(异步写入 DB;失败不影响返回)
try:
job.cover_config = {
**(job.cover_config if isinstance(job.cover_config, dict) else {}),
"mode": "auto_frame",
"url": cover_url,
}
job.output_cover_url = cover_url
job.updated_at = datetime.now(UTC)
db.commit()
except Exception as exc:
logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc)
logger.info(
"渲染成片智能封面生成成功: user=%s render_id=%s cover_url=%s",
current_user.user.id,
job_id,
cover_url[:120],
)
return SmartCoverResponse(cover_url=cover_url, status="completed")
# ── POST /{job_id}/finalize — 封面选定后正式入库成片库 ────────────────────
@router.post("/{job_id}/finalize", response_model=FinalizeRenderResponse)
def finalize_render_job(
job_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
):
"""用户完成封面选择后,将视频正式保存到成片库.
- 必须等渲染任务 completed 后才可调用
- 如果已通过 smart-cover/custom-cover 设置了封面,会自动带上
- 返回成片库视频ID
- 幂等:已 finalize 的任务重复调用会返回 existing 记录
"""
from app.services.ai_avatar_render_service import AiAvatarRenderError, AiAvatarRenderService
svc = AiAvatarRenderService(db)
job = svc.get_render_job(job_id, current_user.user.id)
if job is None:
raise HTTPException(status_code=404, detail="渲染任务不存在")
if job.status != "completed":
raise HTTPException(status_code=400, detail="请先完成视频生成")
# 幂等检查(通过 generation_task_id=job_id 识别,finalize_job 内部也做了一次,这里提前返回简化)
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
existing = (
db.query(GeneratedVideoModel)
.filter(
GeneratedVideoModel.user_id == current_user.user.id,
GeneratedVideoModel.generation_task_id == job_id,
)
.first()
)
if existing is not None:
return FinalizeRenderResponse(
video_id=existing.id,
cover_url=existing.thumbnail_url or "",
status="already_finalized",
)
try:
video = svc.finalize_job(job_id, current_user.user.id)
return FinalizeRenderResponse(
video_id=video.id,
cover_url=video.thumbnail_url or job.output_cover_url or "",
status="success",
)
except AiAvatarRenderError as exc:
status_map = {
"RenderJobNotFound": 404,
"RenderNotCompleted": 400,
"OutputVideoMissing": 400,
}
raise HTTPException(
status_code=status_map.get(exc.code, 400),
detail=str(exc),
) from exc
except Exception as exc:
logger.error("渲染任务finalize失败: job_id=%s err=%s", job_id, exc, exc_info=True)
raise HTTPException(status_code=500, detail=f"保存到成片库失败: {str(exc)}") from exc
+24 -5
View File
@@ -20,7 +20,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibraryKind
from packages.domain import AssetLibrary, AssetLibraryKind
from ._helpers import check_project_access
@@ -120,11 +120,30 @@ def ensure_default_library(
kind = AssetLibraryKind(request.kind)
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
# 查找该项目下同 kind 的素材库,返回第一个
existing = asset_library_repository.find_by_project(request.project_id)
for lib in existing:
if lib.kind == kind:
return _to_asset_library_response(lib)
# 不存在 → 自动创建
import uuid
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
return _to_asset_library_response(library)
library = AssetLibrary(
id=str(uuid.uuid4()),
project_id=request.project_id,
name=default_name,
kind=kind,
asset_count=0,
total_size=0,
created_at=now,
updated_at=now,
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
+82 -265
View File
@@ -1,7 +1,7 @@
import logging
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.core.storage import get_storage_service
from app.dependencies import (
@@ -14,45 +14,28 @@ from app.schemas.asset import (
AssetResponse,
BatchClassifyRequest,
BatchDeleteRequest,
BatchGetRequest,
BatchMarkRequest,
BatchOperationResponse,
BatchTagRequest,
CreateAssetRequest,
ListAssetsResponse,
SmartMatchItem,
SmartMatchRequest,
SmartMatchResponse,
UpdateAssetRequest,
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.domain.smart_match import smart_select_assets
from packages.application import (
CreateAssetCommand,
CreateAssetUseCase,
)
from packages.domain import AssetStatus, ClassificationStatus
logger = logging.getLogger(__name__)
router = APIRouter()
def _asset_availability_fields(item) -> dict:
"""视频素材返回余量四字段;非视频/无时长/异常时返回 None + usable=True(零影响)。"""
try:
info = compute_asset_availability(item)
except Exception:
logger.warning("计算素材余量失败,按可用处理: asset_id=%s", getattr(item, "id", "?"), exc_info=True)
info = None
if info is None:
return {
"used_duration": None,
"available_duration": None,
"used_ratio": None,
"usable": True,
}
return info
def _to_asset_response(item, storage_service=None) -> AssetResponse:
# 生成签名文件 URL(用于视频播放 / 文件下载)
file_url = None
@@ -64,16 +47,10 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
file_url = None
# 缩略图:存储的是 storage_key,需要生成签名 URL 供前端使用
# 不再降级使用视频文件 URL(浏览器 <img> 无法渲染 .mp4,会显示黑屏)
thumbnail_url = None
if item.thumbnail_url:
try:
svc = storage_service or get_storage_service()
thumbnail_url = svc.get_download_url(item.thumbnail_url)
except Exception:
logger.warning("生成缩略图签名URL失败: key=%s", item.thumbnail_url, exc_info=True)
thumbnail_url = None
# 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
thumbnail_url = item.thumbnail_url
if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
thumbnail_url = file_url
return AssetResponse(
id=item.id,
@@ -94,10 +71,8 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
status=item.status.value,
classification_status=item.classification_status.value,
quality_score=item.quality_score,
created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []),
**_asset_availability_fields(item),
)
@@ -119,12 +94,6 @@ def list_assets(
None,
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),
limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -134,26 +103,6 @@ def list_assets(
) -> ListAssetsResponse:
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_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
@@ -222,13 +171,11 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_library_and_file_type(
library_id, ft, skip=skip, limit=limit, status=status_list
)
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list)
total = asset_repository.count_by_project(library.project_id, status=status_list)
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id)
return ListAssetsResponse(
items=[_to_asset_response(item) for item in items],
total=total,
@@ -240,14 +187,14 @@ def list_assets(
if project_id:
check_project_access(project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_project_and_file_type(
project_id, ft, skip=skip, limit=limit, status=status_list
)
total = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list)
paged = items
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
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 = len(items)
paged = items[skip : skip + limit]
else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list)
total = asset_repository.count_by_project(project_id, status=status_list)
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(project_id)
paged = items
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged],
@@ -267,43 +214,22 @@ def list_assets(
if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
if ft:
# 有 kind 过滤:逐项目查 file_type凑够一页
total = 0
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list)
total += proj_total
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project_and_file_type(
pid, ft, skip=offset, limit=remaining, status=status_list
)
paged_items.extend(proj_items)
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 = []
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
total = asset_repository.count_by_project_ids(project_ids)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged_items],
@@ -319,21 +245,12 @@ def list_assets(
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
check_project_access(library.project_id, user_id, project_repository)
if kind:
all_items = asset_repository.find_by_library_and_file_type(
library_id, kind_to_file_type[kind], status=status_list
)
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
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:
check_project_access(project_id, user_id, project_repository)
if kind:
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)
all_items = asset_repository.find_by_project(project_id)
else:
try:
projects = project_repository.find_accessible_projects(user_id)
@@ -342,18 +259,12 @@ def list_assets(
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = []
for proj in projects:
if kind and kind_to_file_type.get(kind):
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))
all_items.extend(asset_repository.find_by_project(proj.id))
# 应用 kind 过滤(如果有)+ keyword/gender/style
if kind:
ft = kind_to_file_type.get(kind)
if ft:
all_items = [i for i in all_items if i.file_type == ft]
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
filtered = _apply_memory_filters(all_items)
total = len(filtered)
paged = filtered[skip : skip + limit]
@@ -390,18 +301,6 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch", response_model=list[AssetResponse])
def batch_get_assets(
request: BatchGetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
) -> list[AssetResponse]:
"""批量获取素材详情(根据 ID 列表)。"""
items = asset_repository.find_by_ids(request.ids)
storage_service = get_storage_service()
return [_to_asset_response(item, storage_service) for item in items]
@router.post("/batch-delete", response_model=BatchOperationResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
@@ -555,119 +454,6 @@ def batch_mark_assets(
)
@router.post("/smart-match", response_model=SmartMatchResponse)
def smart_match_assets(
request: SmartMatchRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
) -> SmartMatchResponse:
"""智能选素材:根据素材库内容,按质量分+时长均衡+新鲜度+未使用偏好综合评分,返回 Top N 素材。"""
library = asset_library_repository.get(request.library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 获取素材库中所有 ready 素材(DB 层按 kind 过滤,避免加载不必要的数据到内存)
# kind → file_type 映射:schema 已校验只允许 video/image/audio,与 file_type 一致
if request.kind:
filtered_assets = asset_repository.find_by_library_and_file_type(
request.library_id, request.kind, status=["ready"], limit=10000
)
else:
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
total_candidates = len(filtered_assets)
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ──────────
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补,
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
usable_assets = []
exhausted_assets = []
for a in filtered_assets:
try:
avail = compute_asset_availability(a)
except Exception:
logger.warning(
"smart-match 余量计算失败,按可用处理: asset_id=%s",
getattr(a, "id", "?"),
exc_info=True,
)
avail = None
if avail is not None and not avail["usable"]:
exhausted_assets.append(a)
else:
usable_assets.append(a)
if exhausted_assets:
logger.info(
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
len(filtered_assets), len(exhausted_assets),
)
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
# (全选同样会选到这些素材,且不经过评分排序)。
pool = usable_assets if usable_assets else filtered_assets
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
MAX_RECENT_USE_COUNT = 3
high_freq_assets = set()
if pool:
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
if asset_ids:
try:
use_counts = get_asset_recent_use_counts(
db=asset_repository.session,
asset_ids=asset_ids,
recent_video_count=5,
)
for a in pool:
aid = getattr(a, "id", "")
count = use_counts.get(aid, 0)
if count > MAX_RECENT_USE_COUNT:
high_freq_assets.add(aid)
logger.info(
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
aid, count, MAX_RECENT_USE_COUNT,
)
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
# 不再全部排除,保留全部可用素材
if high_freq_assets:
remaining_count = len(pool) - len(high_freq_assets)
enough = request.limit is None or remaining_count >= request.limit
if remaining_count > 0 and enough:
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
else:
logger.info(
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d",
remaining_count,
request.limit if request.limit is not None else "不限",
len(pool),
)
except Exception:
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
results = smart_select_assets(pool, limit=request.limit, kind=None)
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
items = [
SmartMatchItem(
**_to_asset_response(r.asset).model_dump(),
score=r.score,
breakdown=r.breakdown,
)
for r in results
]
return SmartMatchResponse(items=items, total_candidates=total_candidates)
@router.get("/{asset_id}", response_model=AssetResponse)
def get_asset(
asset_id: str,
@@ -764,12 +550,43 @@ def untag_asset(
@router.post("", response_model=AssetResponse)
def create_asset() -> None:
"""
已废弃接口。
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
"""
raise HTTPException(
status_code=410,
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
def create_asset(
request: CreateAssetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetResponse:
project = project_repository.find_by_id(request.project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
library = asset_library_repository.get(request.library_id)
if library is None or library.project_id != request.project_id:
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
use_case = CreateAssetUseCase(asset_repository)
item = use_case.execute(
CreateAssetCommand(
project_id=request.project_id,
library_id=request.library_id,
name=request.name,
storage_key=request.storage_key,
mime_type=request.mime_type,
metadata=request.metadata,
file_size=request.file_size,
thumbnail_url=request.thumbnail_url,
duration=request.duration,
width=request.width,
height=request.height,
fps=request.fps,
codec=request.codec,
status=AssetStatus(request.status),
classification_status=ClassificationStatus(request.classification_status),
quality_score=request.quality_score,
uploaded_by_user_id=authenticated_user.user.id,
)
)
return _to_asset_response(item)
+3 -410
View File
@@ -1,5 +1,4 @@
"""
from __future__ import annotations
Canonical authentication API routes.
The route layer is intentionally thin: repository construction lives in
@@ -13,10 +12,10 @@ from typing import Optional
import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_db_session, get_user_repository
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr, field_validator
from pydantic import BaseModel, EmailStr
from packages.adapters.redis import NoopSessionStore
from packages.adapters.smtp import NoopEmailService
@@ -82,26 +81,6 @@ class CurrentUserResponse(BaseModel):
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
profile_completed: bool = True
class UserProfileResponse(BaseModel):
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
user_id: str
email: str
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
profile_completed: bool = True
class PasswordResetRequestModel(BaseModel):
@@ -126,7 +105,6 @@ async def register(
request: RegisterRequest,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
db=Depends(get_db_session),
) -> RegisterResponse:
use_case = RegisterUserUseCase(
user_repository=user_repository,
@@ -144,22 +122,6 @@ async def register(
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error))
# 新用户注册赠送 50 积分(失败不影响注册)
if settings.points_enabled:
try:
from packages.domain.points_service import PointsService
_svc = PointsService()
_svc.add_points(
user_id=response.user_id,
amount=50,
source="task_reward",
db=db,
description="新用户注册赠送",
)
except Exception as _bonus_err:
import logging
logging.getLogger(__name__).warning("注册送积分失败: user_id=%s err=%s", response.user_id, _bonus_err)
return RegisterResponse(
user_id=response.user_id,
email=response.email,
@@ -297,62 +259,15 @@ async def get_current_user_info(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
) -> CurrentUserResponse:
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(
user_id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
email_verified=user.email_verified,
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
class UpdateProfileRequest(BaseModel):
"""更新个人资料请求(当前仅支持昵称)"""
display_name: str
@field_validator("display_name")
@classmethod
def _validate_display_name(cls, v: str) -> str:
name = (v or "").strip()
if not name:
raise ValueError("昵称不能为空白")
if len(name) > 20:
raise ValueError("昵称长度需在 1-20 个字符之间")
return name
class UpdateProfileResponse(BaseModel):
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
user: UserProfileResponse
@router.patch("/me", response_model=UpdateProfileResponse)
async def update_current_user_profile(
request: UpdateProfileRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> UpdateProfileResponse:
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
user = current_user.user
user.display_name = request.display_name # 已 stripvalidator
if not user.profile_completed:
user.profile_completed = True
user_repository.save(user)
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
# 重新读取,确保返回的是持久化后的最新状态
fresh = user_repository.find_by_id(user.id) or user
return UpdateProfileResponse(user=_user_profile(fresh))
class _NoopSessionStore(NoopSessionStore):
pass
@@ -465,325 +380,3 @@ async def wechat_sync(
raise HTTPException(status_code=400, detail=error)
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,
http_request: Request,
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
# 回调可观测性:记录 UA(区分微信内置浏览器 MicroMessenger)与 state
# 便于排查"停留 open.weixin.qq.com / 回调失败"类问题(#1718
user_agent = http_request.headers.get("User-Agent", "")
is_wechat_browser = "MicroMessenger" in user_agent
logger.info(
"[微信回调] 收到回调: state=%s code_len=%d UA=%r 微信内置浏览器=%s",
(request.state or "")[:8],
len(request.code or ""),
user_agent[:200],
is_wechat_browser,
)
# 1. 用 code 换微信用户信息
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
# state 校验失败 / 微信 errcode 等错误原文已在 service 内 log,这里带上 UA 上下文
logger.warning("[微信回调] 处理失败: err=%s 微信内置浏览器=%s", err, is_wechat_browser)
raise HTTPException(status_code=400, detail=err)
logger.info(
"[微信回调] state 校验通过,微信用户信息获取成功: openid=%s unionid=%s",
wechat_user.openid[:8] if wechat_user.openid else "",
bool(wechat_user.unionid),
)
# 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 = bool(
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 WechatBindUrlResponse(BaseModel):
auth_url: str
state: str
class WechatBindCompleteRequest(BaseModel):
code: str
state: str = ""
class WechatBindCompleteResponse(BaseModel):
success: bool
user: UserProfileResponse
class WechatUnbindResponse(BaseModel):
success: bool
def _user_profile(user) -> UserProfileResponse:
binding_complete = bool(
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return UserProfileResponse(
user_id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
email_verified=user.email_verified,
phone=user.phone or "",
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
async def get_wechat_bind_url(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> WechatBindUrlResponse:
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
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()
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
return WechatBindUrlResponse(auth_url=auth_url, state=state)
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
async def wechat_bind(
request: WechatBindCompleteRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatBindCompleteResponse:
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
oauth_service = get_wechat_oauth_service()
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
if err:
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
raise HTTPException(status_code=400, detail=err)
use_case = WechatBindUseCase(user_repository=user_repository)
result, error, http_status = use_case.bind(
WechatBindRequest(
user_id=current_user.user.id,
openid=wechat_user.openid,
unionid=wechat_user.unionid or "",
)
)
if error:
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
async def wechat_unbind(
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> WechatUnbindResponse:
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
use_case = WechatUnbindUseCase(user_repository=user_repository)
result, error, http_status = use_case.unbind(current_user.user.id)
if error:
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
return WechatUnbindResponse(success=True)
# ==================== 验证码 & 绑定 ====================
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 返回值补充字段实现
+9 -11
View File
@@ -8,13 +8,12 @@ import json
import logging
import shutil
import tempfile
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.api.routes.upload import _persist_celery_task_id
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
@@ -156,7 +155,7 @@ def _cleanup_expired_uploads() -> int:
if not CHUNK_STORAGE_ROOT.exists():
return 0
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
cleaned = 0
for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"):
@@ -166,7 +165,7 @@ def _cleanup_expired_uploads() -> int:
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
expires_at = expires_at.replace(tzinfo=timezone.utc)
# Only cleanup uploads that are not actively being uploaded
if expires_at < now and meta.get("status") != "uploading":
@@ -177,8 +176,8 @@ def _cleanup_expired_uploads() -> int:
meta_file.unlink()
cleaned += 1
logger.info(f"Cleaned up expired upload: {upload_id}")
except Exception:
logger.exception("Failed to cleanup upload metadata: %s", meta_file)
except Exception as e:
logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}")
return cleaned
@@ -226,7 +225,7 @@ async def init_chunked_upload(
# Generate upload ID
upload_id = uuid4().hex
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS)
# Create chunk directory
@@ -382,8 +381,7 @@ async def complete_chunked_upload(
file_hash=request.file_hash,
)
)
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
celery_app.send_task("worker.ingest_asset", args=[job.id])
# Update metadata status
meta["status"] = "completed"
@@ -421,9 +419,9 @@ async def upload_chunk(
# Check expiry
expires_at = datetime.fromisoformat(meta["expires_at"])
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(UTC):
if expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
# Validate chunk index
@@ -1,90 +0,0 @@
"""独立的从素材创建片段端点(不依赖 template_id 路径参数).
POST /api/v1/clips/from-assets
- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致
- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板
- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题(P0 #1922
- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_asset_repository, get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from ._default_template import get_or_create_default_template_id
from .templates_editor.clips import create_clips_from_assets_editor
from .templates_editor.dependencies import resolve_draft_plan_id
from .templates_editor.schemas import ClipsFromAssetsRequest, ClipsFromAssetsResponse
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Clips"])
class StandaloneClipsRequest(ClipsFromAssetsRequest):
"""扩展请求:template_id 可选(不传则后端自动兜底默认模板)。"""
template_id: str | None = None
def _get_editor_services_direct(db: Session) -> tuple[EditTemplateService, EditPlanService]:
"""直接构造服务实例(非 Depends 版本,供独立端点内部调用)。"""
return EditTemplateService(db), EditPlanService(db)
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets(
body: StandaloneClipsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(template_id 可选,为空自动兜底)。"""
user_id = str(current_user.user.id)
services = _get_editor_services_direct(db)
# 1. 解析/兜底 template_id,拿到 plan_id
template_id = (body.template_id or "").strip()
if not template_id:
template_id = get_or_create_default_template_id(db, user_id)
if not template_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="无法自动创建默认模板,请刷新页面重试",
)
plan_id = resolve_draft_plan_id(
template_id=template_id,
services=services,
current_user=current_user,
db=db,
auto_create_default=False, # 上面已兜底过
)
# 2. 构造标准化请求(去除独立端扩展字段),复用原端点核心逻辑
core_body = ClipsFromAssetsRequest(
asset_ids=body.asset_ids,
clip_type=body.clip_type,
clip_count=body.clip_count,
required_clips_count=body.required_clips_count,
)
# 3. 直接调用原端点函数(此时所有 Depends 依赖已手动传入)
return create_clips_from_assets_editor(
template_id=template_id,
body=core_body,
background_tasks=background_tasks,
plan_id=plan_id,
services=services,
asset_repo=asset_repo,
db=db,
current_user=current_user,
)
-153
View File
@@ -1,153 +0,0 @@
"""封面模板 CRUD 路由。
API:
GET /api/v1/cover-templates - 列出当前用户可见的模板
POST /api/v1/cover-templates - 创建自定义模板
PUT /api/v1/cover-templates/{id} - 更新模板
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
"""
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_cover_template_repository
from app.schemas.cover_template import (
CoverTemplateResponse,
CreateCoverTemplateRequest,
ListCoverTemplatesResponse,
UpdateCoverTemplateRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.exc import OperationalError, ProgrammingError
from packages.domain.cover_template import CoverTemplate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/cover-templates", tags=["CoverTemplate"])
@router.get("", response_model=ListCoverTemplatesResponse)
def list_cover_templates(
skip: int = 0,
limit: int = 100,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> ListCoverTemplatesResponse:
"""列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。
当数据库表不存在时(迁移未执行),降级返回空列表而非 500。
"""
user_id = authenticated_user.user.id
try:
items = repo.list_for_user(user_id, skip=skip, limit=limit)
total = repo.count_for_user(user_id)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表查询失败(可能未迁移),返回空列表: %s", exc)
return ListCoverTemplatesResponse(items=[], total=0)
return ListCoverTemplatesResponse(
items=[
CoverTemplateResponse(
id=t.id,
name=t.name,
thumbnail_url=t.thumbnail_url,
is_system=t.is_system,
created_at=t.created_at,
config=t.config or {},
)
for t in items
],
total=total,
)
@router.post("", response_model=CoverTemplateResponse, status_code=201)
def create_cover_template(
request: CreateCoverTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> CoverTemplateResponse:
"""创建用户自定义封面模板。"""
user_id = authenticated_user.user.id
config_dict = request.config.model_dump() if request.config else {}
template = CoverTemplate.create_user(
user_id=user_id,
name=request.name,
config=config_dict,
thumbnail_url=request.thumbnail_url,
)
try:
created = repo.create(template)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表不可用(可能未迁移): %s", exc)
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
return CoverTemplateResponse(
id=created.id,
name=created.name,
thumbnail_url=created.thumbnail_url,
is_system=created.is_system,
created_at=created.created_at,
config=created.config,
)
@router.put("/{template_id}", response_model=CoverTemplateResponse)
def update_cover_template(
template_id: str,
request: UpdateCoverTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> CoverTemplateResponse:
"""更新封面模板(仅允许更新自己的模板)。"""
user_id = authenticated_user.user.id
try:
template = repo.get(template_id)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表不可用: %s", exc)
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
if template.is_system:
raise HTTPException(status_code=403, detail="系统模板不可修改")
if template.user_id != user_id:
raise HTTPException(status_code=403, detail="无权修改该模板")
if request.name is not None:
template.update(name=request.name)
if request.config is not None:
template.update(config=request.config.model_dump())
if request.thumbnail_url is not None:
template.update(thumbnail_url=request.thumbnail_url)
updated = repo.update(template)
return CoverTemplateResponse(
id=updated.id,
name=updated.name,
thumbnail_url=updated.thumbnail_url,
is_system=updated.is_system,
created_at=updated.created_at,
config=updated.config,
)
@router.delete("/{template_id}", status_code=204, response_class=Response)
def delete_cover_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> None:
"""删除用户自定义封面模板(系统模板不可删除)。"""
user_id = authenticated_user.user.id
try:
template = repo.get(template_id)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表不可用: %s", exc)
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
if template.is_system:
raise HTTPException(status_code=403, detail="系统模板不可删除")
if template.user_id != user_id:
raise HTTPException(status_code=403, detail="无权删除该模板")
repo.delete(template_id)
-9
View File
@@ -7,7 +7,6 @@ from typing import Any
from uuid import uuid4
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_duplication_repository
from app.schemas.duplication import (
@@ -77,8 +76,6 @@ def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
visual_similarity=getattr(record, "visual_similarity", None),
match_count=getattr(record, "match_count", None),
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
)
@@ -93,8 +90,6 @@ def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
visual_similarity=getattr(record, "visual_similarity", None),
match_count=getattr(record, "match_count", None),
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
segments=[
@@ -197,8 +192,6 @@ async def upload_for_duplication(
authenticated_user.user.id,
)
celery_app.send_task("worker.process_duplication_check", args=[record.id])
return DuplicationUploadResponse(
id=record.id,
status=record.status,
@@ -303,8 +296,6 @@ def retry_duplication(
detail=f"查重记录 {record_id} 不存在",
)
celery_app.send_task("worker.process_duplication_check", args=[updated.id])
return DuplicationUploadResponse(
id=updated.id,
status=updated.status,
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
"""片段调整 API.
- PUT /clips/{clip_id}/speed 调速
- PUT /clips/{clip_id}/volume 音量调节
- PUT /clips/{clip_id}/trim 裁剪(trim in/out
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim
- POST /{plan_id}/clips/batch-speed 批量调速
"""
from __future__ import annotations
import logging
from typing import Any, Optional
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 pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
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
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_clip_config(clip) -> dict:
config = getattr(clip, "config", {}) or {}
if not isinstance(config, dict):
config = {}
return config
def _get_volume(clip) -> float:
config = _get_clip_config(clip)
return float(config.get("volume", 1.0))
def _get_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_response(clip) -> ClipAdjustResponse:
trim_start, trim_end = _get_trim(clip)
return ClipAdjustResponse(
clip_id=clip.id,
speed=clip.playback_speed,
volume=_get_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")
# ── Routes ───────────────────────────────────────────────────────────────────
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
return svc, plan, clip
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
def adjust_speed(
clip_id: str,
body: SpeedAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段播放速度"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
updated = svc.update_clip(clip_id, playback_speed=body.speed)
logger.info(
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
clip_id,
body.speed,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
def adjust_volume(
clip_id: str,
body: VolumeAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""调整片段音量"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 更新 config.volume
config = dict(_get_clip_config(clip))
config["volume"] = body.volume
updated = svc.update_clip(clip_id, config=config)
logger.info(
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
clip_id,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
def adjust_trim(
clip_id: str,
body: TrimAdjustRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""裁剪片段(trim in/out"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
# 验证裁剪时长
try:
_validate_trim(body.trim_start, body.trim_end, clip.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新 config
config = dict(_get_clip_config(clip))
config["trim_start"] = body.trim_start
config["trim_end"] = body.trim_end
updated = svc.update_clip(clip_id, config=config)
logger.info(
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
clip_id,
body.trim_start,
body.trim_end,
current_user.user.id,
)
return _build_response(updated)
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
def adjust_all(
clip_id: str,
body: ClipAdjustmentsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipAdjustResponse:
"""统一调整片段的 speed / volume / trim"""
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
update_kwargs = {}
config_updates = {}
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
# 验证 trim
current_trim_start, current_trim_end = _get_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=status.HTTP_400_BAD_REQUEST, 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_response(clip)
updated = svc.update_clip(clip_id, **update_kwargs)
logger.info(
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
clip_id,
body.speed,
body.volume,
current_user.user.id,
)
return _build_response(updated)
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse)
def batch_adjust_speed(
plan_id: str,
body: BatchSpeedRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchSpeedResponse:
"""批量调整计划内所有片段的播放速度"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id, limit=500, skip=0)
count = 0
for clip in clips:
svc.update_clip(clip.id, playback_speed=body.speed)
count += 1
logger.info(
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
plan_id,
count,
body.speed,
current_user.user.id,
)
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
+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)) from 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 as _e:
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推荐结果保存失败,请稍后重试",
) from _e
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)) from 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,
)
+415
View File
@@ -0,0 +1,415 @@
"""剪辑计划片段(Clip)CRUD 路由。"""
from __future__ import annotations
import logging
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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.domain.edit_plan_clip import EditPlanClipStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
from pydantic import BaseModel, Field
class EditPlanClipResponse(BaseModel):
"""剪辑片段响应体"""
id: str
plan_id: str
clip_type: str
order: int
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
status: str
config: dict[str, Any] = Field(default_factory=dict)
created_at: Optional[str] = None
updated_at: Optional[str] = None
class EditPlanClipListResponse(BaseModel):
"""剪辑片段列表响应体"""
items: List[EditPlanClipResponse]
total: int
class EditPlanClipCreateRequest(BaseModel):
"""创建剪辑片段请求体"""
clip_type: str = Field(
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
)
order: int = Field(..., ge=0, description="排序序号")
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
class EditPlanClipUpdateRequest(BaseModel):
"""更新剪辑片段请求体"""
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
返回 plan 对象供后续使用,避免重复查询。
"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _clip_to_response(clip) -> EditPlanClipResponse:
"""将领域对象转换为响应体"""
return EditPlanClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
config=clip.config or {},
created_at=clip.created_at.isoformat() if clip.created_at else None,
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
)
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanClipListResponse)
def list_clips(
plan_id: str,
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
skip: int = Query(0, ge=0, description="分页偏移"),
limit: int = Query(100, ge=1, le=500, description="每页数量"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipListResponse:
"""获取剪辑计划的片段列表"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
total = svc.count_clips(plan_id, status=status_enum)
return EditPlanClipListResponse(
items=[_clip_to_response(c) for c in clips],
total=total,
)
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
def create_clip(
plan_id: str,
body: EditPlanClipCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""创建剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clip = svc.create_clip(
plan_id=plan_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
return _clip_to_response(clip)
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
def get_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""获取剪辑片段详情"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
return _clip_to_response(clip)
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
def update_clip(
plan_id: str,
clip_id: str,
body: EditPlanClipUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""更新剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
try:
updated = svc.update_clip(
clip_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return _clip_to_response(updated)
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
deleted = svc.delete_clip(clip_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return None
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
class SplitClipRequest(BaseModel):
"""分割片段请求体"""
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
class MergeClipsRequest(BaseModel):
"""合并片段请求体"""
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
@router.post(
"/{clip_id}/split",
response_model=dict[str, Any],
summary="分割片段",
status_code=status.HTTP_200_OK,
)
def split_clip(
plan_id: str,
clip_id: str,
body: SplitClipRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将一个片段从指定时间点分割为两个片段。
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
try:
result = svc.split_clip(clip_id, body.split_time)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
left = result["left_clip"]
right = result["right_clip"]
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
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(
"/merge",
response_model=dict[str, Any],
summary="合并多个连续片段",
status_code=status.HTTP_200_OK,
)
def merge_clips(
plan_id: str,
body: MergeClipsRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将多个连续的同类型片段合并为一个片段。
合并要求:
- 至少 2 个片段
- 属于同一剪辑计划
- order 连续
- 类型相同
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 校验所有片段都属于该 plan
for cid in body.clip_ids:
clip = svc.get_clip(cid)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {cid}",
)
try:
merged = svc.merge_clips(body.clip_ids)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"合并片段: plan_id=%s clip_count=%d by user=%s",
plan_id,
len(body.clip_ids),
current_user.user.id,
)
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,
}
+241
View File
@@ -0,0 +1,241 @@
"""剪辑计划片段批量操作 API。"""
from __future__ import annotations
import logging
from typing import Any, List
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
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
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
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
created_count: int
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
from app.services.edit_plan_service import EditPlanService
from ._helpers import check_project_access
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.post("/reorder", response_model=ClipReorderResponse)
def reorder_clips(
plan_id: str,
body: ClipReorderRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipReorderResponse:
"""批量重排序片段
前端拖拽调整顺序后,一次性提交所有变更的 order。
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
clip_ids = [item.clip_id for item in body.items]
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
if invalid_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
)
# 执行重排序
updated_count = 0
for item in body.items:
try:
svc.update_clip(item.clip_id, order=item.new_order)
updated_count += 1
except ValueError as e:
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
logger.info(
"批量重排序片段: plan_id=%s count=%d by user=%s",
plan_id,
updated_count,
current_user.user.id,
)
return ClipReorderResponse(
success=True,
updated_count=updated_count,
message=f"成功更新 {updated_count} 个片段的顺序",
)
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse)
def batch_delete_clips(
plan_id: str,
body: ClipBatchDeleteRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipBatchDeleteResponse:
"""批量删除片段
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
skipped = len(body.clip_ids) - len(valid_ids)
# 执行删除
deleted_count = 0
for clip_id in valid_ids:
if svc.delete_clip(clip_id):
deleted_count += 1
message = f"成功删除 {deleted_count} 个片段"
if skipped > 0:
message += f",跳过 {skipped} 个不存在的片段"
logger.info(
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
plan_id,
deleted_count,
skipped,
current_user.user.id,
)
return ClipBatchDeleteResponse(
success=True,
deleted_count=deleted_count,
message=message,
)
@router.post("/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets(
plan_id: str,
body: ClipsFromAssetsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(追加到时间线末尾)
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
自动触发编辑状态回退(completed/failed → editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clips = svc.create_clips_from_assets(
plan_id=plan_id,
asset_ids=body.asset_ids,
clip_type=body.clip_type,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
clip_ids = [c.id for c in clips]
logger.info(
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
plan_id,
len(clips),
current_user.user.id,
)
return ClipsFromAssetsResponse(
success=True,
created_count=len(clips),
message=f"成功创建 {len(clips)} 个片段",
clip_ids=clip_ids,
)
+315
View File
@@ -0,0 +1,315 @@
"""封面管理 API.
- GET /{plan_id}/cover 获取封面配置
- PUT /{plan_id}/cover 更新封面配置
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
- POST /{plan_id}/cover/smart 智能选帧生成封面
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService
from app.services.cover_service import CoverService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
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="抽帧时间点(秒)")
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
def get_cover(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""获取封面配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
cover = CoverService.get_cover_config(plan.config or {})
return CoverConfigResponse(**cover)
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
def update_cover(
plan_id: str,
body: CoverUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> CoverConfigResponse:
"""更新封面配置
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current_cover = CoverService.get_cover_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_cover = {**current_cover, **updates}
# 验证 type 值
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
if "type" in updates and updates["type"] not in valid_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
)
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["cover"] = new_cover
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
result = CoverService.get_cover_config(updated_plan.config or {})
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
return CoverConfigResponse(**result)
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
def extract_cover(
plan_id: str,
body: CoverExtractRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""从指定片段的指定时间点抽帧生成封面"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 获取片段对应的素材
clip = svc.get_clip(body.clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {body.clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材,无法抽帧",
)
# 抽帧生成封面
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.extract_cover_from_clip(
plan_id=plan_id,
asset_id=clip.asset_id,
frame_time=body.frame_time,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"封面抽帧失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
plan_id,
body.clip_id,
body.frame_time,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
def smart_cover(
plan_id: str,
body: CoverSmartRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
storage_service: Any = Depends(get_storage_service),
asset_repository: Any = Depends(get_asset_repository),
) -> CoverGenerateResponse:
"""智能选帧生成封面
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 确定使用哪个片段
clip_id = body.clip_id
asset_id = ""
if clip_id:
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
if clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段不属于该剪辑计划",
)
if not clip.asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="片段没有关联素材",
)
asset_id = clip.asset_id
else:
# 找第一个有素材的视频片段
clips = svc.list_clips(plan_id, limit=50, skip=0)
for c in clips:
if c.asset_id and c.clip_type == "video":
asset_id = c.asset_id
clip_id = c.id
break
if not asset_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有找到可用的视频片段",
)
# 智能选帧
cover_svc = CoverService(storage_service, asset_repository)
try:
cover_data = cover_svc.generate_smart_cover(
plan_id=plan_id,
asset_id=asset_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
except RuntimeError as e:
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"智能封面生成失败: {e}",
) from e
# 更新到 plan.config.cover
current_config = dict(plan.config or {})
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
plan_id,
clip_id,
current_user.user.id,
)
return CoverGenerateResponse(**cover_data)
+274
View File
@@ -0,0 +1,274 @@
"""导出设置 API.
- GET /{plan_id}/export 获取导出配置
- PUT /{plan_id}/export 更新导出配置
- GET /export-presets 导出预设列表
"""
from __future__ import annotations
import logging
import re
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.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, validator
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── 导出预设 ──────────────────────────────────────────────────────────────────
EXPORT_PRESETS = [
{
"id": "export_1080p_30",
"name": "1080P 高清",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 8000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "balanced",
"description": "竖屏高清,适合短视频平台",
"size_hint": "约 10MB/分钟",
},
{
"id": "export_1080p_60",
"name": "1080P 高帧率",
"resolution": "1080x1920",
"fps": 60,
"video_bitrate": 12000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "high",
"description": "60帧高帧率,流畅运动画面",
"size_hint": "约 18MB/分钟",
},
{
"id": "export_720p_30",
"name": "720P 流畅",
"resolution": "720x1280",
"fps": 30,
"video_bitrate": 4000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "fast",
"description": "快速导出,文件较小",
"size_hint": "约 5MB/分钟",
},
{
"id": "export_4k_30",
"name": "4K 超清",
"resolution": "2160x3840",
"fps": 30,
"video_bitrate": 20000,
"audio_bitrate": 192,
"format": "mp4",
"quality_preset": "best",
"description": "4K超清画质,专业品质",
"size_hint": "约 30MB/分钟",
},
{
"id": "export_1080p_30_mov",
"name": "1080P ProRes",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 15000,
"audio_bitrate": 256,
"format": "mov",
"quality_preset": "high",
"description": "MOV格式,适合后期剪辑",
"size_hint": "约 25MB/分钟",
},
]
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
VALID_FORMATS = {"mp4", "mov"}
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
# ── Schemas ──────────────────────────────────────────────────────────────────
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 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 VALID_FORMATS:
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
return v
@validator("quality_preset")
def validate_quality_preset(cls, v):
if v is None:
return v
if v not in VALID_QUALITY_PRESETS:
raise ValueError(f"无效质量预设: {v},支持: {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
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_export_config(plan_config: dict) -> dict:
e = plan_config.get("export", {})
if not isinstance(e, dict):
e = {}
return {
"resolution": e.get("resolution", "1080x1920"),
"fps": e.get("fps", 30),
"video_bitrate": e.get("video_bitrate", 8000),
"audio_bitrate": e.get("audio_bitrate", 128),
"format": e.get("format", "mp4"),
"quality_preset": e.get("quality_preset", "balanced"),
"watermark_enabled": e.get("watermark_enabled", False),
"watermark_text": e.get("watermark_text", ""),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/export-presets", response_model=ExportPresetListResponse)
def list_export_presets(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ExportPresetListResponse:
"""获取导出预设列表"""
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
return ExportPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
def get_export_config(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""获取导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_export_config(plan.config or {})
return ExportConfigResponse(**config)
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
def update_export_config(
plan_id: str,
body: ExportUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""更新导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current = _get_export_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_export = {**current, **updates}
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["export"] = new_export
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
result = _get_export_config(updated_plan.config or {})
logger.info(
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
plan_id,
result["resolution"],
result["fps"],
current_user.user.id,
)
return ExportConfigResponse(**result)
+197
View File
@@ -0,0 +1,197 @@
"""滤镜调色 API.
- GET /filter-presets 滤镜预设列表
- GET /{plan_id}/filter 获取全局滤镜配置
- PUT /{plan_id}/filter 更新全局滤镜配置
"""
from __future__ import annotations
import logging
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.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.filter_presets import (
FilterPreset,
get_filter_preset,
list_filter_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
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
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
return FilterPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
)
def _get_filter_config(plan_config: dict) -> dict:
"""从 plan.config 中提取滤镜配置"""
f = plan_config.get("filter", {})
if not isinstance(f, dict):
f = {}
return {
"enabled": f.get("enabled", False),
"preset_id": f.get("preset_id", "filter_none"),
"intensity": f.get("intensity", 100),
"brightness": f.get("brightness", 0.0),
"contrast": f.get("contrast", 1.0),
"saturation": f.get("saturation", 1.0),
"warmth": f.get("warmth", 0.0),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/filter-presets", response_model=FilterPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
presets = list_filter_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return FilterPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
def get_filter(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""获取剪辑计划的全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_filter_config(plan.config or {})
return FilterConfigResponse(**config)
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
def update_filter(
plan_id: str,
body: FilterUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""更新全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证 preset_id
updates = body.model_dump(exclude_none=True)
if "preset_id" in updates:
preset = get_filter_preset(updates["preset_id"])
if preset is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的滤镜预设: {updates['preset_id']}",
)
# 合并更新
current = _get_filter_config(plan.config or {})
new_filter = {**current, **updates}
# 如果设为原图 preset,自动关闭
if new_filter["preset_id"] == "filter_none":
new_filter["enabled"] = False
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["filter"] = new_filter
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
result = _get_filter_config(updated_plan.config or {})
logger.info(
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
plan_id,
result["preset_id"],
result["intensity"],
current_user.user.id,
)
return FilterConfigResponse(**result)
+412
View File
@@ -0,0 +1,412 @@
"""剪辑计划生成相关 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.storage import OSSStorageService, get_storage_service
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: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段
注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto,
导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段,
就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。
"""
if not clips_without_asset:
return
if 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)) from 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)
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
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 [],
)
)
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 as _e:
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="生成失败,请稍后重试",
) from _e
@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),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> 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)) from 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
]
# 从 plan.config 中取渲染结果 URL,转换为签名 URL
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失败,返回原始URL: plan_id=%s error=%s", plan_id, e)
video_url = raw_video_url
# 从 gen_status 中取进度、错误信息、任务状态
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
# 如果计划已完成但进度还是0,补100
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(
"/{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))
@@ -0,0 +1,257 @@
"""剪辑计划时间线 & 模板生成 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 auto_select_video_assets, 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_asset_library_repository,
get_asset_repository,
get_db_session,
get_project_repository,
)
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, 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),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_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)) from exc
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材
resolved_asset_ids = list(body.asset_ids)
if not resolved_asset_ids and body.project_id:
auto_assets = auto_select_video_assets(
project_id=body.project_id,
asset_library_repo=asset_library_repository,
asset_repo=asset_repository,
logger=logger,
)
if auto_assets:
resolved_asset_ids = auto_assets
logger.info(
"generate-from-template 自动选素材: project_id=%s count=%d",
body.project_id,
len(auto_assets),
)
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=resolved_asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
# 把 asset_ids 写入 plan.config,供生成时兜底分配使用
if resolved_asset_ids:
from app.services import EditPlanService
from packages.domain.config_schemas import normalize_plan_config
svc = EditPlanService(db)
current_config = plan.config or {}
if current_config.get("asset_ids") != resolved_asset_ids:
current_config["asset_ids"] = resolved_asset_ids
plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config))
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d",
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
],
)
+272
View File
@@ -0,0 +1,272 @@
"""转场特效 API.
- GET /transition-presets 转场预设列表
- PUT /clips/{clip_id}/transition 设置单个片段转场
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
"""
from __future__ import annotations
import logging
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.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.transition_presets import (
TransitionPreset,
get_transition_preset,
list_transition_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
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
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
return TransitionPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
default_duration=p.default_duration,
min_duration=p.min_duration,
max_duration=p.max_duration,
)
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
"""验证转场效果和时长,返回 (effect, duration)"""
preset = get_transition_preset(effect)
if preset is None:
raise ValueError(f"无效的转场效果: {effect}")
# 硬切特殊处理,时长强制为0
if effect == "transition_none" or preset.transition == "none":
return "cut", 0.0
final_duration = duration if duration is not None else preset.default_duration
if final_duration < preset.min_duration:
final_duration = preset.min_duration
if final_duration > preset.max_duration:
final_duration = preset.max_duration
return preset.transition, round(final_duration, 3)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> TransitionPresetListResponse:
"""获取转场预设列表"""
presets = list_transition_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return TransitionPresetListResponse(items=items, total=len(items))
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
def update_clip_transition(
clip_id: str,
body: TransitionUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipTransitionResponse:
"""设置单个片段的转场效果"""
svc = EditPlanService(db)
clip = svc.get_clip(clip_id)
if not clip:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
plan = svc.get_plan(clip.plan_id)
if plan and plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 更新片段
updated_clip = svc.update_clip(
clip_id,
transition_effect=effect,
transition_duration=duration,
)
logger.info(
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
clip_id,
effect,
duration,
current_user.user.id,
)
return ClipTransitionResponse(
clip_id=clip_id,
effect=updated_clip.transition_effect,
duration=updated_clip.transition_duration,
)
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse)
def batch_update_transitions(
plan_id: str,
body: BatchTransitionRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> BatchTransitionResponse:
"""批量设置计划内所有片段的转场效果
apply_to 说明:
- all: 所有片段
- except_first: 除第一个片段外(第一个片段不需要前转场)
- except_last: 除最后一个片段外
- middle: 只设置中间片段(除首尾)
"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证转场参数
try:
effect, duration = _validate_transition(body.effect, body.duration)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 获取所有片段
clips = svc.list_clips(plan_id, limit=500, skip=0)
if not clips:
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 确定应用范围
total = len(clips)
if total <= 1:
# 只有一个片段时,只有 all 模式才应用
if body.apply_to != "all":
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
# 按 order 排序
clips_sorted = sorted(clips, key=lambda c: c.order)
indices_to_update = []
if body.apply_to == "all":
indices_to_update = list(range(total))
elif body.apply_to == "except_first":
indices_to_update = list(range(1, total))
elif body.apply_to == "except_last":
indices_to_update = list(range(total - 1))
elif body.apply_to == "middle":
if total <= 2:
indices_to_update = []
else:
indices_to_update = list(range(1, total - 1))
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的 apply_to: {body.apply_to}",
)
# 批量更新
count = 0
for idx in indices_to_update:
clip = clips_sorted[idx]
svc.update_clip(
clip.id,
transition_effect=effect,
transition_duration=duration,
)
count += 1
logger.info(
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
plan_id,
count,
effect,
body.apply_to,
current_user.user.id,
)
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
+3 -1
View File
@@ -32,7 +32,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
ALLOWED_FLAGS: set[str] = set()
ALLOWED_FLAGS = {
"render_engine",
}
def _get_feature_flag_store() -> RedisFeatureFlagStore:

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