Compare commits
362 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed775edeee | |||
| e633ff2de7 | |||
| 6511bc24a9 | |||
| 6a79776b51 | |||
| a31145950f | |||
| 796074867b | |||
| 32dcf483f8 | |||
| 06857d0a0d | |||
| dcd51f48de | |||
| 2f9a1a42f5 | |||
| f776fe0250 | |||
| b914f55b7d | |||
| dd416ebed9 | |||
| 31c0f23297 | |||
| c2aa0344f5 | |||
| 057431f531 | |||
| 2500e1df20 | |||
| 74337af2ad | |||
| ea97816d98 | |||
| c963bb5ee1 | |||
| 231cd4b203 | |||
| 52f27427b1 | |||
| f9a934d916 | |||
| ecd2021043 | |||
| 2ddfa641a7 | |||
| 9160db2218 | |||
| e6c750b670 | |||
| b01ae28c6f | |||
| 3bffa3cb02 | |||
| cdad6fd641 | |||
| 3d6cf28054 | |||
| 6e00c9d3c3 | |||
| e1b9ddaa1e | |||
| f8f70fe61b | |||
| 7091e3bbc7 | |||
| ecc3feb746 | |||
| 4b81a6e4d8 | |||
| 3a6a10875f | |||
| f13eeb4d28 | |||
| b52e721e0f | |||
| 1d9bfab296 | |||
| 7e0047cca6 | |||
| 5ebdb94bd8 | |||
| 9327e55be1 | |||
| 0bed1d5274 | |||
| 530b31dc10 | |||
| 588f0652e6 | |||
| d31e30c494 | |||
| 9ce7098007 | |||
| 43de087282 | |||
| 6ece2fc84f | |||
| 35268503d4 | |||
| 8e016d060d | |||
| 91df06b84d | |||
| e94541d793 | |||
| 34dbc0af5f | |||
| 6f1a71e8bd | |||
| bad34ad001 | |||
| 1261e21f0b | |||
| 15a1177c52 | |||
| 84518a783f | |||
| 57942e06d9 | |||
| 15aea40366 | |||
| 05ebe166ba | |||
| 0e05a2a23e | |||
| 37e849f90e | |||
| 2cfd606355 | |||
| a1fc9104aa | |||
| 45d52074d3 | |||
| 9298c5e583 | |||
| 989b937ea9 | |||
| 4526701945 | |||
| 404514fcb2 | |||
| aeda835349 | |||
| e97e777e0b | |||
| f3887da532 | |||
| 82c01003fb | |||
| 84a4ab4345 | |||
| babdb06287 | |||
| 1d638027bf | |||
| e63d5f07c4 | |||
| dc89f66bfd | |||
| 36a0ae1afd | |||
| 93d212c1c4 | |||
| 176914b3a4 | |||
| feb410b9b6 | |||
| 2cccc1b1d1 | |||
| 9743123abc | |||
| 0354ad65d2 | |||
| f7e3a7a5a2 | |||
| 136c40360c | |||
| 719b090217 | |||
| 18a3490eda | |||
| 5fa4e2573e | |||
| d00d63f14d | |||
| 90911d21fc | |||
| fb7024c359 | |||
| 4c396b2a7b | |||
| cb2e20ffd4 | |||
| fe7a8ee6fb | |||
| 36fcc71c80 | |||
| a9fb1f89e7 | |||
| 61163eadca | |||
| 1245f7b270 | |||
| 313bb1b2cc | |||
| 386ce76761 | |||
| ca4fb8791b | |||
| 8a4cab3cc7 | |||
| 7716371c8d | |||
| ccc6aa27e8 | |||
| 91ec8559b6 | |||
| 2c8fe8f9d6 | |||
| 9517871afe | |||
| a1cf1ddd9d | |||
| 04861a48cb | |||
| d8bbc1b245 | |||
| a23987ae22 | |||
| 4344bfad3c | |||
| ca8647bdc7 | |||
| c5451678e8 | |||
| a670a0c793 | |||
| f5827b52d3 | |||
| 9ac9ba8184 | |||
| 631328ea7c | |||
| 668838f7fb | |||
| 90071ce495 | |||
| dce7928d90 | |||
| edf87da8b5 | |||
| 74f57c52bc | |||
| e5ca464e62 | |||
| 072eccebef | |||
| b0d897ed32 | |||
| 59291b0e8f | |||
| 1a9d862584 | |||
| d60ae7dafa | |||
| f19f649849 | |||
| e844a74a02 | |||
| c5042f8a51 | |||
| 70e58ccf8e | |||
| a7f816fe5b | |||
| a1bc6eba90 | |||
| 11c203eef6 | |||
| bab6a812a6 | |||
| 786a7c30fc | |||
| f22af7ee4c | |||
| 1f89f8edfe | |||
| 47a9d7be9c | |||
| 114bc8aff2 | |||
| 1c9a041a31 | |||
| 95387c4096 | |||
| 89e5b4db5d | |||
| 747a1c0ad3 | |||
| ee6a98398f | |||
| 1708401bcd | |||
| e36347210c | |||
| fd38823fa4 | |||
| 683f879bdf | |||
| 6eba0cd296 | |||
| 00c3dfb7da | |||
| 9b6aab71d8 | |||
| fa19a34f7a | |||
| e36af9e7a9 | |||
| a20267091a | |||
| 93ba337538 | |||
| c5321f4f12 | |||
| 38b5b60160 | |||
| 2e863c8b43 | |||
| b084e5b468 | |||
| 2c3856a5a0 | |||
| 1c10c2d8af | |||
| 4a8703b037 | |||
| 309906cb53 | |||
| f89a059c6b | |||
| 10256a1b74 | |||
| 4ac7a3c5b8 | |||
| ac12d98c7f | |||
| 48d71ecffb | |||
| b79d6718d6 | |||
| e62036da45 | |||
| 427d999c62 | |||
| f9f9afed7a | |||
| a54af57881 | |||
| 0501e96cd2 | |||
| e5224d090b | |||
| 50fb2c04df | |||
| b965d8d766 | |||
| f474b97a57 | |||
| e2f0e6faf9 | |||
| ebb141994f | |||
| fd701857ec | |||
| a988f5935a | |||
| cb42f0eda4 | |||
| 29f74f0861 | |||
| 93ebce18ca | |||
| 5c1773e2f1 | |||
| 8cba7b6c07 | |||
| 391abdff2a | |||
| 925365fcb5 | |||
| f0088d0c26 | |||
| 71d7913f2f | |||
| ba06c95b16 | |||
| df2a6d68ec | |||
| 569d7ff8a2 | |||
| e37eec67dc | |||
| cf1d187b6d | |||
| bb2ae19091 | |||
| 32d6d0eb9d | |||
| 8df0a476c0 | |||
| e122e084c0 | |||
| 3b6b16d0e1 | |||
| 7269ed6cc1 | |||
| a97106d9b8 | |||
| acaff441bc | |||
| e7b1d3eeff | |||
| 8120597ae1 | |||
| 50d2d9642a | |||
| ae13650bfd | |||
| e819f6f89b | |||
| 9656588bf2 | |||
| bd35e57147 | |||
| 283b2e7510 | |||
| dce28d5bb8 | |||
| c22f5e9c70 | |||
| f38df083db | |||
| b548d18e4f | |||
| 8ad7fd09d5 | |||
| 4ddaafbdef | |||
| 55011c06ca | |||
| a1fce0d467 | |||
| d9822c20ff | |||
| a339053733 | |||
| e5d5ae4ce7 | |||
| 7e98f917d1 | |||
| 8ac0d0da3e | |||
| bd078438c0 | |||
| 938ba71869 | |||
| e67f2a17c6 | |||
| 53666760fc | |||
| 6cd6a5d753 | |||
| 1fd5abc5cf | |||
| 7c99f26667 | |||
| 454c561f71 | |||
| bd0188c737 | |||
| bdd42e0d78 | |||
| c0f8c82cf1 | |||
| c710a1390b | |||
| 5b0c85685c | |||
| 4aa8bf7a28 | |||
| 11c680a3a1 | |||
| e5fd3c8ad1 | |||
| 53ac0b94c5 | |||
| 6c7bb6cfba | |||
| 18d5dcf5f4 | |||
| cf62c9b8a6 | |||
| f0c50adb5d | |||
| 8b378ea6c9 | |||
| 63d09fac2b | |||
| 86135037f3 | |||
| af487c5f6f | |||
| 4e0291fb44 | |||
| 2c2bd618fa | |||
| b06201e503 | |||
| a027534cc4 | |||
| c7d4f21f3d | |||
| 28e2c12b7f | |||
| ed0250f279 | |||
| 495f0e36e1 | |||
| ebcd2ceb63 | |||
| 2cda8dec39 | |||
| 3bf90f8614 | |||
| ce891e8515 | |||
| e32c84da1c | |||
| b131b82c21 | |||
| 490c432e17 | |||
| 7970359543 | |||
| 4f77025f5d | |||
| 7dfedd3245 | |||
| 7380644d69 | |||
| 45b3659f31 | |||
| 432c7666a9 | |||
| fee8255480 | |||
| bc5e47528c | |||
| ebebef7341 | |||
| 625ecdd8a6 | |||
| f49e30bbb6 | |||
| 76f23b4058 | |||
| 902e9d8dfb | |||
| f8742351e3 | |||
| 9c9818a4b1 | |||
| 9071d505a5 | |||
| 4a81eb6130 | |||
| 0acba78f7b | |||
| 16e1de3317 | |||
| 0f94a76258 | |||
| bf0a66388c | |||
| 3f3821581c | |||
| 8095539dd4 | |||
| 04cfa5e7a8 | |||
| 4495d0c53d | |||
| 50b1584cbe | |||
| b8c7edc14f | |||
| 2f55b26bb2 | |||
| ddf395b0c1 | |||
| aeb7b52263 | |||
| 20f7145981 | |||
| b8a3b40e0b | |||
| 8647f5614c | |||
| e7c8fdbd79 | |||
| 1eb1fa1fb9 | |||
| f0cf4e2c14 | |||
| 6e1b0786d8 | |||
| fa2c9968e6 | |||
| 585e66c257 | |||
| a040f33d6a | |||
| 6f3c881322 | |||
| ce33eb900d | |||
| 0bb499e039 | |||
| a8a1f6d290 | |||
| bcc3089f53 | |||
| c44ec56207 | |||
| 90c34949b8 | |||
| 19ab0e2fc4 | |||
| 2b4eeadbbb | |||
| 1aa111ffd2 | |||
| 651bbd0142 | |||
| 424327c762 | |||
| eed05e56c8 | |||
| c72be74727 | |||
| 402a767d33 | |||
| 16f715a1e0 | |||
| e344fe2e9e | |||
| 4f1186c75e | |||
| 145657c1b8 | |||
| a8f521e63b | |||
| c6f21d2703 | |||
| a0fee54e43 | |||
| 6eb394d6ad | |||
| 0217c8ce28 | |||
| c187e3eee8 | |||
| ca169de6f9 | |||
| ef4cd8aedc | |||
| bda4db5e5b | |||
| 9dd36f308f | |||
| 355dd0ffbb | |||
| 2c78407f7f | |||
| 35a29d85d3 | |||
| d3d90d3775 | |||
| 33fb16a75a | |||
| b47cfa549d | |||
| b9a95eea68 | |||
| f13c7ab9af | |||
| 2a7201a2a4 | |||
| 38df02514a | |||
| e786de7349 | |||
| 99e72cdcba | |||
| 7f416700de | |||
| 02000b9889 | |||
| 6782f79df0 | |||
| 35f9ff1d82 | |||
| 823faa4a52 | |||
| be4b30f4da | |||
| 749ef7f55a |
@@ -1 +0,0 @@
|
||||
re-trigger
|
||||
@@ -1,2 +0,0 @@
|
||||
CI trigger file - safe to delete
|
||||
updated!
|
||||
Executable → Regular
+19
-173
@@ -1,198 +1,44 @@
|
||||
# ============================================================
|
||||
# 小虾 SaaS 环境变量完整配置
|
||||
# ============================================================
|
||||
# 本文件列出所有可配置的环境变量及默认值。
|
||||
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
|
||||
#
|
||||
# 配置读取规则(pydantic-settings,大小写不敏感):
|
||||
# 1. 系统环境变量(最高优先级)
|
||||
# 2. .env.{APP_ENV} 文件(如 .env.staging)
|
||||
# 3. .env 文件
|
||||
# 4. 代码中的默认值(最低优先级)
|
||||
# ============================================================
|
||||
|
||||
|
||||
# ==================== 应用基本配置 ====================
|
||||
|
||||
# 应用名称
|
||||
APP_NAME=xiaoxia-saas
|
||||
|
||||
# 应用版本号(展示用,代码中已内置默认)
|
||||
APP_VERSION=0.1.61
|
||||
|
||||
# 环境标识:development / staging / production
|
||||
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
|
||||
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
|
||||
# 小虾 SaaS 环境变量配置
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
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 配置 ====================
|
||||
CORS_ORIGINS=["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
|
||||
|
||||
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
|
||||
# ==================== 阿里云 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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# =======================
|
||||
# 生产环境配置
|
||||
# =======================
|
||||
APP_ENV=production
|
||||
APP_NAME=xiaoxia-saas
|
||||
APP_VERSION=0.1.0
|
||||
DEBUG=false
|
||||
|
||||
# =======================
|
||||
# API 服务配置
|
||||
# =======================
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_PREFIX=/api/v1
|
||||
|
||||
# =======================
|
||||
# Web 前端配置
|
||||
# =======================
|
||||
WEB_PORT=3000
|
||||
WEB_URL=https://xiaoxiajianji.com
|
||||
|
||||
# =======================
|
||||
# 数据库配置
|
||||
# =======================
|
||||
DATABASE_URL=postgresql+psycopg://postgres:CHANGE_ME@postgres:5432/xiaoxia_saas_production
|
||||
DATABASE_POOL_SIZE=50
|
||||
DATABASE_MAX_OVERFLOW=100
|
||||
DATABASE_POOL_TIMEOUT=30
|
||||
DATABASE_POOL_RECYCLE=3600
|
||||
|
||||
# =======================
|
||||
# Redis 配置
|
||||
# =======================
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
REDIS_MAX_CONNECTIONS=100
|
||||
|
||||
# =======================
|
||||
# Celery Worker 配置
|
||||
# =======================
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
CELERY_WORKER_CONCURRENCY=8
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD=1000
|
||||
|
||||
# =======================
|
||||
# MinIO 对象存储配置
|
||||
# =======================
|
||||
MINIO_ENDPOINT=47.98.113.167:9000
|
||||
MINIO_ACCESS_KEY=CHANGE_ME_PRODUCTION
|
||||
MINIO_SECRET_KEY=CHANGE_ME_PRODUCTION
|
||||
MINIO_BUCKET=xiaoxia-assets
|
||||
MINIO_SECURE=false
|
||||
MINIO_PUBLIC_URL=http://47.98.113.167:9000
|
||||
|
||||
# =======================
|
||||
# 日志配置
|
||||
# =======================
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
LOG_FILE=/var/log/xiaoxia-saas/app.log
|
||||
|
||||
# =======================
|
||||
# CORS 配置
|
||||
# =======================
|
||||
CORS_ORIGINS=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
|
||||
# =======================
|
||||
# 文件上传限制
|
||||
# =======================
|
||||
MAX_UPLOAD_SIZE_MB=2000
|
||||
ALLOWED_FILE_TYPES=video/mp4,video/quicktime,video/x-msvideo,audio/mpeg,audio/wav,image/jpeg,image/png,image/gif
|
||||
|
||||
# =======================
|
||||
# 安全配置
|
||||
# =======================
|
||||
SECRET_KEY=CHANGE_ME_TO_RANDOM_STRING_AT_LEAST_32_CHARS_IN_PRODUCTION
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# =======================
|
||||
# 监控与追踪(可选)
|
||||
# =======================
|
||||
# SENTRY_DSN=
|
||||
# PROMETHEUS_PORT=9090
|
||||
Executable → Regular
+13
-49
@@ -1,66 +1,30 @@
|
||||
# 生产环境配置模板(实际使用时复制为 .env.production)
|
||||
|
||||
# ==================== 基础配置 ====================
|
||||
APP_ENV=production
|
||||
ENVIRONMENT=production
|
||||
DEBUG=false
|
||||
USE_IN_MEMORY_DB=false
|
||||
LOG_LEVEL=WARNING
|
||||
|
||||
# ==================== 数据库(必须修改)====================
|
||||
# 数据库(必须修改)
|
||||
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
|
||||
|
||||
# ==================== Redis(必须修改)====================
|
||||
# Redis(必须修改)
|
||||
REDIS_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0
|
||||
ENABLE_REDIS_SESSIONS=false
|
||||
|
||||
# ==================== JWT(必须修改,至少 32 字符)====================
|
||||
# JWT(必须修改,至少 32 字符)
|
||||
JWT_SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_SECRET_KEY_AT_LEAST_32_CHARS
|
||||
|
||||
# ==================== 邮件(必须配置)====================
|
||||
ENABLE_EMAIL_DELIVERY=false
|
||||
# SMTP(必须配置)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=CHANGE_ME_SMTP_USER
|
||||
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
SMTP_FROM_EMAIL=noreply@yourdomain.com
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
APP_BASE_URL=https://yourdomain.com
|
||||
# 应用配置
|
||||
BASE_URL=https://yourdomain.com
|
||||
|
||||
# ==================== CORS(修改为实际域名,逗号分隔)====================
|
||||
CORS_ORIGINS_RAW=https://yourdomain.com,https://app.yourdomain.com
|
||||
# CORS(修改为实际域名)
|
||||
CORS_ORIGINS=["https://yourdomain.com","https://app.yourdomain.com"]
|
||||
|
||||
# ==================== 阿里云 OSS(必须配置)====================
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=CHANGE_ME_ACCESS_KEY_ID
|
||||
OSS_ACCESS_KEY_SECRET=CHANGE_ME_ACCESS_KEY_SECRET
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
OSS_DIRECT_UPLOAD_MAX_MB=2000
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||
|
||||
# ==================== 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_API_KEY=CHANGE_ME_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
|
||||
|
||||
# ==================== 生成文件 ====================
|
||||
GENERATED_FILES_DIR=/app/generated
|
||||
GENERATED_FILES_URL_PREFIX=/generated-files
|
||||
PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com
|
||||
|
||||
# ==================== Celery ====================
|
||||
CELERY_BROKER_URL=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://:CHANGE_THIS_PASSWORD@redis-prod:6379/1
|
||||
|
||||
# ==================== 监控(可选)====================
|
||||
# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
|
||||
# 监控(可选)
|
||||
SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# =======================
|
||||
# Staging 环境配置
|
||||
# =======================
|
||||
APP_ENV=staging
|
||||
APP_NAME=xiaoxia-saas
|
||||
APP_VERSION=0.1.0
|
||||
DEBUG=true
|
||||
|
||||
# =======================
|
||||
# API 服务配置
|
||||
# =======================
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_PREFIX=/api/v1
|
||||
|
||||
# =======================
|
||||
# Web 前端配置
|
||||
# =======================
|
||||
WEB_PORT=3000
|
||||
WEB_URL=http://47.98.113.167:3001
|
||||
|
||||
# =======================
|
||||
# 数据库配置
|
||||
# =======================
|
||||
DATABASE_URL=postgresql+psycopg://postgres:CHANGE_ME_STAGING_DB_PASSWORD@postgres:5432/xiaoxia_saas_staging
|
||||
DATABASE_POOL_SIZE=20
|
||||
DATABASE_MAX_OVERFLOW=40
|
||||
DATABASE_POOL_TIMEOUT=30
|
||||
DATABASE_POOL_RECYCLE=3600
|
||||
|
||||
# =======================
|
||||
# Redis 配置
|
||||
# =======================
|
||||
REDIS_URL=redis://redis:6379/1
|
||||
REDIS_MAX_CONNECTIONS=50
|
||||
|
||||
# =======================
|
||||
# Celery Worker 配置
|
||||
# =======================
|
||||
CELERY_BROKER_URL=redis://redis:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
CELERY_WORKER_CONCURRENCY=4
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD=1000
|
||||
|
||||
# =======================
|
||||
# MinIO 对象存储配置
|
||||
# =======================
|
||||
MINIO_ENDPOINT=47.98.113.167:9000
|
||||
MINIO_ACCESS_KEY=CHANGE_ME_STAGING
|
||||
MINIO_SECRET_KEY=CHANGE_ME_STAGING
|
||||
MINIO_BUCKET=xiaoxia-assets
|
||||
MINIO_SECURE=false
|
||||
MINIO_PUBLIC_URL=http://47.98.113.167:9000
|
||||
|
||||
# =======================
|
||||
# 日志配置
|
||||
# =======================
|
||||
LOG_LEVEL=DEBUG
|
||||
LOG_FORMAT=json
|
||||
LOG_FILE=/var/log/xiaoxia-saas/staging.log
|
||||
|
||||
# =======================
|
||||
# CORS 配置
|
||||
# =======================
|
||||
CORS_ORIGINS=http://47.98.113.167:3001,http://47.98.113.167:8001
|
||||
CORS_ALLOW_CREDENTIALS=true
|
||||
|
||||
# =======================
|
||||
# 文件上传限制
|
||||
# =======================
|
||||
MAX_UPLOAD_SIZE_MB=1000
|
||||
ALLOWED_FILE_TYPES=video/mp4,video/quicktime,video/x-msvideo,audio/mpeg,audio/wav,image/jpeg,image/png,image/gif
|
||||
|
||||
# =======================
|
||||
# 安全配置
|
||||
# =======================
|
||||
SECRET_KEY=CHANGE_ME_STAGING_SECRET_KEY
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# =======================
|
||||
# 监控与追踪(可选)
|
||||
# =======================
|
||||
# SENTRY_DSN=
|
||||
# PROMETHEUS_PORT=9090
|
||||
@@ -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
|
||||
|
||||
# ====== 模式1:PR关闭时清理 ======
|
||||
- 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
|
||||
|
||||
# ====== 模式2:Cron全量清理 ======
|
||||
- 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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,96 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'feature/**'
|
||||
- 'bugfix/**'
|
||||
- 'hotfix/**'
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ubuntu-latest
|
||||
container: xiaoxia-ci-python:3.12
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
api_url = os.environ['GITHUB_API_URL']
|
||||
repository = os.environ['GITHUB_REPOSITORY']
|
||||
sha = os.environ['GITHUB_SHA']
|
||||
token = os.environ.get('GITHUB_TOKEN', '')
|
||||
archive_url = f"{api_url}/repos/{repository}/archive/{sha}.tar.gz"
|
||||
request = urllib.request.Request(archive_url)
|
||||
if token:
|
||||
request.add_header('Authorization', f'token {token}')
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
with open('/tmp/repo.tar.gz', 'wb') as archive:
|
||||
archive.write(response.read())
|
||||
with tarfile.open('/tmp/repo.tar.gz', 'r:gz') as archive:
|
||||
members = archive.getmembers()
|
||||
top_level = members[0].name.split('/')[0] + '/'
|
||||
for member in members:
|
||||
member.name = member.name.removeprefix(top_level)
|
||||
if member.name:
|
||||
archive.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Verify CI environment
|
||||
run: |
|
||||
python --version
|
||||
python -m pip --version
|
||||
python -m black --version
|
||||
python -m isort --version-number
|
||||
python -m flake8 --version
|
||||
bandit --version
|
||||
pytest --version
|
||||
echo "✅ Prebuilt CI environment is ready"
|
||||
|
||||
- name: Run code quality checks
|
||||
run: |
|
||||
python -m compileall -q alembic apps packages tests scripts
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
python -m isort --check-only alembic apps packages tests scripts
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
|
||||
- name: Run security scan
|
||||
run: |
|
||||
bandit -r apps packages -q
|
||||
|
||||
- name: Validate release scripts syntax
|
||||
run: |
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
|
||||
- name: Validate Alembic migrations
|
||||
run: |
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \
|
||||
python -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
python scripts/check_schema_metadata.py
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
|
||||
- name: Build summary
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "✅ Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
@@ -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
|
||||
@@ -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
@@ -1,52 +0,0 @@
|
||||
name: CI Trigger Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *' # 每5分钟检查一次
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_threshold:
|
||||
description: 'CI未触发告警阈值(分钟)'
|
||||
required: false
|
||||
default: '5'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor CI Trigger Reliability
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/ci_trigger_monitor.py
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
name: AI Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
# 同一个 PR 只跑一个 review,新的取消旧的
|
||||
concurrency:
|
||||
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ci-l2
|
||||
# 跳过草稿 PR
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# 确保 python3-pip 可用(兼容不同基础镜像)
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
|
||||
fi
|
||||
# 部分镜像 ensurepip 方式兜底
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
||||
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
|
||||
fi
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install requests
|
||||
|
||||
- name: Run AI Code Review
|
||||
env:
|
||||
# Gitea 配置(自动从运行环境获取)
|
||||
GITEA_API_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
||||
# 可选参数
|
||||
MAX_DIFF_CHARS: "30000"
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 注意:脚本退出码决定job状态
|
||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
name: Daily Health Check
|
||||
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨 3:00
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||
production-smoke:
|
||||
name: Production Smoke Test
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
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: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
env:
|
||||
SMOKE_ENV: production
|
||||
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
|
||||
MODULES: health,assets,generation,subscription,nginx
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
BASE_URL="https://api.xiaoxiajianji.com" \
|
||||
WEB_URL="https://saas.xiaoxiajianji.com" \
|
||||
SMOKE_ENV="${SMOKE_ENV}" \
|
||||
EXISTING_TOKEN="${EXISTING_TOKEN}" \
|
||||
MODULES="${MODULES}" \
|
||||
CLEANUP_ENABLED=0 \
|
||||
PERF_CHECK_ENABLED=1 \
|
||||
PERF_WARN_THRESHOLD_MS=500 \
|
||||
PERF_FAIL_THRESHOLD_MS=5000 \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/prod-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 生产冒烟测试报告 =========="
|
||||
echo "环境: https://api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
# 提取通过/失败数
|
||||
grep "测试完成:" /tmp/prod-smoke.log || true
|
||||
if [ "$SMOKE_EXIT" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
grep "失败用例:" /tmp/prod-smoke.log || true
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
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
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
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: 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)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
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 CLEANUP_ENABLED=1 \
|
||||
-e PERF_CHECK_ENABLED=1 \
|
||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||
-e PERF_FAIL_THRESHOLD_MS=3000 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/staging-api-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging API 冒烟测试报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep "测试完成:" /tmp/staging-api-smoke.log || true
|
||||
if [ "$SMOKE_EXIT" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "api_report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
grep "失败用例:" /tmp/staging-api-smoke.log || true
|
||||
echo "api_report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=============================================="
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
- name: Run Staging API Integration Tests (Playwright)
|
||||
id: e2e_api
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1 | tee /tmp/staging-api-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging API 集成测试报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep -E "passed|failed|timed out" /tmp/staging-api-e2e.log || true
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "int_report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
echo "int_report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=============================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Set report output
|
||||
id: report
|
||||
shell: sh
|
||||
run: |
|
||||
if [ "${{ steps.smoke.outputs.api_report }}" = "PASS" ] && [ "${{ steps.e2e_api.outputs.int_report }}" = "PASS" ]; then
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
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
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.e2e.outputs.report }}
|
||||
|
||||
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: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging E2E 测试报告 =========="
|
||||
echo "环境: https://staging.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep -E "passed|failed|timed out" /tmp/staging-e2e.log || true
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
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
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
steps:
|
||||
- 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)
|
||||
echo "=========================================="
|
||||
echo " 性能基线巡检 - Staging API"
|
||||
echo " 目标: https://staging-api.xiaoxiajianji.com"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
TOTAL=0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
WARN_LIST=""
|
||||
FAIL_LIST=""
|
||||
|
||||
# 核心接口配置: 名称|路径|方法|阈值(ms)|失败阈值(ms)
|
||||
# 核心接口(core): 500ms
|
||||
# 普通接口(normal): 1000ms
|
||||
# 重操作接口(heavy): 3000ms
|
||||
ENDPOINTS="
|
||||
登录|/api/v1/auth/login|POST|500|3000
|
||||
获取当前用户|/api/v1/auth/me|GET|500|3000
|
||||
项目列表|/api/v1/projects|GET|500|3000
|
||||
素材列表|/api/v1/assets|GET|500|3000
|
||||
模板列表|/api/v1/templates|GET|500|3000
|
||||
剪辑计划列表|/api/v1/edit-plans|GET|500|3000
|
||||
生成任务列表|/api/v1/generation/tasks|GET|500|3000
|
||||
订阅信息|/api/v1/subscription/current|GET|500|3000
|
||||
音色列表|/api/v1/voices|GET|1000|5000
|
||||
健康检查|/health|GET|200|1000
|
||||
"
|
||||
|
||||
# 先登录获取 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" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
|
||||
|
||||
if [ "$AUTH_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null)
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo "Token 获取成功"
|
||||
else
|
||||
echo "Token 解析失败,部分接口可能无法测试"
|
||||
TOKEN=""
|
||||
fi
|
||||
else
|
||||
echo "登录失败 (HTTP $AUTH_CODE),部分接口将跳过鉴权测试"
|
||||
TOKEN=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 开始性能测试 ---"
|
||||
echo ""
|
||||
|
||||
echo "$ENDPOINTS" | while IFS='|' read -r name path method warn_ms fail_ms; do
|
||||
[ -z "$name" ] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# 构建 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\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
fi
|
||||
|
||||
# 执行请求
|
||||
RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
|
||||
HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
|
||||
TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
|
||||
ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$HTTP_CODE" -ge 500 ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_LIST="$FAIL_LIST\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
|
||||
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms (FAIL)"
|
||||
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_LIST="$FAIL_LIST\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms"
|
||||
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms (FAIL)"
|
||||
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
|
||||
WARN=$((WARN + 1))
|
||||
WARN_LIST="$WARN_LIST\n ⚠️ $name - ${ELAPSED_MS}ms > ${warn_ms}ms"
|
||||
echo "⚠️ $name - ${ELAPSED_MS}ms (WARN, threshold: ${warn_ms}ms)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
echo "✅ $name - ${ELAPSED_MS}ms (OK, threshold: ${warn_ms}ms)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 由于 while 在子 shell 中执行,用文件传递结果
|
||||
# 重新跑一次用文件计数方式
|
||||
echo ""
|
||||
echo "--- 汇总性能数据 ---"
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 性能基线巡检报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
- 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 ""
|
||||
echo "=========================================="
|
||||
echo " 性能基线巡检 - 详细报告"
|
||||
echo "=========================================="
|
||||
|
||||
TOTAL=0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
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" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
|
||||
TOKEN=""
|
||||
if [ "$AUTH_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
run_perf_test() {
|
||||
local name="$1" path="$2" method="$3" warn_ms="$4" fail_ms="$5"
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
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\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
fi
|
||||
|
||||
local RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
|
||||
local HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
|
||||
local TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
|
||||
local ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
|
||||
|
||||
if echo "$HTTP_CODE" | grep -q "^[5]"; then
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
|
||||
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms [FAIL]"
|
||||
return 1
|
||||
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
|
||||
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
|
||||
return 1
|
||||
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
|
||||
WARN=$((WARN + 1))
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS\n ⚠️ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [WARN]"
|
||||
echo "⚠️ $name - ${ELAPSED_MS}ms > 阈值 ${warn_ms}ms [WARN]"
|
||||
return 0
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS\n ✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
|
||||
echo "✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== 核心接口 (阈值: 500ms / 3000ms) ==="
|
||||
run_perf_test "登录" "/api/v1/auth/login" "POST" 500 3000 || true
|
||||
run_perf_test "获取当前用户" "/api/v1/auth/me" "GET" 500 3000 || true
|
||||
run_perf_test "项目列表" "/api/v1/projects" "GET" 500 3000 || true
|
||||
run_perf_test "素材列表" "/api/v1/assets" "GET" 500 3000 || true
|
||||
run_perf_test "模板列表" "/api/v1/templates" "GET" 500 3000 || true
|
||||
run_perf_test "剪辑计划列表" "/api/v1/edit-plans" "GET" 500 3000 || true
|
||||
run_perf_test "生成任务列表" "/api/v1/generation/tasks" "GET" 500 3000 || true
|
||||
run_perf_test "订阅信息" "/api/v1/subscription/current" "GET" 500 3000 || true
|
||||
|
||||
echo ""
|
||||
echo "=== 普通接口 (阈值: 1000ms / 5000ms) ==="
|
||||
run_perf_test "音色列表" "/api/v1/voices" "GET" 1000 5000 || true
|
||||
|
||||
echo ""
|
||||
echo "=== 基础接口 (阈值: 200ms / 1000ms) ==="
|
||||
run_perf_test "健康检查" "/health" "GET" 200 1000 || true
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 性能基线巡检报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "总接口: ${TOTAL}"
|
||||
echo "通过: ${PASS}"
|
||||
echo "失败: ${FAIL}"
|
||||
echo "警告: ${WARN}"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
# 写入结果文件供 report job 使用
|
||||
echo "${TOTAL}" > /tmp/perf_total
|
||||
echo "${PASS}" > /tmp/perf_pass
|
||||
echo "${FAIL}" > /tmp/perf_fail
|
||||
echo "${WARN}" > /tmp/perf_warn
|
||||
echo "${ELAPSED}" > /tmp/perf_elapsed
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
echo "perf_detail=fail:${FAIL}:warn:${WARN}" >> "${GITHUB_OUTPUT}"
|
||||
exit 1
|
||||
else
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
if [ "$WARN" -gt 0 ]; then
|
||||
echo "perf_detail=pass:warn:${WARN}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "perf_detail=pass" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
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
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
needs:
|
||||
- production-smoke
|
||||
- staging-api-tests
|
||||
- staging-e2e
|
||||
- performance-check
|
||||
|
||||
steps:
|
||||
- name: Print summary report
|
||||
shell: sh
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ 每日巡检报告 ║"
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
|
||||
# 获取各 job 状态
|
||||
PROD_STATUS="${{ needs.production-smoke.result }}"
|
||||
STAGING_API_STATUS="${{ needs.staging-api-tests.result }}"
|
||||
STAGING_E2E_STATUS="${{ needs.staging-e2e.result }}"
|
||||
PERF_STATUS="${{ needs.performance-check.result }}"
|
||||
|
||||
format_result() {
|
||||
if [ "$1" = "success" ]; then
|
||||
echo "✅ PASS"
|
||||
elif [ "$1" = "failure" ]; then
|
||||
echo "❌ FAIL"
|
||||
elif [ "$1" = "skipped" ]; then
|
||||
echo "⏭️ SKIP"
|
||||
else
|
||||
echo "❓ UNKNOWN ($1)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "║"
|
||||
echo "║ 生产冒烟测试: $(format_result "$PROD_STATUS")"
|
||||
echo "║ Staging API: $(format_result "$STAGING_API_STATUS")"
|
||||
echo "║ Staging E2E: $(format_result "$STAGING_E2E_STATUS")"
|
||||
echo "║ 性能基线巡检: $(format_result "$PERF_STATUS")"
|
||||
echo "║"
|
||||
echo "║ 巡检时间: $(date '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "║"
|
||||
|
||||
# 判断整体状态
|
||||
ALL_PASS=true
|
||||
FAILED_ITEMS=""
|
||||
for status_name in "$PROD_STATUS:生产冒烟" "$STAGING_API_STATUS:Staging API" "$STAGING_E2E_STATUS:Staging E2E" "$PERF_STATUS:性能基线"; do
|
||||
STATUS=$(echo "$status_name" | cut -d: -f1)
|
||||
NAME=$(echo "$status_name" | cut -d: -f2)
|
||||
if [ "$STATUS" != "success" ] && [ "$STATUS" != "skipped" ]; then
|
||||
ALL_PASS=false
|
||||
FAILED_ITEMS="$FAILED_ITEMS $NAME"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
if [ "$ALL_PASS" = "true" ]; then
|
||||
echo "║ 整体状态: ✅ 全部通过 ║"
|
||||
else
|
||||
echo "║ 整体状态: ❌ 存在失败 ║"
|
||||
echo "║ 失败项: ${FAILED_ITEMS} ║"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 如果有失败项,以非零退出码结束(方便 Gitea 标记流水线失败)
|
||||
if [ "$ALL_PASS" = "false" ]; then
|
||||
echo "⚠️ 部分巡检项失败,请检查上方日志获取详细信息。"
|
||||
# 不 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
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
name: Deploy Staging
|
||||
runs-on: runtime-builder
|
||||
if: github.ref_name == 'main' || github.ref_name == 'develop'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||
wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url"
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Build staging web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
-t "xiaoxia-saas-web:staging-${GITHUB_SHA}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
- name: Package staging release artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf dist/staging-artifacts
|
||||
mkdir -p dist/staging-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz .
|
||||
docker save -o "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" "xiaoxia-saas-web:staging-${GITHUB_SHA}"
|
||||
|
||||
- name: Upload staging artifact to business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$staging_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "mkdir -p /var/lib/xiaoxia-saas-staging/artifacts"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
scp -i "$key_path" "dist/staging-artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar" \
|
||||
"$staging_user@$staging_host:/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
|
||||
- name: Deploy staging stack on business host
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
if [ -n "${STAGING_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
fi
|
||||
ssh -i "$key_path" "$staging_user@$staging_host" "GITHUB_SHA='${GITHUB_SHA}' sh -s" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
artifact="/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-staging-${GITHUB_SHA}.tar.gz"
|
||||
image_tar="/var/lib/xiaoxia-saas-staging/artifacts/xiaoxia-web-staging-${GITHUB_SHA}.tar"
|
||||
test -f "$artifact"
|
||||
test -f "$image_tar"
|
||||
test -f /var/lib/xiaoxia-saas-staging/.env
|
||||
docker load -i "$image_tar"
|
||||
rm -rf /var/lib/xiaoxia-saas-staging/repo
|
||||
mkdir -p /var/lib/xiaoxia-saas-staging/repo
|
||||
tar -xzf "$artifact" -C /var/lib/xiaoxia-saas-staging/repo
|
||||
test -f /var/lib/xiaoxia-saas-staging/repo/apps/web/dist/index.html
|
||||
cp /var/lib/xiaoxia-saas-staging/.env /var/lib/xiaoxia-saas-staging/repo/.env
|
||||
chmod +x /var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
WEB_IMAGE="xiaoxia-saas-web:staging-${GITHUB_SHA}" HOST_PREFIX= WEB_PORT=3001 REBUILD_BACKEND=0 BUILD_WEB=0 RUN_MIGRATIONS=0 /var/lib/xiaoxia-saas-staging/repo/infra/docker/deploy-staging.sh
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if wget -qO- http://127.0.0.1:8000/health; then
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
REMOTE_DEPLOY
|
||||
|
||||
build-production-runtime-images:
|
||||
name: Build Production Runtime Images
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||
wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url"
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Build runtime image artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
chmod +x scripts/build_release_images.sh
|
||||
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Build production web artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
--pull=never \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc 'npm ci && npm run build'
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
-t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \
|
||||
.
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
- name: Package release source artifact
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist/release-artifacts
|
||||
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
|
||||
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
|
||||
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Upload runtime image and release artifacts
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
production_user="${PRODUCTION_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
|
||||
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
|
||||
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
|
||||
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: build-production-runtime-images
|
||||
|
||||
steps:
|
||||
- name: Deploy production over SSH
|
||||
shell: sh
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||||
production_user="${PRODUCTION_SSH_USER:-root}"
|
||||
mkdir -p ~/.ssh
|
||||
if [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
else
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
test -f "$key_path"
|
||||
fi
|
||||
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
|
||||
ssh -i "$key_path" "$production_user@$production_host" \
|
||||
"RELEASE_VERSION='${GITHUB_REF_NAME}' sh -s" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
release_tar="/var/lib/xiaoxia-saas-production/release-${RELEASE_VERSION}.tar.gz"
|
||||
test -f "$release_tar"
|
||||
test -f "/var/lib/xiaoxia-saas-production/runtime-images-${RELEASE_VERSION}.tar"
|
||||
test -f "/var/lib/xiaoxia-saas-production/web-${RELEASE_VERSION}.tar"
|
||||
mkdir -p /var/lib/xiaoxia-saas-production
|
||||
old_assets_dir="/tmp/xiaoxia-previous-web-assets-${RELEASE_VERSION}"
|
||||
rm -rf "$old_assets_dir"
|
||||
mkdir -p "$old_assets_dir"
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$old_assets_dir"/ 2>/dev/null || true
|
||||
fi
|
||||
if [ -d /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets ]; then
|
||||
cp -a /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/. "$old_assets_dir"/
|
||||
fi
|
||||
rm -rf /var/lib/xiaoxia-saas-production/repo
|
||||
mkdir -p /var/lib/xiaoxia-saas-production/repo
|
||||
tar -xzf "$release_tar" -C /var/lib/xiaoxia-saas-production/repo
|
||||
test -f /var/lib/xiaoxia-saas-production/repo/apps/web/dist/index.html
|
||||
if [ -d "$old_assets_dir" ]; then
|
||||
mkdir -p /var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets
|
||||
for asset in "$old_assets_dir"/*; do
|
||||
[ -e "$asset" ] || continue
|
||||
name="$(basename "$asset")"
|
||||
if [ ! -e "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name" ]; then
|
||||
cp -a "$asset" "/var/lib/xiaoxia-saas-production/repo/apps/web/dist/assets/$name"
|
||||
fi
|
||||
done
|
||||
rm -rf "$old_assets_dir"
|
||||
fi
|
||||
test -f /var/lib/xiaoxia-saas-production/.env
|
||||
cp /var/lib/xiaoxia-saas-production/.env /var/lib/xiaoxia-saas-production/repo/.env
|
||||
HOST_PREFIX= WEB_IMAGE="xiaoxia-saas-web:${RELEASE_VERSION}" WEB_IMAGE_TAR="/var/lib/xiaoxia-saas-production/web-${RELEASE_VERSION}.tar" sh /var/lib/xiaoxia-saas-production/repo/infra/docker/deploy-production.sh
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if wget -qO- http://127.0.0.1:8001/health; then
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
REMOTE_DEPLOY
|
||||
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: runtime-builder
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
archive_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz"
|
||||
wget --header="Authorization: token ${GITHUB_TOKEN}" -O /tmp/repo.tar.gz "$archive_url"
|
||||
tar -xzf /tmp/repo.tar.gz --strip-components=1 -C .
|
||||
rm -f /tmp/repo.tar.gz
|
||||
|
||||
- name: Run production browser E2E
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://saas-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
mcr.microsoft.com/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
@@ -1,56 +0,0 @@
|
||||
name: PR Auto Scan
|
||||
# 定时扫描所有open PR,对CI全绿的触发审批/合并
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/5 * * * *" # 每5分钟扫描一次
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
auto-scan:
|
||||
name: Auto Scan Open PRs
|
||||
runs-on: ci-check
|
||||
timeout-minutes: 5
|
||||
if: github.repository == 'xiaoxia/xiaoxia-saas'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/pr_auto_scan.py?ref=develop" -o /tmp/pr_auto_scan.py
|
||||
python3 /tmp/pr_auto_scan.py --help > /dev/null 2>&1 || {
|
||||
# fallback: checkout
|
||||
echo "使用checkout方式"
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=develop" | bash
|
||||
}
|
||||
|
||||
- name: Scan and auto process PRs
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "=== 扫描所有open PR并自动处理 ==="
|
||||
echo "时间: $(date)"
|
||||
echo
|
||||
|
||||
python3 /tmp/pr_auto_scan.py --token "$REVIEW_TOKEN" --repo "$GITHUB_REPOSITORY" --base develop --approve --merge --dry-run false
|
||||
|
||||
echo ""
|
||||
echo "✅ 扫描完成"
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "" || true
|
||||
@@ -1,118 +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: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: "🔍 脚本语法自检"
|
||||
shell: bash
|
||||
run: |
|
||||
ERROR=0
|
||||
for f in scripts/ci/*.sh; do [ -f "$f" ] && bash -n "$f" 2>&1 || ERROR=$((ERROR+1)); done
|
||||
for f in scripts/ci/*.py; do [ -f "$f" ] && python3 -m py_compile "$f" 2>&1 || ERROR=$((ERROR+1)); done
|
||||
if [ "$ERROR" -ne 0 ]; then echo "❌ 语法自检失败 ($ERROR个)"; exit 1; fi
|
||||
echo "✅ 脚本语法自检通过"
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash scripts/ci/auto_approve.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
|
||||
- name: "🔍 脚本语法自检(防止脚本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
|
||||
@@ -1,207 +0,0 @@
|
||||
name: Preview Cleanup
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
jobs:
|
||||
cleanup-preview:
|
||||
name: Cleanup Preview Environment
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Extract PR number
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 优先从event payload中读取(兼容所有PR事件类型)
|
||||
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
||||
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
# fallback: 从GITHUB_REF中提取
|
||||
if [ -z "${PR_NUMBER:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
|
||||
fi
|
||||
# 再fallback: 兼容纯数字ref
|
||||
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
|
||||
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
fi
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
||||
echo "PR number: $PR_NUMBER"
|
||||
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
- name: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# 先检查是否已存在ssh
|
||||
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
|
||||
echo "SSH client already available: $(ssh -V 2>&1)"
|
||||
exit 0
|
||||
fi
|
||||
# 尝试多种包管理器安装
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache openssh-client >/dev/null 2>&1
|
||||
echo "openssh-client installed via apk"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
|
||||
echo "openssh-client installed via apt-get"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y openssh-clients >/dev/null 2>&1
|
||||
echo "openssh-client installed via yum"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y openssh-clients >/dev/null 2>&1
|
||||
echo "openssh-client installed via dnf"
|
||||
else
|
||||
echo "ERROR: No package manager found and ssh not pre-installed"
|
||||
which ssh 2>/dev/null || echo " ssh: not found"
|
||||
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Remove preview directory from server
|
||||
shell: sh
|
||||
env:
|
||||
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
|
||||
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
|
||||
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
|
||||
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
|
||||
key_path=""
|
||||
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
echo "Using key from PREVIEW_SSH_KEY secret"
|
||||
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using key: $key_path (builder key)"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using key: $key_path (home key)"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
ls -la ~/.ssh/ 2>/dev/null || true
|
||||
ls -la /root/.ssh/ 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "SSH keyscan done"
|
||||
|
||||
# 测试SSH连接
|
||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
|
||||
echo "SSH connection verified"
|
||||
|
||||
# 检查目录是否存在
|
||||
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
||||
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
|
||||
|
||||
if [ "$DIR_EXISTS" = "yes" ]; then
|
||||
echo "Removing preview directory: ${preview_dir}"
|
||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
||||
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
|
||||
echo "Cleanup completed: ${preview_dir}"
|
||||
else
|
||||
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
|
||||
fi
|
||||
|
||||
- name: Comment cleanup notice on PR
|
||||
if: success()
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
# 从event payload读取PR号(最可靠)
|
||||
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
||||
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
|
||||
else
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
|
||||
fi
|
||||
export PR_NUMBER
|
||||
|
||||
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
|
||||
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$COMMENT_BODY" \
|
||||
"$API_URL" \
|
||||
> /dev/null
|
||||
echo "Cleanup comment posted"
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
name: Preview Deploy
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "手动触发 - 预览环境补跑"
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: preview-deploy-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
deploy-preview:
|
||||
name: Deploy Preview Environment
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
||||
echo "Job started at $(date)"
|
||||
|
||||
- name: Extract PR number
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
||||
echo "PR number: $PR_NUMBER"
|
||||
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
|
||||
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
||||
|
||||
- name: Build frontend
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
cd apps/web
|
||||
|
||||
# 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/
|
||||
|
||||
- name: Install SSH client and rsync
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache openssh-client rsync >/dev/null 2>&1
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y openssh-clients rsync >/dev/null 2>&1
|
||||
else
|
||||
echo "ERROR: No package manager found"
|
||||
exit 1
|
||||
fi
|
||||
echo "openssh-client and rsync installed"
|
||||
|
||||
- name: Deploy preview to server
|
||||
shell: sh
|
||||
env:
|
||||
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
|
||||
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
|
||||
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
set -eux
|
||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||
preview_user="${PREVIEW_SSH_USER:-root}"
|
||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
|
||||
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
|
||||
key_path=""
|
||||
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
|
||||
key_path="$HOME/.ssh/id_ed25519"
|
||||
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
|
||||
chmod 600 "$key_path"
|
||||
echo "Using key from PREVIEW_SSH_KEY secret"
|
||||
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using key: $key_path (builder key)"
|
||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
||||
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
|
||||
echo "Using key: $key_path (home key)"
|
||||
else
|
||||
echo "ERROR: No SSH key available"
|
||||
ls -la ~/.ssh/ 2>/dev/null || true
|
||||
ls -la /root/.ssh/ 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# SSH密钥完整性自检
|
||||
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
|
||||
echo "ERROR: SSH密钥损坏(private key contents do not match public)"
|
||||
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
|
||||
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
|
||||
head -2 "$key_path"
|
||||
exit 1
|
||||
fi
|
||||
echo "SSH key integrity check passed"
|
||||
|
||||
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "SSH keyscan done"
|
||||
|
||||
# 测试SSH连接
|
||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
|
||||
echo "SSH connection verified"
|
||||
|
||||
# 创建预览目录并上传文件
|
||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
||||
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
|
||||
|
||||
# 使用rsync上传dist目录内容
|
||||
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
|
||||
apps/web/dist/ \
|
||||
"${preview_user}@${preview_host}:${preview_dir}/"
|
||||
|
||||
echo "Preview deployed to: ${preview_dir}"
|
||||
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
||||
|
||||
- name: Comment preview link on PR
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
||||
export PR_NUMBER PREVIEW_URL
|
||||
|
||||
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
|
||||
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
|
||||
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
for c in json.load(sys.stdin):
|
||||
if '预览环境已部署' in c.get('body', ''):
|
||||
print(c['id'])
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
")
|
||||
|
||||
if [ -n "$EXISTING_COMMENT_ID" ]; then
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$COMMENT_BODY" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
|
||||
> /dev/null
|
||||
echo "Comment updated"
|
||||
else
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$COMMENT_BODY" \
|
||||
"$API_URL" \
|
||||
> /dev/null
|
||||
echo "Comment posted"
|
||||
fi
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
set +eu
|
||||
if [ -n "$JOB_START_TIME" ]; then
|
||||
END_TIME=$(date +%s)
|
||||
DURATION=$((END_TIME - JOB_START_TIME))
|
||||
MINS=$((DURATION / 60))
|
||||
SECS=$((DURATION % 60))
|
||||
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
|
||||
echo "=== Job Duration: ${MINS}m${SECS}s ==="
|
||||
else
|
||||
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
|
||||
echo "=== Job Duration: unknown ==="
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: xiaoxia-ci-python:3.12
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
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']}"})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
|
||||
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: Show Python version
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python --version
|
||||
python -m pip --version
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
- name: Run tests
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: xiaoxia-ci-python:3.12
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
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']}"})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
|
||||
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: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
|
||||
|
||||
- name: Run Black (check only)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m black --check alembic apps packages tests scripts
|
||||
|
||||
- name: Run Flake8
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python -m flake8 apps packages tests --count --statistics
|
||||
@@ -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"
|
||||
+2
-18
@@ -6,7 +6,6 @@ dist/
|
||||
coverage/
|
||||
|
||||
# Python / backend
|
||||
.cache/
|
||||
.venv/
|
||||
venv/
|
||||
.venv-ci-root/
|
||||
@@ -21,9 +20,9 @@ ruff_cache/
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.staging
|
||||
.env.production # 生产环境配置不提交(包含敏感信息)
|
||||
!.env.example
|
||||
!.env.staging # staging 配置可以提交
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
@@ -39,18 +38,3 @@ temp/
|
||||
# Build / runtime artifacts
|
||||
build/
|
||||
.runtime/
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Tracker temp files
|
||||
tracker_tasks.json
|
||||
|
||||
frontend-v21-ui-prototype-final.html
|
||||
|
||||
!.vscode/
|
||||
!.vscode/settings.json
|
||||
.vscode/extensions.json
|
||||
.coverage
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 26.5.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.12
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 8.0.1
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ["--profile", "black"]
|
||||
language_version: python3.12
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
language_version: python3.12
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v4.0.0-alpha.11
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: prettier (frontend)
|
||||
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
|
||||
additional_dependencies:
|
||||
- prettier@3.4.2
|
||||
Vendored
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.requireConfig": true,
|
||||
"isort.args": ["--profile", "black"],
|
||||
"python.linting.ruffEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
@@ -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 1(MVP,2天):**
|
||||
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 生成,请遵循相关法律法规及《人工智能生成合成内容标识办法》使用与传播。
|
||||
@@ -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 个 GenerationTask(is_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:单版本预览(MVP,2 天)
|
||||
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 天(后端)
|
||||
+11
-268
@@ -1,276 +1,19 @@
|
||||
## [v0.1.110] - 2026-07-03
|
||||
# Changelog
|
||||
|
||||
### 🔒 安全修复
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
- 注册登录接口添加 RateLimitMiddleware 防止暴力破解
|
||||
- JWT logout 黑名单机制,防止令牌重放攻击
|
||||
- 生产环境禁用 Swagger 文档防止信息泄露
|
||||
- `/metrics` 端点添加 Bearer Token 认证
|
||||
- 禁用 SVG 上传防止 XSS 风险
|
||||
- 删除 `decode_token_unsafe()` 方法,消除不安全的 JWT 解码
|
||||
- 移除遗留 `tasks.py` 消除 Celery 任务名冲突
|
||||
- 清理全局 `except:pass`(22处)改为 `logger.warning` 记录异常
|
||||
|
||||
### ✨ 功能
|
||||
|
||||
- 添加剪辑计划时间线场景 API (`GET /edit-plans/{id}/timeline`)
|
||||
- 前端对接真实 API 替换 mock 数据
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- **[P1]** 修复登录故障 — `password_hasher` 导入错误
|
||||
- 订阅续费事务修复 — 支付回调在数据库事务中更新订阅状态
|
||||
- 账单返回空数组修复 — 从数据库查询账单记录
|
||||
- 修复 `Image.open()` 资源泄漏
|
||||
- 清理已移除 workspace 概念的残留引用
|
||||
- 修复 AssetLibrary/TemplateLibrary 类型错误
|
||||
- 修复前端 workspace 残留导致项目创建失败
|
||||
- 永久修复 nginx `proxy_pass` 配置
|
||||
- 添加 Docker DNS resolver 防止 API 容器重启后 502
|
||||
- 修复 worker healthcheck YAML 语法
|
||||
- 修复 204 响应体断言崩溃
|
||||
- 修复 Alembic 元数据漂移检测
|
||||
- 修复 migration 009 DEFAULT 表达式 PostgreSQL 兼容性
|
||||
|
||||
### 🔄 重构与清理
|
||||
|
||||
- 后端代码清理 — 移除死代码和无用文件
|
||||
- 前端代码清理 — 移除无用代码和遗留 demo
|
||||
- 代码精简优化 — 移除无用代码和重复定义
|
||||
- 后端代码 black/isort 格式化
|
||||
|
||||
### 🧪 测试
|
||||
|
||||
- 完善 E2E 错误场景测试,Playwright 接入 CI
|
||||
- API 集成测试补充(145 项通过)
|
||||
- 添加核心流程 E2E 测试
|
||||
|
||||
### 🚀 CI/CD & 基础设施
|
||||
|
||||
- Validate 阶段添加 PostgreSQL 服务支持
|
||||
- 所有 workflow checkout 添加 5 次指数退避重试
|
||||
- 启用 BuildKit 分布式缓存 + Gitea Registry 优化构建速度
|
||||
- Deploy 阶段全面修复(E2E 服务器/Worker venv/Registry 登录)
|
||||
- Docker 网络隔离 staging/production 环境
|
||||
- 修复 CI 代码质量检查(black/flake8/bandit)
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.88] - 2026-06-29
|
||||
|
||||
### Phase 2 前端优化 - 完成 ✅
|
||||
|
||||
**前端交互全面优化:**
|
||||
|
||||
- 素材上传添加 project_id 参数
|
||||
- Drager 组件显示上传列表
|
||||
- 按钮防重复提交
|
||||
- 前端交互状态反馈补充(P0 第一批)
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.87] - 2026-06-29
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- Docker compose 修复 mem_limit 冲突
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.86] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.85] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.84] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.83] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.82] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.81] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.80] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 redirect_slashes + 标题字段匹配
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.79] - 2026-06-28
|
||||
|
||||
### Deployment
|
||||
|
||||
- Re-trigger deployment
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.78] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 500 错误
|
||||
- CORS 配置修复
|
||||
- redirect_slashes 禁用
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.77] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复标题库新建/编辑 — 前后端字段名不匹配导致 422
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 功能合并(v0.1.77 ~ v0.1.88)
|
||||
|
||||
**新增功能 PR:**
|
||||
|
||||
- PR#74: Phase 1 核心重构 — 标题库 API、配音库 API、去 Project 层清理
|
||||
- PR#75: Phase 2 查重功能前端页面
|
||||
- PR#76: Phase 2 查重功能后端 API(5 个端点)
|
||||
- PR#77: Phase 2 订阅管理前端页面
|
||||
- PR#78: Phase 2 订阅管理后端 API(5 个端点)
|
||||
- PR#79: 修复一键生成页面废弃 API 调用
|
||||
- PR#80: 回退域对象 extra_meta → metadata
|
||||
- PR#81: 删除查重 API 错误的 204 返回
|
||||
- PR#82: 查重上传接口错误信息不再泄露内部异常(安全审计)
|
||||
- PR#83: 订阅 + 查重单元测试(63 用例)
|
||||
- PR#84: 订阅管理前端对接真实 API
|
||||
- PR#85: 禁用 redirect_slashes 修复 307 重定向
|
||||
- PR#90: 标题库字段名修复
|
||||
- PR#91: 标题/配音创建 500 修复 + CORS
|
||||
- PR#94: 素材库新建自动获取默认 project_id
|
||||
- PR#97: 前端交互状态反馈全面补充
|
||||
|
||||
---
|
||||
|
||||
|
||||
- Docker compose 修复 mem_limit 冲突
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.86] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.85] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.84] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.83] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.82] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.81] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.80] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 redirect_slashes + 标题字段匹配
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.79] - 2026-06-28
|
||||
|
||||
### Deployment
|
||||
|
||||
- Re-trigger deployment
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.78] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 500 错误
|
||||
- CORS 配置修复
|
||||
- redirect_slashes 禁用
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.77] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复标题库新建/编辑 — 前后端字段名不匹配导致 422
|
||||
|
||||
---
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-06-19
|
||||
|
||||
### Phase 7: 核心视频剪辑业务 - 完成 ✅
|
||||
|
||||
**完成进度:** 100%
|
||||
**状态:** 已完成并验证
|
||||
|
||||
#### Added
|
||||
|
||||
**素材管理:**
|
||||
|
||||
@@ -1,68 +1,70 @@
|
||||
# 小虾 SaaS - 自动化视频剪辑平台
|
||||
# 小虾 SaaS - 自动化剪辑 SaaS 平台
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
自动化视频剪辑 SaaS 平台,支持素材上传、AI 分类、智能剪辑计划生成、自动化视频合成与成片管理。
|
||||
一个功能完整、生产就绪的多租户 SaaS 平台,专为自动化视频剪辑服务设计。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎬 视频剪辑主链路
|
||||
- 素材上传(直传 OSS + 分片上传大文件,最大 2GB)
|
||||
- AI 智能分类与质量评分
|
||||
- 4 种剪辑模式:one_take / pip(画中画)/ voice_over(口播+B-roll)/ voice_pip
|
||||
- 剪辑计划模板 + 智能生成
|
||||
- 自动化视频合成任务(Celery 异步)
|
||||
- 成片下载与审核管理
|
||||
- 资产诊断(素材就绪度评估、缺口分析)
|
||||
### 🎬 视频剪辑主链路(Phase 7 已完成)
|
||||
- 素材上传与管理
|
||||
- AI 智能分类
|
||||
- 自动化视频生成
|
||||
- 成片下载与管理
|
||||
|
||||
### 🔐 认证系统
|
||||
- JWT Bearer Token 认证
|
||||
- 邮箱注册 + 邮箱验证
|
||||
- 密码重置(邮箱找回)
|
||||
### 🔐 完整的认证系统
|
||||
- JWT 认证(access + refresh token)
|
||||
- 邮箱验证和密码重置
|
||||
- Session 管理
|
||||
- bcrypt 密码加密
|
||||
|
||||
### 📋 项目管理
|
||||
- 项目 CRUD + 共享
|
||||
- 任务管理(创建/更新/状态流转/进度追踪)
|
||||
- 里程碑管理
|
||||
- 任务问题追踪
|
||||
### 🏢 多租户架构
|
||||
- 工作空间隔离
|
||||
- 团队成员管理
|
||||
- 基于角色的权限控制(Owner/Admin/Member/Viewer)
|
||||
- 邀请和审批流程
|
||||
|
||||
### 📊 素材库管理
|
||||
- 素材库创建与管理
|
||||
- 素材上传、审核状态流转(pending_review → approved/rejected)
|
||||
- 素材诊断(就绪度评分、缺口分析、智能视图)
|
||||
### 💸 订阅管理
|
||||
- 3 级订阅计划(Free/Pro/Enterprise)
|
||||
- 配额管理(项目数/存储空间)
|
||||
- 升级和取消订阅
|
||||
|
||||
### ⚡ 高性能
|
||||
- 数据库连接池(5-6x 性能提升)
|
||||
- 请求日志和监控
|
||||
- 慢查询检测
|
||||
- 健康检查(Kubernetes 就绪)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式 1: Docker Compose(推荐)
|
||||
### 方式 1: Docker(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git
|
||||
git clone https://github.com/your-org/xiaoxia-saas.git
|
||||
cd xiaoxia-saas
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填写数据库、Redis、OSS 等配置
|
||||
|
||||
# 3. 启动所有服务
|
||||
# 2. 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 访问 API 文档
|
||||
# 3. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
就这么简单!🎉
|
||||
|
||||
### 方式 2: 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git
|
||||
git clone https://github.com/your-org/xiaoxia-saas.git
|
||||
cd xiaoxia-saas
|
||||
|
||||
# 2. 创建虚拟环境
|
||||
@@ -72,90 +74,36 @@ source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 配置环境变量
|
||||
cp .env.example .env
|
||||
|
||||
# 5. 启动 API 服务
|
||||
# 4. 启动开发服务器
|
||||
uvicorn apps.api.main:app --reload
|
||||
|
||||
# 6. 访问 API 文档
|
||||
# 5. 访问 API 文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
## 📚 主要文档
|
||||
|
||||
### 交互式文档
|
||||
- **Swagger UI**: https://saas-api.xiaoxiajianji.com/docs
|
||||
- **OpenAPI Schema**: https://saas-api.xiaoxiajianji.com/openapi.json
|
||||
### 快速入口
|
||||
- **[API 主线清单](docs/API-MAINLINE.md)** - 所有 API endpoints 总览
|
||||
- **[代码状态标注](docs/CODE-STATUS.md)** - 代码库导航指南
|
||||
- **[API 使用指南](docs/API-GUIDE.md)** - API 详细用法
|
||||
|
||||
### 核心 API 路径
|
||||
### Phase 7 交付
|
||||
- **[Phase 7 进度](docs/PHASE7-PROGRESS.md)** - 核心视频剪辑业务已完成
|
||||
- **[Phase 7 设计](docs/PHASE7-DESIGN.md)** - 设计文档
|
||||
|
||||
**认证** (`/api/v1/auth`)
|
||||
### 基础设施
|
||||
- **[Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)**
|
||||
- **[CI/CD 文档](docs/CI-CD.md)**
|
||||
- **[性能监控指南](docs/PERFORMANCE-MONITORING.md)**
|
||||
- **[健康检查指南](docs/HEALTH-CHECKS.md)**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/register` | 用户注册 |
|
||||
| POST | `/login` | 用户登录 |
|
||||
| GET | `/me` | 获取当前用户信息 |
|
||||
| POST | `/password/forgot` | 忘记密码 |
|
||||
| POST | `/password/reset` | 重置密码 |
|
||||
|
||||
**视频剪辑主链路**
|
||||
|
||||
```
|
||||
上传素材 → POST /api/v1/upload(直传)或 /api/v1/upload/chunk/init(分片)
|
||||
↓
|
||||
创建素材 → POST /api/v1/assets
|
||||
↓
|
||||
AI 分类 → POST /api/v1/classification-jobs
|
||||
↓
|
||||
生成剪辑计划 → POST /api/v1/projects/{id}/edit-plans/auto-generate
|
||||
↓
|
||||
创建生成任务 → POST /api/v1/generation/tasks/
|
||||
↓
|
||||
查询结果 → GET /api/v1/generation/tasks/{task_id}/results/
|
||||
↓
|
||||
获取成片 → GET /api/v1/generated-videos/{video_id}/download-url
|
||||
```
|
||||
|
||||
**项目管理** (`/api/v1/project-management`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET/POST | `/tasks` | 任务列表/创建 |
|
||||
| PATCH | `/tasks/{id}` | 更新任务信息 |
|
||||
| PATCH | `/tasks/{id}/status` | 更新任务状态 |
|
||||
| PATCH | `/tasks/{id}/progress` | 更新任务进度 |
|
||||
| GET/POST | `/milestones` | 里程碑列表/创建 |
|
||||
| GET/POST | `/issues` | 问题列表/创建 |
|
||||
| PATCH | `/issues/{id}/resolve` | 解决问题 |
|
||||
|
||||
**素材与上传**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/v1/upload` | 直传素材(multipart/form-data) |
|
||||
| POST | `/api/v1/upload/direct/prepare` | 准备 OSS 直传签名 |
|
||||
| POST | `/api/v1/upload/direct/complete` | 确认直传完成 |
|
||||
| POST | `/api/v1/upload/chunk/init` | 初始化分片上传 |
|
||||
| POST | `/api/v1/upload/chunk/{id}/{index}` | 上传分片 |
|
||||
| POST | `/api/v1/upload/chunk/{id}/complete` | 完成分片上传 |
|
||||
| GET | `/api/v1/assets` | 素材列表 |
|
||||
| PATCH | `/api/v1/assets/{id}/review` | 更新素材审核状态 |
|
||||
| GET | `/api/v1/projects/{id}/asset-diagnosis` | 资产诊断 |
|
||||
|
||||
**成片管理** (`/api/v1/generated-videos`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 成片列表 |
|
||||
| GET | `/{video_id}` | 成片详情 |
|
||||
| GET | `/{video_id}/download-url` | 下载链接 |
|
||||
| PATCH | `/{video_id}/review` | 审核状态 |
|
||||
|
||||
完整 API 列表请查看 [API 主线清单](docs/API-MAINLINE.md)
|
||||
### 开发规范
|
||||
- **[Git 工作流](docs/GIT-WORKFLOW.md)**
|
||||
- **[环境配置指南](docs/ENVIRONMENT-CONFIG.md)**
|
||||
- **[贡献指南](CONTRIBUTING.md)**
|
||||
|
||||
---
|
||||
|
||||
@@ -163,49 +111,61 @@ AI 分类 → POST /api/v1/classification-jobs
|
||||
|
||||
```
|
||||
小虾 SaaS
|
||||
├── packages/ # 核心业务逻辑(Clean Architecture)
|
||||
│ ├── domain/ # 领域模型(dataclass)
|
||||
│ ├── application/ # 用例(Use Cases)
|
||||
│ ├── ports/ # 接口定义(抽象端口)
|
||||
│ └── adapters/ # 适配器实现(SQLAlchemy、Redis、SMTP 等)
|
||||
├── packages/ # 核心业务逻辑
|
||||
│ ├── domain/ # 领域模型
|
||||
│ ├── application/ # 用例
|
||||
│ ├── ports/ # 接口定义
|
||||
│ └── adapters/ # 适配器实现
|
||||
├── apps/ # 应用层
|
||||
│ ├── api/ # FastAPI 应用 + 路由 + Pydantic schemas
|
||||
│ ├── web/ # React + Vite 前端
|
||||
│ └── worker/ # Celery 异步任务(视频处理、分类等)
|
||||
├── migrations/ # Alembic 数据库迁移
|
||||
│ ├── api/ # FastAPI 应用
|
||||
│ ├── web/ # React 前端
|
||||
│ └── worker/ # Celery worker
|
||||
├── migrations/ # 数据库迁移
|
||||
├── tests/ # 测试
|
||||
│ ├── unit/ # 单元测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # 端到端测试
|
||||
│ └── integration/ # 集成测试
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
**设计模式:**
|
||||
- Clean Architecture(依赖方向:外层 → 内层)
|
||||
- 依赖注入(FastAPI Depends)
|
||||
- Repository 模式(通过 ports 抽象)
|
||||
- Clean Architecture
|
||||
- 依赖注入
|
||||
- Repository 模式
|
||||
- Domain-Driven Design
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
## 🔑 核心 API 路径
|
||||
|
||||
**后端:**
|
||||
- Python 3.12 + FastAPI 0.115.0
|
||||
- PostgreSQL 16(生产)
|
||||
- Redis 7(缓存 + Celery Broker)
|
||||
- Celery(异步任务:视频处理、素材导入、分类)
|
||||
- 阿里云 OSS(文件存储)
|
||||
### 视频剪辑主链路(Phase 7)
|
||||
|
||||
**前端:**
|
||||
- React 18 + TypeScript
|
||||
- Vite(构建工具)
|
||||
- Ant Design(UI 组件)
|
||||
```
|
||||
上传素材
|
||||
↓
|
||||
POST /api/v1/upload
|
||||
|
||||
**部署:**
|
||||
- Docker + Docker Compose
|
||||
- Gitea + Gitea Actions(CI/CD)
|
||||
- Nginx(反向代理)
|
||||
创建 Asset
|
||||
↓
|
||||
POST /api/v1/assets
|
||||
|
||||
发起分类
|
||||
↓
|
||||
POST /api/v1/classification-jobs
|
||||
|
||||
创建生成任务
|
||||
↓
|
||||
POST /api/v1/generation/tasks
|
||||
|
||||
查询生成结果
|
||||
↓
|
||||
GET /api/v1/generation/tasks/{task_id}/results
|
||||
|
||||
获取下载链接
|
||||
↓
|
||||
GET /api/v1/generated-videos/{video_id}/download-url
|
||||
```
|
||||
|
||||
完整 API 列表请查看 [API 主线清单](docs/API-MAINLINE.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -225,32 +185,51 @@ pytest tests/integration -v
|
||||
pytest --cov=packages --cov-report=html
|
||||
```
|
||||
|
||||
**测试统计:**
|
||||
- 单元测试: 17+ 个 ✅
|
||||
- 集成测试: 6+ 个 ✅
|
||||
- 测试覆盖率: 持续提升中
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
**后端:**
|
||||
- Python 3.12
|
||||
- FastAPI 0.115.0
|
||||
- PostgreSQL 16
|
||||
- Redis 7
|
||||
- Celery
|
||||
- MinIO
|
||||
|
||||
**前端:**
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Ant Design
|
||||
|
||||
**测试:**
|
||||
- pytest
|
||||
- pytest-asyncio
|
||||
- pytest-cov
|
||||
|
||||
**部署:**
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Gitea Actions
|
||||
|
||||
---
|
||||
|
||||
## 📊 当前状态
|
||||
|
||||
| 模块 | 状态 |
|
||||
|------|------|
|
||||
| 视频剪辑主链路(Phase 7) | ✅ 已完成 |
|
||||
| 分片上传(最大 2GB) | ✅ 已完成 |
|
||||
| 4 种剪辑模式 | ✅ 已完成 |
|
||||
| 项目管理 + 任务追踪 | ✅ 已完成 |
|
||||
| 资产诊断 | ✅ 已完成 |
|
||||
| 认证系统(JWT) | ✅ 已完成 |
|
||||
| CI/CD 流水线 | ✅ 运行中 |
|
||||
| 前端界面(Vite) | ✅ 已完成 |
|
||||
|
||||
---
|
||||
|
||||
## 📄 更多文档
|
||||
|
||||
- [API 主线清单](docs/API-MAINLINE.md) - 全部端点总览
|
||||
- [API 使用指南](docs/API-GUIDE.md) - 详细用法
|
||||
- [代码状态标注](docs/CODE-STATUS.md) - 代码库导航
|
||||
- [Docker 部署指南](docs/DOCKER-DEPLOYMENT.md)
|
||||
- [CI/CD 文档](docs/CI-CD.md)
|
||||
- [Git 工作流](docs/GIT-WORKFLOW.md)
|
||||
- [环境配置指南](docs/ENVIRONMENT-CONFIG.md)
|
||||
| Phase 7(视频剪辑主链) | ✅ 已完成 |
|
||||
| CI/CD 稳定性 | ✅ 已修复 |
|
||||
| 认证系统 | ✅ 完整 |
|
||||
| 多租户架构 | ✅ 完整 |
|
||||
| 订阅系统 | ✅ 完整 |
|
||||
| 前端联调 | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
@@ -258,8 +237,38 @@ pytest --cov=packages --cov-report=html
|
||||
|
||||
欢迎贡献!请查看 [贡献指南](CONTRIBUTING.md)
|
||||
|
||||
**仓库地址**: https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas
|
||||
1. Fork 项目
|
||||
2. 创建分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'feat: Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **问题反馈:** GitHub Issues
|
||||
- **文档:** 参考 `docs/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 🎉 致谢
|
||||
|
||||
感谢所有贡献者和使用者!
|
||||
|
||||
**开发团队:** 小虾 🦐
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对你有帮助,请给一个 Star!**
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-19
|
||||
**当前版本**: Phase 7 完成
|
||||
|
||||
Executable → Regular
+4
-7
@@ -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,29 +0,0 @@
|
||||
"""Add editing_mode to generation_tasks
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "007"
|
||||
down_revision = "006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks", sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take")
|
||||
)
|
||||
# 添加索引以支持查询
|
||||
op.create_index("ix_generation_tasks_editing_mode", "generation_tasks", ["editing_mode"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_editing_mode", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "editing_mode")
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Add video fingerprint and duplicate detection fields to generated_videos table.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2024-06-26
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "008"
|
||||
down_revision = "007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add video_fingerprint column as JSON text
|
||||
op.add_column("generated_videos", sa.Column("video_fingerprint", sa.Text(), nullable=True))
|
||||
# Add is_duplicate column
|
||||
op.add_column("generated_videos", sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="false"))
|
||||
# Add duplicate_of column for tracking original video
|
||||
op.add_column("generated_videos", sa.Column("duplicate_of", sa.String(32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "duplicate_of")
|
||||
op.drop_column("generated_videos", "is_duplicate")
|
||||
op.drop_column("generated_videos", "video_fingerprint")
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Remove workspace concept - Projects now directly under User
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-06-26
|
||||
|
||||
This migration:
|
||||
1. Moves subscription/quota fields from workspaces to users table
|
||||
2. Converts projects.workspace_id to projects.owner_user_id
|
||||
3. Adds shared_users JSON field to projects table
|
||||
4. Removes workspace_id from all tables that had it
|
||||
5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "009"
|
||||
down_revision = "008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: Add subscription/quota fields to users table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT 'free'
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMP
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS max_projects FLOAT NOT NULL DEFAULT 3
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS max_storage_gb FLOAT NOT NULL DEFAULT 10
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0
|
||||
"""))
|
||||
|
||||
# Step 2: Copy subscription data from workspaces to users
|
||||
conn.execute(text("""
|
||||
UPDATE users SET
|
||||
subscription_plan = w.subscription_plan,
|
||||
subscription_status = w.subscription_status,
|
||||
subscription_expires_at = w.subscription_expires_at,
|
||||
max_projects = w.max_projects,
|
||||
max_storage_gb = w.max_storage_gb,
|
||||
used_storage_gb = w.used_storage_gb
|
||||
FROM workspaces w
|
||||
WHERE w.owner_user_id = users.id
|
||||
"""))
|
||||
|
||||
# Step 3: Add owner_user_id and shared_users to projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS owner_user_id VARCHAR(32)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS shared_users JSON
|
||||
"""))
|
||||
|
||||
# Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner)
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET
|
||||
owner_user_id = wm.user_id
|
||||
FROM workspace_members wm
|
||||
WHERE wm.workspace_id = projects.workspace_id
|
||||
AND wm.role = 'owner'
|
||||
"""))
|
||||
|
||||
# Set shared_users to empty array for all projects
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET shared_users = '[]'::json
|
||||
WHERE shared_users IS NULL
|
||||
"""))
|
||||
|
||||
# Step 5: Remove workspace_id from all tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
"assets",
|
||||
"classification_jobs",
|
||||
"edit_plans",
|
||||
"edit_templates",
|
||||
"generation_tasks",
|
||||
"generated_videos",
|
||||
"ingest_jobs",
|
||||
"milestones",
|
||||
"project_titles",
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
# Step 6: Drop workspace-related tables
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_invitations
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_members
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspaces
|
||||
"""))
|
||||
|
||||
# Step 7: Drop workspace_id from projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Add back workspace tables (simplified - in real scenario would need full recreation)
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
owner_user_id VARCHAR(36) NOT NULL,
|
||||
subscription_plan VARCHAR(20) NOT NULL DEFAULT 'free',
|
||||
subscription_status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
subscription_expires_at TIMESTAMP,
|
||||
max_projects FLOAT NOT NULL DEFAULT 3,
|
||||
max_storage_gb FLOAT NOT NULL DEFAULT 10,
|
||||
used_storage_gb FLOAT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
invited_by VARCHAR(36),
|
||||
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(workspace_id, user_id)
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36) NOT NULL,
|
||||
inviter_user_id VARCHAR(36) NOT NULL,
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
invitation_token VARCHAR(255) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
expires_at TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
# Add back workspace_id column to projects
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32)
|
||||
"""))
|
||||
|
||||
# Add back workspace_id columns to other tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
"assets",
|
||||
"classification_jobs",
|
||||
"edit_plans",
|
||||
"edit_templates",
|
||||
"generation_tasks",
|
||||
"generated_videos",
|
||||
"ingest_jobs",
|
||||
"milestones",
|
||||
"project_titles",
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36)
|
||||
"""))
|
||||
|
||||
# Note: This downgrade is incomplete - projects.owner_user_id data would need to be
|
||||
# converted back to workspace_ids, which requires reconstructing workspace records.
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Phase 0 - 扩展性基础设施:metadata JSONB + title_libraries + voice_libraries
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-06-28
|
||||
|
||||
This migration:
|
||||
1. Adds metadata JSONB column to 5 tables:
|
||||
- projects, asset_libraries, assets, edit_templates, generation_tasks
|
||||
2. Creates title_libraries table (独立标题库,支持跨项目复用)
|
||||
3. Creates voice_libraries table (配音库,支持 AI 配音管理)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "010"
|
||||
down_revision = "009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Add metadata JSONB to existing tables ──
|
||||
|
||||
conn.execute(sa.text("ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
|
||||
# ── 2. Create title_libraries table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS title_libraries (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
text VARCHAR(500) NOT NULL,
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)"))
|
||||
|
||||
# ── 3. Create voice_libraries table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS voice_libraries (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
project_id VARCHAR(36),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
voice_provider VARCHAR(50) NOT NULL DEFAULT '',
|
||||
voice_id VARCHAR(100) NOT NULL DEFAULT '',
|
||||
voice_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
audio_url VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
duration FLOAT NOT NULL DEFAULT 0,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Drop new tables
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS voice_libraries"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS title_libraries"))
|
||||
|
||||
# Remove metadata columns
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE edit_templates DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE assets DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE asset_libraries DROP COLUMN IF EXISTS metadata"))
|
||||
conn.execute(sa.text("ALTER TABLE projects DROP COLUMN IF EXISTS metadata"))
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Phase 1 - 核心重构:清理废弃表
|
||||
|
||||
Revision ID: 011
|
||||
Revises: 010
|
||||
Create Date: 2026-06-28
|
||||
|
||||
This migration:
|
||||
1. Drops 6 deprecated tables:
|
||||
- tasks (任务管理)
|
||||
- milestones (里程碑)
|
||||
- task_issues (任务问题)
|
||||
- project_titles (项目标题,已被 title_libraries 替代)
|
||||
- edit_plans (编辑计划)
|
||||
- edit_plan_clips (编辑计划片段)
|
||||
2. Removes edit_plan_id column from generation_tasks table
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "011"
|
||||
down_revision = "010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Drop deprecated tables ──
|
||||
|
||||
# Drop in reverse dependency order
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS task_issues"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS milestones"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS tasks"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS project_titles"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS edit_plan_clips"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS edit_plans"))
|
||||
|
||||
# ── 2. Remove edit_plan_id from generation_tasks ──
|
||||
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks DROP COLUMN IF EXISTS edit_plan_id"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Re-add edit_plan_id to generation_tasks ──
|
||||
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS edit_plan_id VARCHAR(32)"))
|
||||
|
||||
# ── 2. Recreate deprecated tables (basic structure) ──
|
||||
|
||||
# Note: Full schema recreation is complex; this is a minimal downgrade
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS edit_plans (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
project_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||
created_by_user_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS edit_plan_clips (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
edit_plan_id VARCHAR(32) NOT NULL,
|
||||
asset_id VARCHAR(32) NOT NULL,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
start_time FLOAT NOT NULL DEFAULT 0,
|
||||
end_time FLOAT NOT NULL DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS project_titles (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
project_id VARCHAR(36) NOT NULL,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
favorite BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
project_id VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
assigned_to_user_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
due_date TIMESTAMP,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
project_id VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
due_date TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS task_issues (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
task_id VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Phase 2 - 查重功能:duplication_records + duplication_segments
|
||||
|
||||
Revision ID: 012
|
||||
Revises: 011
|
||||
Create Date: 2026-06-28
|
||||
|
||||
This migration creates two new tables:
|
||||
1. duplication_records — 查重记录主表
|
||||
2. duplication_segments — 重复片段详情表
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "012"
|
||||
down_revision = "011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Create duplication_records table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS duplication_records (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
storage_key VARCHAR(500) NOT NULL,
|
||||
duration_seconds FLOAT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
duplicate_rate FLOAT,
|
||||
duplicate_count INTEGER NOT NULL DEFAULT 0,
|
||||
video_fingerprint TEXT,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_records_user_id ON duplication_records(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_records_status ON duplication_records(status)"))
|
||||
|
||||
# ── 2. Create duplication_segments table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS duplication_segments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
record_id VARCHAR(36) NOT NULL,
|
||||
source_start FLOAT NOT NULL,
|
||||
source_end FLOAT NOT NULL,
|
||||
matched_video_id VARCHAR(36) NOT NULL,
|
||||
matched_video_name VARCHAR(500) NOT NULL DEFAULT '',
|
||||
matched_start FLOAT NOT NULL,
|
||||
matched_end FLOAT NOT NULL,
|
||||
similarity FLOAT NOT NULL
|
||||
)
|
||||
"""))
|
||||
conn.execute(
|
||||
sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_segments_record_id ON duplication_segments(record_id)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS duplication_segments"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS duplication_records"))
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Phase 2 - 配方复用:recipes + recipe_items
|
||||
|
||||
Revision ID: 013
|
||||
Revises: 012
|
||||
Create Date: 2026-06-29
|
||||
|
||||
This migration creates two new tables:
|
||||
1. recipes — 配方主表
|
||||
2. recipe_items — 配方素材项表
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "013"
|
||||
down_revision = "012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Create recipes table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
template_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||
generation_params JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)"))
|
||||
|
||||
# ── 2. Create recipe_items table ──
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS recipe_items (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
recipe_id VARCHAR(36) NOT NULL,
|
||||
item_type VARCHAR(20) NOT NULL,
|
||||
item_id VARCHAR(36) NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS recipe_items"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS recipes"))
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Phase 3 - 剪辑计划模板:templates + template_segments + template_categories
|
||||
|
||||
Revision ID: 014
|
||||
Revises: 013
|
||||
Create Date: 2026-06-29
|
||||
|
||||
This migration creates three new tables:
|
||||
1. templates — 剪辑计划模板主表
|
||||
2. template_segments — 模板片段表
|
||||
3. template_categories — 模板分类表
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "014"
|
||||
down_revision = "013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. Create templates table ──
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
mode VARCHAR(30) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL DEFAULT '',
|
||||
tags JSONB NOT NULL DEFAULT '[]',
|
||||
title_config JSONB NOT NULL DEFAULT '{}',
|
||||
subtitle_config JSONB NOT NULL DEFAULT '{}',
|
||||
bgm_config JSONB NOT NULL DEFAULT '{}',
|
||||
estimated_duration FLOAT NOT NULL DEFAULT 0.0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)"))
|
||||
|
||||
# ── 2. Create template_segments table ──
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS template_segments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
template_id VARCHAR(36) NOT NULL,
|
||||
segment_order INTEGER NOT NULL,
|
||||
duration_min FLOAT NOT NULL,
|
||||
duration_max FLOAT NOT NULL,
|
||||
material_type VARCHAR(20),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(
|
||||
sa.text("CREATE INDEX IF NOT EXISTS ix_template_segments_template_id " "ON template_segments(template_id)")
|
||||
)
|
||||
|
||||
# ── 3. Create template_categories table ──
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS template_categories (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(
|
||||
sa.text("CREATE INDEX IF NOT EXISTS ix_template_categories_user_id " "ON template_categories(user_id)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS template_categories"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS template_segments"))
|
||||
conn.execute(sa.text("DROP TABLE IF EXISTS templates"))
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add generation task extensions
|
||||
|
||||
Revision ID: 015
|
||||
Revises: 014
|
||||
Create Date: 2026-06-29
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers
|
||||
revision = "015"
|
||||
down_revision = "014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("generation_tasks", sa.Column("template_id", sa.String(36), nullable=False, server_default=""))
|
||||
op.add_column("generation_tasks", sa.Column("asset_ids", mysql.JSON(), nullable=False, server_default="[]"))
|
||||
op.add_column("generation_tasks", sa.Column("title_ids", mysql.JSON(), nullable=False, server_default="[]"))
|
||||
op.add_column("generation_tasks", sa.Column("voice_ids", mysql.JSON(), nullable=False, server_default="[]"))
|
||||
|
||||
op.create_index(op.f("ix_generation_tasks_template_id"), "generation_tasks", ["template_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generation_tasks_template_id"), table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "voice_ids")
|
||||
op.drop_column("generation_tasks", "title_ids")
|
||||
op.drop_column("generation_tasks", "asset_ids")
|
||||
op.drop_column("generation_tasks", "template_id")
|
||||
@@ -1,116 +0,0 @@
|
||||
"""phase8 edit template plan
|
||||
|
||||
Revision ID: 016
|
||||
Revises: 015
|
||||
Create Date: 2026-07-01
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "016"
|
||||
down_revision = "015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- edit_templates: 替换为 Phase 8 新 schema ---
|
||||
# 删除旧列
|
||||
op.drop_column("edit_templates", "project_id")
|
||||
op.drop_column("edit_templates", "target_duration")
|
||||
op.drop_column("edit_templates", "clip_count")
|
||||
op.drop_column("edit_templates", "is_active")
|
||||
op.drop_column("edit_templates", "created_by_user_id")
|
||||
op.drop_column("edit_templates", "metadata")
|
||||
|
||||
# 添加新列
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("template_type", sa.String(50), nullable=False, server_default="default"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("preview_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("sort_weight", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
)
|
||||
|
||||
# 添加索引
|
||||
op.create_index("ix_edit_templates_template_type", "edit_templates", ["template_type"])
|
||||
op.create_index("ix_edit_templates_sort_weight", "edit_templates", ["sort_weight"])
|
||||
op.create_index("ix_edit_templates_status", "edit_templates", ["status"])
|
||||
|
||||
# --- edit_plans: 重建表(在 011 中被删除) ---
|
||||
op.create_table(
|
||||
"edit_plans",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
|
||||
sa.Column("total_duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("edit_plans")
|
||||
|
||||
op.drop_index("ix_edit_templates_status", "edit_templates")
|
||||
op.drop_index("ix_edit_templates_sort_weight", "edit_templates")
|
||||
op.drop_index("ix_edit_templates_template_type", "edit_templates")
|
||||
|
||||
op.drop_column("edit_templates", "status")
|
||||
op.drop_column("edit_templates", "sort_weight")
|
||||
op.drop_column("edit_templates", "preview_url")
|
||||
op.drop_column("edit_templates", "config")
|
||||
op.drop_column("edit_templates", "template_type")
|
||||
|
||||
# 恢复旧列
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("project_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Phase 8: Create template_clip_configs and edit_plan_clips tables
|
||||
|
||||
Revision ID: 017
|
||||
Revises: 016
|
||||
Create Date: 2026-07-01
|
||||
|
||||
新增两张表:
|
||||
- template_clip_configs: 模板片段配置(定义模板中每个片段的规则)
|
||||
- edit_plan_clips: 剪辑计划片段(剪辑计划中的具体片段实例)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "017"
|
||||
down_revision = "016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# template_clip_configs: 模板片段配置表
|
||||
op.create_table(
|
||||
"template_clip_configs",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("clip_type", sa.String(20), nullable=False, index=True),
|
||||
sa.Column("order", sa.Integer, nullable=False),
|
||||
sa.Column("min_duration", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column("max_duration", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column("text_template", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("material_requirements", sa.JSON, nullable=False, server_default="{}"),
|
||||
sa.Column("transition_effect", sa.String(20), nullable=False, server_default="cut"),
|
||||
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
||||
# edit_plan_clips: 剪辑计划片段表
|
||||
op.create_table(
|
||||
"edit_plan_clips",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("plan_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("clip_type", sa.String(20), nullable=False, index=True),
|
||||
sa.Column("order", sa.Integer, nullable=False),
|
||||
sa.Column("template_clip_config_id", sa.String(32), nullable=False, server_default="", index=True),
|
||||
sa.Column("asset_id", sa.String(32), nullable=False, server_default="", index=True),
|
||||
sa.Column("text_content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("start_time", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column("duration", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column("transition_effect", sa.String(20), nullable=False, server_default="cut"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("edit_plan_clips")
|
||||
op.drop_table("template_clip_configs")
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Phase 8 任务 2.10: Create jobs table for unified async task management
|
||||
|
||||
Revision ID: 018
|
||||
Revises: 017
|
||||
Create Date: 2026-07-01
|
||||
|
||||
新增 jobs 表,用于统一管理异步任务(视频合成、渲染等)的生命周期。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "018"
|
||||
down_revision = "017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"jobs",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("project_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("job_type", sa.String(30), nullable=False, index=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("progress", sa.Float, nullable=False, server_default="0.0"),
|
||||
sa.Column("current_stage", sa.String(200), nullable=False, server_default=""),
|
||||
sa.Column("payload", sa.JSON, nullable=False, server_default="{}"),
|
||||
sa.Column("result", sa.JSON, nullable=False, server_default="{}"),
|
||||
sa.Column("error_message", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("max_retries", sa.Integer, nullable=False, server_default="3"),
|
||||
sa.Column("celery_task_id", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("source_id", sa.String(32), nullable=False, server_default="", index=True),
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default="", index=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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("jobs")
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Task 3.05: Create voice_clone_profiles table
|
||||
|
||||
Revision ID: 019
|
||||
Revises: 018
|
||||
Create Date: 2026-07-02
|
||||
|
||||
新增 voice_clone_profiles 表,用于存储音色克隆档案。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "019"
|
||||
down_revision = "018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"voice_clone_profiles",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("source_audio_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("voice_id", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("voice_model", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("language", sa.String(20), nullable=False, server_default="zh-CN"),
|
||||
sa.Column("gender", sa.String(20), nullable=False, server_default="unknown"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("voice_clone_profiles")
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Task 3.06: Create tts_jobs table
|
||||
|
||||
Revision ID: 020
|
||||
Revises: 019
|
||||
Create Date: 2026-07-02
|
||||
|
||||
新增 tts_jobs 表,用于存储 TTS 合成任务。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "020"
|
||||
down_revision = "019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tts_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("input_text", sa.Text(), nullable=False),
|
||||
sa.Column("voice_id", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("voice_model", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("voice_clone_profile_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("output_audio_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_audio_key", sa.String(500), nullable=False, server_default=""),
|
||||
sa.Column("duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("file_size", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("sample_rate", sa.Integer(), nullable=False, server_default="22050"),
|
||||
sa.Column("format", sa.String(20), nullable=False, server_default="mp3"),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("tts_jobs")
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Task 3.09: Create billing_records table
|
||||
|
||||
Revision ID: 021
|
||||
Revises: 020
|
||||
Create Date: 2026-07-03
|
||||
|
||||
新增 billing_records 表,用于存储账单记录。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "021"
|
||||
down_revision = "020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"billing_records",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("plan_name", sa.String(50), nullable=False),
|
||||
sa.Column("amount", sa.Float, nullable=False),
|
||||
sa.Column("billing_cycle", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("payment_method", sa.String(50), nullable=True),
|
||||
sa.Column("payment_id", sa.String(100), nullable=True),
|
||||
sa.Column("invoice_url", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("billing_records")
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Task: Add source_edit_plan_id to edit_plans and generation_tasks
|
||||
|
||||
Revision ID: 022
|
||||
Revises: 021
|
||||
Create Date: 2026-07-04
|
||||
|
||||
新增 source_edit_plan_id 字段到 edit_plans 和 generation_tasks 表,
|
||||
用于关联生成记录到其来源的剪辑计划。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "022"
|
||||
down_revision = "021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("source_edit_plan_id", sa.String(32), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_edit_plans_source_edit_plan_id"),
|
||||
"edit_plans",
|
||||
["source_edit_plan_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_edit_plan_id", sa.String(32), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_source_edit_plan_id"),
|
||||
"generation_tasks",
|
||||
["source_edit_plan_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_generation_tasks_source_edit_plan_id"),
|
||||
table_name="generation_tasks",
|
||||
)
|
||||
op.drop_column("generation_tasks", "source_edit_plan_id")
|
||||
|
||||
op.drop_index(
|
||||
op.f("ix_edit_plans_source_edit_plan_id"),
|
||||
table_name="edit_plans",
|
||||
)
|
||||
op.drop_column("edit_plans", "source_edit_plan_id")
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Task: Add project_id and created_by_user_id to edit_plans
|
||||
|
||||
Revision ID: 023
|
||||
Revises: 022
|
||||
Create Date: 2026-07-05
|
||||
|
||||
新增 project_id 和 created_by_user_id 字段到 edit_plans 表,
|
||||
用于项目归属鉴权和用户归属追踪,修复审计发现的 P1 越权漏洞。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "023"
|
||||
down_revision = "022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("project_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_edit_plans_project_id"),
|
||||
"edit_plans",
|
||||
["project_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_edit_plans_created_by_user_id"),
|
||||
"edit_plans",
|
||||
["created_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_edit_plans_created_by_user_id"),
|
||||
table_name="edit_plans",
|
||||
)
|
||||
op.drop_column("edit_plans", "created_by_user_id")
|
||||
|
||||
op.drop_index(
|
||||
op.f("ix_edit_plans_project_id"),
|
||||
table_name="edit_plans",
|
||||
)
|
||||
op.drop_column("edit_plans", "project_id")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Task: Add is_admin to users
|
||||
|
||||
Revision ID: 024
|
||||
Revises: 023
|
||||
Create Date: 2026-07-05
|
||||
|
||||
新增 is_admin 字段到 users 表,用于模板管理等管理员权限校验。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "024"
|
||||
down_revision = "023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "is_admin")
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Task: Add wechat_openid / wechat_unionid to users
|
||||
|
||||
Revision ID: 025
|
||||
Revises: 024
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录微信小程序登录所需的 wechat 字段。
|
||||
生产数据库已手动添加过这些字段和索引,因此 upgrade 做幂等检查,
|
||||
避免在已有字段的库上执行报错。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "025"
|
||||
down_revision = "024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
"""检查列是否已存在。离线模式下返回 False。"""
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
if result is None:
|
||||
return False
|
||||
return result.scalar() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _index_exists(index: str) -> bool:
|
||||
"""检查索引是否已存在。离线模式下返回 False。"""
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index"),
|
||||
{"index": index},
|
||||
)
|
||||
if result is None:
|
||||
return False
|
||||
return result.scalar() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# wechat_openid
|
||||
if not _column_exists("users", "wechat_openid"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("wechat_openid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
|
||||
# wechat_unionid
|
||||
if not _column_exists("users", "wechat_unionid"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("wechat_unionid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
|
||||
# 唯一索引
|
||||
if not _index_exists("ix_users_wechat_openid"):
|
||||
op.create_index("ix_users_wechat_openid", "users", ["wechat_openid"], unique=True)
|
||||
|
||||
if not _index_exists("ix_users_wechat_unionid"):
|
||||
op.create_index("ix_users_wechat_unionid", "users", ["wechat_unionid"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_wechat_unionid", table_name="users")
|
||||
op.drop_index("ix_users_wechat_openid", table_name="users")
|
||||
op.drop_column("users", "wechat_unionid")
|
||||
op.drop_column("users", "wechat_openid")
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Add user profile fields (name, avatar, updated_at)
|
||||
|
||||
Revision ID: 026
|
||||
Revises: 025
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录用户资料字段。生产数据库已手动添加过这些字段,
|
||||
因此 upgrade 做幂等检查,避免在已有字段的库上执行报错。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "026"
|
||||
down_revision = "025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "name"):
|
||||
op.add_column("users", sa.Column("name", sa.String(100), nullable=True))
|
||||
|
||||
if not _column_exists("users", "avatar"):
|
||||
op.add_column("users", sa.Column("avatar", sa.String(500), nullable=True))
|
||||
|
||||
if not _column_exists("users", "updated_at"):
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=True,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "updated_at")
|
||||
op.drop_column("users", "avatar")
|
||||
op.drop_column("users", "name")
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Add user ban fields (ban_reason, ban_at)
|
||||
|
||||
Revision ID: 027
|
||||
Revises: 026
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录用户封禁字段。生产数据库已手动添加过这些字段,
|
||||
因此 upgrade 做幂等检查。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "027"
|
||||
down_revision = "026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "ban_reason"):
|
||||
op.add_column("users", sa.Column("ban_reason", sa.Text(), nullable=True))
|
||||
|
||||
if not _column_exists("users", "ban_at"):
|
||||
op.add_column("users", sa.Column("ban_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "ban_at")
|
||||
op.drop_column("users", "ban_reason")
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Add user admin fields (admin_status, admin_remarks)
|
||||
|
||||
Revision ID: 028
|
||||
Revises: 027
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录管理员备注字段。生产数据库已手动添加过这些字段,
|
||||
因此 upgrade 做幂等检查。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "028"
|
||||
down_revision = "027"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "admin_status"):
|
||||
op.add_column("users", sa.Column("admin_status", sa.String(50), nullable=True))
|
||||
|
||||
if not _column_exists("users", "admin_remarks"):
|
||||
op.add_column("users", sa.Column("admin_remarks", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "admin_remarks")
|
||||
op.drop_column("users", "admin_status")
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Add user phone field
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-07-05
|
||||
|
||||
补录用户手机号字段。生产数据库已手动添加过该字段,
|
||||
因此 upgrade 做幂等检查。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision = "029"
|
||||
down_revision = "028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table: str, column: str) -> bool:
|
||||
if context.is_offline_mode():
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.scalar() > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _column_exists("users", "phone"):
|
||||
op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "phone")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Add tags and asset_tags tables
|
||||
|
||||
Revision ID: 030
|
||||
Revises: 029
|
||||
Create Date: 2026-07-07
|
||||
|
||||
新增标签表和素材-标签关联表,支持规范化多对多标签管理。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "030"
|
||||
down_revision = "029"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table: str) -> bool:
|
||||
ctx = op.get_context()
|
||||
if ctx.as_sql:
|
||||
return False
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"),
|
||||
{"table": table},
|
||||
)
|
||||
return (result.scalar() or 0) > 0
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("tags"):
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_name"),
|
||||
)
|
||||
op.create_index("ix_tags_user_id", "tags", ["user_id"])
|
||||
|
||||
if not _table_exists("asset_tags"):
|
||||
op.create_table(
|
||||
"asset_tags",
|
||||
sa.Column("asset_id", sa.String(36), primary_key=True),
|
||||
sa.Column("tag_id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_asset_tags_tag_id", "asset_tags", ["tag_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_asset_tags_tag_id", table_name="asset_tags")
|
||||
op.drop_table("asset_tags")
|
||||
op.drop_index("ix_tags_user_id", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Add file_hash to assets and ingest_jobs
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-07-07
|
||||
|
||||
为素材去重检测功能添加 file_hash 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "031"
|
||||
down_revision = "030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
|
||||
|
||||
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
|
||||
op.drop_column("ingest_jobs", "file_hash")
|
||||
|
||||
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
|
||||
op.drop_column("assets", "file_hash")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Add asset_select_mode to generation_tasks
|
||||
|
||||
Revision ID: 032
|
||||
Revises: 031
|
||||
Create Date: 2026-07-07
|
||||
|
||||
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "032"
|
||||
down_revision = "031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "asset_select_mode")
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Add batch_id to generation_tasks
|
||||
|
||||
Revision ID: 033
|
||||
Revises: 032
|
||||
Create Date: 2026-07-07
|
||||
|
||||
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
|
||||
用于关联同一次批量生成请求中的多个任务。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "033"
|
||||
down_revision = "032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "batch_id")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""CMS Enhancements (placeholder - manually applied on production)
|
||||
|
||||
Revision ID: 034_cms_enhance
|
||||
Revises: 033
|
||||
Create Date: 2026-07-09
|
||||
|
||||
占位迁移文件:生产数据库已手动升级到此版本,
|
||||
此文件用于让 alembic 识别当前版本,避免部署时迁移失败。
|
||||
实际的表结构变更(helpcenter, tickets, partners, site_settings 等)
|
||||
已在生产环境手动执行。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "034_cms_enhance"
|
||||
down_revision = "033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""占位 - 变更已在生产环境手动应用"""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""占位 - 不执行实际回退"""
|
||||
pass
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Add editing_mode to edit_templates
|
||||
|
||||
Revision ID: 035_editing_mode
|
||||
Revises: 034_cms_enhance
|
||||
Create Date: 2026-07-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "035_editing_mode"
|
||||
down_revision = "034_cms_enhance"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_templates", "editing_mode")
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Expand UUID fields from varchar(32) to varchar(36)
|
||||
|
||||
All UUID fields across all tables were varchar(32), but standard UUIDs with
|
||||
hyphens are 36 characters (e.g. 550e8400-e29b-41d4-a716-446655440000).
|
||||
This caused StringDataRightTruncation errors on insert.
|
||||
|
||||
Revision ID: 036_expand_uuid_36
|
||||
Revises: 035_editing_mode
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "036_expand_uuid_36"
|
||||
down_revision = "035_editing_mode"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# ── 表 → 需要扩容的列 ─────────────────────────────────────────────────────────
|
||||
|
||||
_TABLES: dict[str, list[str]] = {
|
||||
"projects": ["id", "owner_user_id"],
|
||||
"edit_templates": ["id"],
|
||||
"edit_plans": ["id", "template_id", "source_edit_plan_id", "project_id", "created_by_user_id"],
|
||||
"template_clip_configs": ["id", "template_id"],
|
||||
"edit_plan_clips": ["id", "plan_id", "template_clip_config_id", "asset_id"],
|
||||
"ingest_jobs": ["id", "project_id", "library_id", "result_asset_id"],
|
||||
"classification_jobs": ["id", "project_id", "asset_id"],
|
||||
"generation_tasks": [
|
||||
"id",
|
||||
"project_id",
|
||||
"strategy_id",
|
||||
"asset_library_id",
|
||||
"voice_library_id",
|
||||
"created_by_user_id",
|
||||
"source_edit_plan_id",
|
||||
"batch_id",
|
||||
],
|
||||
"generated_videos": ["id", "project_id", "generation_task_id", "duplicate_of"],
|
||||
"jobs": ["id", "project_id", "source_id", "created_by_user_id"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table, columns in _TABLES.items():
|
||||
for col in columns:
|
||||
op.alter_column(
|
||||
table,
|
||||
col,
|
||||
existing_type=sa.String(32),
|
||||
type_=sa.String(36),
|
||||
existing_nullable=None,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table, columns in reversed(list(_TABLES.items())):
|
||||
for col in columns:
|
||||
op.alter_column(
|
||||
table,
|
||||
col,
|
||||
existing_type=sa.String(36),
|
||||
type_=sa.String(32),
|
||||
existing_nullable=None,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Add logs field to generation_tasks
|
||||
|
||||
Revision ID: 037_generation_logs
|
||||
Revises: 036_expand_uuid_36
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "037_generation_logs"
|
||||
down_revision = "036_expand_uuid_36"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("logs", sa.Text(), nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "logs")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""add error_info and retry fields to generation_tasks
|
||||
|
||||
Revision ID: 038_error_retry
|
||||
Revises: 037_generation_logs
|
||||
Create Date: 2026-07-13 22:15:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "038_error_retry"
|
||||
down_revision = "037_generation_logs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# error_info: 结构化错误信息(error_type, message, stack_trace, failed_at, stage等)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("error_info", sa.JSON(), nullable=True),
|
||||
)
|
||||
# retry_count: 重试次数
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
# auto_retry_enabled: 是否开启自动重试
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("auto_retry_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
# auto_retry_max: 最大自动重试次数
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("auto_retry_max", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("generation_tasks", "auto_retry_max")
|
||||
op.drop_column("generation_tasks", "auto_retry_enabled")
|
||||
op.drop_column("generation_tasks", "retry_count")
|
||||
op.drop_column("generation_tasks", "error_info")
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add transition_duration to edit_plan_clips
|
||||
|
||||
Revision ID: 039_transition_duration
|
||||
Revises: 038_error_retry
|
||||
Create Date: 2026-07-14 09:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "039_transition_duration"
|
||||
down_revision = "038_error_retry"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plan_clips",
|
||||
sa.Column(
|
||||
"transition_duration",
|
||||
sa.Float(),
|
||||
nullable=False,
|
||||
server_default="0.0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_plan_clips", "transition_duration")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""add playback_speed to edit_plan_clips
|
||||
|
||||
Revision ID: 040_playback_speed
|
||||
Revises: 039_transition_duration
|
||||
Create Date: 2026-07-14 10:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "040_playback_speed"
|
||||
down_revision = "039_transition_duration"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plan_clips",
|
||||
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_plan_clips", "playback_speed")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""add result_count to edit_plans
|
||||
|
||||
Revision ID: 041_result_count
|
||||
Revises: 040_playback_speed
|
||||
Create Date: 2026-07-15 14:05:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "041_result_count"
|
||||
down_revision = "040_playback_speed"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plans",
|
||||
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_plans", "result_count")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""add storage_key to assets
|
||||
|
||||
Revision ID: 042_storage_key
|
||||
Revises: 041_result_count
|
||||
Create Date: 2026-07-17 18:10:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "042_storage_key"
|
||||
down_revision = "041_result_count"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"assets",
|
||||
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assets", "storage_key")
|
||||
@@ -1,33 +0,0 @@
|
||||
"""add updated_at to generation_tasks
|
||||
|
||||
Revision ID: 043_updated_at_generation_tasks
|
||||
Revises: 042_storage_key
|
||||
Create Date: 2026-07-18 19:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "043_updated_at_generation_tasks"
|
||||
down_revision = "042_storage_key"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "updated_at")
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add user_id to generated_videos
|
||||
|
||||
Revision ID: 044_user_id_generated_videos
|
||||
Revises: 043_updated_at_generation_tasks
|
||||
Create Date: 2026-07-19 08:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "044_user_id_generated_videos"
|
||||
down_revision = "043_updated_at_generation_tasks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.String(36),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "user_id")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
"""API application package."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
Executable → Regular
+28
-105
@@ -1,32 +1,19 @@
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_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.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.duplication import router as duplication_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.edit_plans import router as edit_plans_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.project_management import router as project_management_router
|
||||
from app.api.routes.project_titles import router as project_titles_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.share import router as share_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
from app.api.routes.task_center import router as task_center_router
|
||||
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
|
||||
from app.api.routes.videos import router as videos_router
|
||||
from app.api.routes.voice_clones import router as voice_clones_router
|
||||
from app.api.routes.voices import router as voices_router
|
||||
from app.api.routes.workspaces import router as workspaces_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
@@ -35,133 +22,69 @@ health_router.include_router(health_check_router)
|
||||
|
||||
api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
tags=["认证"],
|
||||
)
|
||||
api_router.include_router(
|
||||
workspaces_router,
|
||||
tags=["工作空间"],
|
||||
)
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
tags=["Project"],
|
||||
tags=["项目管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
tags_router,
|
||||
prefix="/tags",
|
||||
tags=["Tag"],
|
||||
project_titles_router,
|
||||
tags=["标题库"],
|
||||
)
|
||||
api_router.include_router(
|
||||
cover_templates_router,
|
||||
tags=["CoverTemplate"],
|
||||
edit_plans_router,
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
tags=["任务中心"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_diagnosis_router,
|
||||
tags=["AssetDiagnosis"],
|
||||
tags=["素材诊断"],
|
||||
)
|
||||
api_router.include_router(
|
||||
asset_libraries_router,
|
||||
prefix="/asset-libraries",
|
||||
tags=["AssetLibrary"],
|
||||
tags=["素材库管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
assets_router,
|
||||
prefix="/assets",
|
||||
tags=["Asset"],
|
||||
tags=["素材资产"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ingest_jobs_router,
|
||||
prefix="/ingest-jobs",
|
||||
tags=["IngestJob"],
|
||||
tags=["导入任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
classification_jobs_router,
|
||||
prefix="/classification-jobs",
|
||||
tags=["ClassificationJob"],
|
||||
tags=["分类任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
upload_router,
|
||||
prefix="/upload",
|
||||
tags=["Upload"],
|
||||
)
|
||||
api_router.include_router(
|
||||
chunked_upload_router,
|
||||
prefix="/upload/chunk",
|
||||
tags=["ChunkedUpload"],
|
||||
tags=["文件上传"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_tasks_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
tags=["生成任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_preview_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
generated_videos_router,
|
||||
prefix="/generated-videos",
|
||||
tags=["成片管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_cover_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
tags=["TitleLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
voices_router,
|
||||
prefix="/voices",
|
||||
tags=["VoiceLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
voice_clones_router,
|
||||
prefix="/voice-clones",
|
||||
tags=["VoiceClone"],
|
||||
)
|
||||
api_router.include_router(
|
||||
videos_router,
|
||||
tags=["VideoCenter"],
|
||||
)
|
||||
api_router.include_router(
|
||||
share_router,
|
||||
tags=["Share"],
|
||||
)
|
||||
api_router.include_router(
|
||||
duplication_router,
|
||||
prefix="/duplication",
|
||||
tags=["Duplication"],
|
||||
)
|
||||
api_router.include_router(
|
||||
subscription_router,
|
||||
prefix="/subscription",
|
||||
tags=["Subscription"],
|
||||
)
|
||||
api_router.include_router(
|
||||
templates_router,
|
||||
prefix="/templates",
|
||||
tags=["Template"],
|
||||
)
|
||||
api_router.include_router(
|
||||
templates_editor_router,
|
||||
prefix="/templates/{template_id}/editor",
|
||||
tags=["TemplateEditor"],
|
||||
)
|
||||
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"],
|
||||
)
|
||||
api_router.include_router(
|
||||
internal_render_router,
|
||||
tags=["Internal"],
|
||||
project_management_router,
|
||||
prefix="/project-management",
|
||||
tags=["项目推进管理"],
|
||||
)
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from packages.application import GetProjectUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
|
||||
def check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||
"""检查用户是否有项目访问权限。
|
||||
|
||||
合并自 asset_libraries.py / edit_plans.py 的同名函数。
|
||||
- 空 project_id 直接放行(兼容 edit_plans 中 project_id 可选的场景)
|
||||
- 错误信息使用中文,与项目其他路由保持一致
|
||||
"""
|
||||
if not project_id or not project_id.strip():
|
||||
return
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
|
||||
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||
"""获取用户的订阅计划名称。"""
|
||||
user = user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return "free"
|
||||
return getattr(user, "subscription_plan", "free") or "free"
|
||||
|
||||
|
||||
def require_project_and_library(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
) -> None:
|
||||
"""Verify project and asset library exist."""
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
libraries = asset_library_repository.find_by_project(project_id)
|
||||
if not any(item.id == library_id for item in libraries):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||
|
||||
|
||||
def auto_select_video_assets(
|
||||
*,
|
||||
project_id: str,
|
||||
asset_library_repo: Any,
|
||||
asset_repo: Any,
|
||||
logger=None,
|
||||
) -> list[str]:
|
||||
"""从项目视频素材库自动选取 ready 状态的视频素材。
|
||||
|
||||
Args:
|
||||
project_id: 项目 ID
|
||||
asset_library_repo: 素材库仓储
|
||||
asset_repo: 素材仓储
|
||||
logger: 可选的 logger 实例,用于记录警告
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表,无可用素材时返回空列表
|
||||
"""
|
||||
if not project_id:
|
||||
return []
|
||||
|
||||
# 找到项目的视频素材库
|
||||
libs = asset_library_repo.find_by_project(project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if not video_lib:
|
||||
if logger:
|
||||
logger.warning("自动选素材: 项目 %s 无视频素材库", project_id)
|
||||
return []
|
||||
|
||||
# 从素材库中选取 ready 状态的视频素材
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
|
||||
# 过滤横屏素材(只保留竖屏/正方形)
|
||||
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
|
||||
# 注意:这只是选素材优化,渲染引擎本身支持任何分辨率的素材
|
||||
filtered_videos = []
|
||||
skipped_landscape = 0
|
||||
for a in ready_videos:
|
||||
width = a.width if hasattr(a, "width") and a.width else 0
|
||||
height = a.height if hasattr(a, "height") and a.height else 0
|
||||
if not width or not height:
|
||||
# 从 metadata 兜底
|
||||
if a.metadata and isinstance(a.metadata, dict):
|
||||
width = int(a.metadata.get("width", 0) or 0)
|
||||
height = int(a.metadata.get("height", 0) or 0)
|
||||
if width and height and width > height:
|
||||
skipped_landscape += 1
|
||||
continue
|
||||
filtered_videos.append(a)
|
||||
|
||||
if skipped_landscape and logger:
|
||||
logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape)
|
||||
|
||||
if not filtered_videos:
|
||||
if logger:
|
||||
logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name)
|
||||
return []
|
||||
|
||||
# 按创建时间降序(新素材在前)
|
||||
filtered_videos.sort(key=lambda a: a.created_at, reverse=True)
|
||||
return [a.id for a in filtered_videos]
|
||||
|
||||
|
||||
def format_utc_datetime(dt: datetime | None) -> str:
|
||||
"""将数据库读出的 UTC naive datetime 格式化为带时区的 ISO 8601 字符串。
|
||||
|
||||
数据库 DateTime 列不带时区信息,但存的是 UTC 时间。
|
||||
直接 .isoformat() 输出无时区标识,前端会按本地时间解析,导致差 8 小时。
|
||||
输出带 Z 后缀,前端 new Date() 自动转本地时间。
|
||||
"""
|
||||
if dt is None:
|
||||
return ""
|
||||
if isinstance(dt, str):
|
||||
return dt
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -1,136 +0,0 @@
|
||||
"""AI 相关接口 — 智能标题、智能素材匹配等.
|
||||
|
||||
基于豆包大模型的 AI 能力接口,未配置 API Key 时自动降级为本地模拟。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── 请求/响应模型 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateTitlesRequest(BaseModel):
|
||||
"""智能标题生成请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="视频内容描述")
|
||||
style: Literal["viral", "emotional", "informative"] = Field(
|
||||
default="viral",
|
||||
description="标题风格:viral爆款 / emotional情感 / informative信息",
|
||||
)
|
||||
count: int = Field(default=5, ge=3, le=10, description="生成数量,3-10个")
|
||||
|
||||
|
||||
class GenerateTitlesResponse(BaseModel):
|
||||
"""智能标题生成响应."""
|
||||
|
||||
titles: List[str] = Field(..., description="生成的标题列表")
|
||||
style: str = Field(..., description="实际使用的风格")
|
||||
source: str = Field(..., description="来源:doubao 或 fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
|
||||
|
||||
class TitleStyleInfo(BaseModel):
|
||||
"""标题风格信息."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AssetMatchItem(BaseModel):
|
||||
"""待匹配素材项."""
|
||||
|
||||
id: str = Field(..., description="素材ID")
|
||||
name: str = Field(default="", description="素材名称")
|
||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
||||
description: str = Field(default="", description="素材描述")
|
||||
|
||||
|
||||
class SemanticMatchRequest(BaseModel):
|
||||
"""语义匹配请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
|
||||
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
|
||||
|
||||
|
||||
class SemanticMatchResultItem(AssetMatchItem):
|
||||
"""匹配结果项."""
|
||||
|
||||
match_score: float = Field(..., description="匹配度评分 0-1")
|
||||
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
|
||||
|
||||
|
||||
class SemanticMatchResponse(BaseModel):
|
||||
"""语义匹配响应."""
|
||||
|
||||
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
source: str = Field(..., description="来源:doubao / fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
total: int = Field(..., description="输入素材总数")
|
||||
|
||||
|
||||
# ── 路由 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/titles/generate", response_model=GenerateTitlesResponse)
|
||||
def generate_titles(request: GenerateTitlesRequest):
|
||||
"""生成智能标题.
|
||||
|
||||
根据视频描述生成指定风格的标题,支持爆款、情感、信息三种风格。
|
||||
未配置豆包 API Key 时自动降级为本地规则生成。
|
||||
"""
|
||||
result = generate_smart_titles(
|
||||
description=request.description,
|
||||
style=request.style,
|
||||
count=request.count,
|
||||
)
|
||||
return GenerateTitlesResponse(**result)
|
||||
|
||||
|
||||
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
|
||||
def list_title_styles():
|
||||
"""获取支持的标题风格列表."""
|
||||
return [
|
||||
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
|
||||
for key, info in TITLE_STYLES.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assets/match", response_model=SemanticMatchResponse)
|
||||
def match_assets(request: SemanticMatchRequest):
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,对素材列表做语义匹配并按匹配度排序。
|
||||
未配置豆包 API Key 时自动降级为关键词匹配。
|
||||
|
||||
- 支持最多 100 个素材同时匹配
|
||||
- 返回 match_score (0-1),按降序排列
|
||||
- top_k 可限制返回数量
|
||||
"""
|
||||
# 转为 dict 传给服务层
|
||||
assets_dict = [asset.model_dump() for asset in request.assets]
|
||||
|
||||
result = semantic_match_assets(
|
||||
description=request.description,
|
||||
assets=assets_dict,
|
||||
top_k=request.top_k,
|
||||
)
|
||||
|
||||
return SemanticMatchResponse(
|
||||
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
|
||||
source=result["source"],
|
||||
description=result["description"],
|
||||
total=result["total"],
|
||||
)
|
||||
@@ -1,18 +1,18 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.domain import Asset, AssetLibraryKind, AssetStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,7 +37,7 @@ def _readiness_label(score: int) -> str:
|
||||
return "暂不建议生成"
|
||||
|
||||
|
||||
def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
|
||||
def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
|
||||
ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY]
|
||||
video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO]
|
||||
image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE]
|
||||
@@ -175,6 +175,7 @@ def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagno
|
||||
]
|
||||
|
||||
return ProjectAssetDiagnosisResponse(
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
readiness_score=score,
|
||||
readiness_label=_readiness_label(score),
|
||||
@@ -193,152 +194,23 @@ def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagno
|
||||
)
|
||||
|
||||
|
||||
def _build_single_asset_diagnosis(project_id: str, asset: Asset) -> ProjectAssetDiagnosisResponse:
|
||||
"""为单个素材构建诊断结果"""
|
||||
kind = _asset_kind(asset)
|
||||
is_ready = asset.status == AssetStatus.READY
|
||||
is_problem = asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}
|
||||
is_risky = is_ready and (
|
||||
(asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
)
|
||||
is_unclassified = is_ready and asset.classification_status.value in {"pending", "failed"}
|
||||
|
||||
# 单素材评分
|
||||
score = 0
|
||||
if is_ready:
|
||||
score = 60
|
||||
if kind == "video":
|
||||
score += 20
|
||||
if asset.duration and asset.duration >= 5:
|
||||
score += 10
|
||||
if asset.quality_score and asset.quality_score >= 60:
|
||||
score += 10
|
||||
if is_problem:
|
||||
score = max(score - 30, 0)
|
||||
if is_risky:
|
||||
score = max(score - 20, 0)
|
||||
score = max(0, min(100, score))
|
||||
|
||||
gaps: list[AssetGapItem] = []
|
||||
if not is_ready:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="asset_not_ready",
|
||||
severity="critical",
|
||||
message=f"素材状态为 {asset.status.value},尚未就绪",
|
||||
recommendation="等待素材导入完成后再使用。",
|
||||
)
|
||||
)
|
||||
if is_risky:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="asset_low_quality",
|
||||
severity="warning",
|
||||
message="素材质量分偏低或已被拒绝",
|
||||
recommendation="建议使用更清晰、稳定的素材替代。",
|
||||
)
|
||||
)
|
||||
if is_unclassified:
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="asset_unclassified",
|
||||
severity="info",
|
||||
message="素材尚未完成分类",
|
||||
recommendation="等待分类完成或手动检查素材类型。",
|
||||
)
|
||||
)
|
||||
if kind == "video" and (asset.duration is None or asset.duration < 5):
|
||||
gaps.append(
|
||||
AssetGapItem(
|
||||
key="short_video",
|
||||
severity="warning",
|
||||
message="视频时长偏短",
|
||||
recommendation="建议使用时长 5 秒以上的视频素材。",
|
||||
)
|
||||
)
|
||||
|
||||
used_count = int(asset.metadata.get("generation_use_count") or 0)
|
||||
smart_views = [
|
||||
AssetSmartViewItem(
|
||||
key="asset_info",
|
||||
label="素材信息",
|
||||
count=1,
|
||||
description=f"类型: {kind},状态: {asset.status.value}",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="asset_quality",
|
||||
label="质量评分",
|
||||
count=int(asset.quality_score or 0),
|
||||
description=f"质量分: {asset.quality_score or '未评分'}",
|
||||
),
|
||||
AssetSmartViewItem(
|
||||
key="asset_usage",
|
||||
label="使用次数",
|
||||
count=used_count,
|
||||
description=f"参与生成 {used_count} 次",
|
||||
),
|
||||
]
|
||||
|
||||
video_count = 1 if kind == "video" and is_ready else 0
|
||||
image_count = 1 if kind == "image" and is_ready else 0
|
||||
voice_count = 1 if kind == "voice" and is_ready else 0
|
||||
total_duration = round(float(asset.duration or 0), 2) if kind == "video" else 0.0
|
||||
|
||||
return ProjectAssetDiagnosisResponse(
|
||||
project_id=project_id,
|
||||
readiness_score=score,
|
||||
readiness_label=_readiness_label(score),
|
||||
total_assets=1,
|
||||
ready_assets=1 if is_ready else 0,
|
||||
video_assets=video_count,
|
||||
image_assets=image_count,
|
||||
voice_assets=voice_count,
|
||||
total_duration_seconds=total_duration,
|
||||
estimated_video_count=1 if video_count and total_duration >= 5 else 0,
|
||||
used_assets=1 if used_count > 0 else 0,
|
||||
unused_assets=1 if used_count == 0 and is_ready else 0,
|
||||
pending_review_assets=1 if asset.metadata.get("review_status") == "pending_review" else 0,
|
||||
smart_views=smart_views,
|
||||
gaps=gaps,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse)
|
||||
def get_project_asset_diagnosis(
|
||||
project_id: str,
|
||||
asset_id: Optional[str] = Query(None),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ProjectAssetDiagnosisResponse:
|
||||
try:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
|
||||
# 单素材诊断模式
|
||||
if asset_id:
|
||||
asset = asset_repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
if asset.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="Asset does not belong to this project")
|
||||
return _build_single_asset_diagnosis(project_id, asset)
|
||||
libraries = asset_library_repository.list_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
|
||||
libraries = asset_library_repository.find_by_project(project_id)
|
||||
assets: list[Asset] = []
|
||||
for library in libraries:
|
||||
assets.extend(asset_repository.list_by_library(library.id))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("素材诊断查询失败: project_id=%s", project_id)
|
||||
# 返回空诊断结果,避免 500
|
||||
return _build_diagnosis(project_id, [])
|
||||
|
||||
return _build_diagnosis(project_id, assets)
|
||||
return _build_diagnosis(project.workspace_id, project_id, assets)
|
||||
|
||||
Executable → Regular
+29
-115
@@ -1,18 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
EnsureDefaultLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetLibraryCommand,
|
||||
@@ -20,16 +15,26 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
|
||||
from ._helpers import check_project_access
|
||||
from packages.domain import AssetLibraryKind
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_workspace_member(
|
||||
workspace_id: str,
|
||||
authenticated_user: AuthenticatedUser,
|
||||
workspace_member_repository: WorkspaceMemberRepository,
|
||||
) -> None:
|
||||
member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id)
|
||||
if member is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
|
||||
|
||||
|
||||
def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
@@ -40,36 +45,20 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str | None = Query(None),
|
||||
kind: str | None = Query(None, pattern="^(video|voice|image)$"),
|
||||
project_id: str,
|
||||
kind: str | None = None,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListAssetLibrariesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
_require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListAssetLibrariesUseCase(asset_library_repository)
|
||||
|
||||
if project_id:
|
||||
# If project_id provided, check access and filter by project
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
items = use_case.execute(project_id)
|
||||
else:
|
||||
# If no project_id, list all libraries from accessible projects
|
||||
accessible_projects = project_repository.find_accessible_projects(user_id)
|
||||
all_items = []
|
||||
for proj in accessible_projects:
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
# 按 kind 过滤(可选)
|
||||
if kind:
|
||||
kind_enum = AssetLibraryKind(kind)
|
||||
items = [item for item in items if item.kind == kind_enum]
|
||||
|
||||
parsed_kind = AssetLibraryKind(kind) if kind else None
|
||||
items = use_case.execute(project_id, kind=parsed_kind)
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
@@ -79,94 +68,19 @@ def create_asset_library(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> AssetLibraryResponse:
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
project = GetProjectUseCase(project_repository).execute(request.project_id)
|
||||
if project is None or project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
_require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = CreateAssetLibraryUseCase(asset_library_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetLibraryCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
name=request.name,
|
||||
kind=AssetLibraryKind(request.kind),
|
||||
)
|
||||
)
|
||||
return _to_asset_library_response(item)
|
||||
|
||||
|
||||
# 默认素材库名称映射
|
||||
_DEFAULT_LIBRARY_NAMES = {
|
||||
"video": "视频素材库",
|
||||
"voice": "配音素材库",
|
||||
"image": "图片素材库",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ensure-default", response_model=AssetLibraryResponse)
|
||||
def ensure_default_library(
|
||||
request: EnsureDefaultLibraryRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetLibraryResponse:
|
||||
"""确保项目下指定 kind 的默认素材库存在,已存在则直接返回,不存在则自动创建。"""
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
|
||||
|
||||
kind = AssetLibraryKind(request.kind)
|
||||
|
||||
# 查找该项目下同 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 = 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)
|
||||
def delete_asset_library(
|
||||
library_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除素材库,同时删除库内所有素材。"""
|
||||
# 查找素材库
|
||||
library = asset_library_repository.find_by_id(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
|
||||
|
||||
# 权限校验:检查用户是否有项目访问权限
|
||||
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态)
|
||||
assets_in_library = asset_repository.find_by_library(library_id)
|
||||
for asset in assets_in_library:
|
||||
asset_repository.delete(asset.id)
|
||||
|
||||
# 删除素材库本身
|
||||
asset_library_repository.delete(library_id)
|
||||
|
||||
Executable → Regular
+57
-608
@@ -1,58 +1,31 @@
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
get_tag_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchGetRequest,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
ListAssetsResponse,
|
||||
SmartMatchItem,
|
||||
SmartMatchRequest,
|
||||
SmartMatchResponse,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetReviewRequest,
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
ListAssetsUseCase,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
# 生成签名文件 URL(用于视频播放 / 文件下载)
|
||||
file_url = None
|
||||
if item.storage_key:
|
||||
try:
|
||||
svc = storage_service or get_storage_service()
|
||||
file_url = svc.get_download_url(item.storage_key)
|
||||
except Exception:
|
||||
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
|
||||
file_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
|
||||
|
||||
def _to_asset_response(item) -> AssetResponse:
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
@@ -60,8 +33,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
file_size=item.file_size,
|
||||
file_url=file_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
duration=item.duration,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
@@ -70,274 +42,25 @@ 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", []),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: Optional[str] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
kind: Optional[str] = Query(None, pattern="^(video|voice|image)$"),
|
||||
keyword: Optional[str] = Query(None, description="按名称模糊匹配"),
|
||||
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
||||
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
||||
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
||||
smart_view: Optional[str] = Query(
|
||||
None,
|
||||
description="智能视图筛选:recommended=推荐(质量分≥80)、cautious=慎用(60-79)、risky=高风险(<60或已驳回)、unused=未使用、used=已使用、pending_review=待复核",
|
||||
pattern="^(recommended|cautious|risky|unused|used|pending_review)$",
|
||||
),
|
||||
classification: Optional[str] = Query(
|
||||
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),
|
||||
library_id: str,
|
||||
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),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> 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"}
|
||||
|
||||
# 解析 tag_ids 参数(逗号分隔)
|
||||
filter_tag_ids: list[str] | None = None
|
||||
if tag_ids:
|
||||
filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()]
|
||||
if not filter_tag_ids:
|
||||
filter_tag_ids = None
|
||||
|
||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids/smart_view/classification 无法在 DB 层过滤)
|
||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids or smart_view or classification)
|
||||
|
||||
def _apply_memory_filters(items):
|
||||
"""应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。"""
|
||||
result = items
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
result = [i for i in result if kw in (i.name or "").lower()]
|
||||
if gender:
|
||||
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
||||
if style:
|
||||
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
||||
if classification:
|
||||
result = [i for i in result if (i.metadata or {}).get("classification") == classification]
|
||||
if filter_tag_ids:
|
||||
tag_set = set(filter_tag_ids)
|
||||
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
||||
if smart_view:
|
||||
|
||||
def __meta(a):
|
||||
return a.metadata or {}
|
||||
|
||||
def __use_count(a):
|
||||
return int(__meta(a).get("generation_use_count") or 0)
|
||||
|
||||
def __review_status(a):
|
||||
return __meta(a).get("review_status", "")
|
||||
|
||||
if smart_view == "recommended":
|
||||
result = [i for i in result if i.quality_score is not None and i.quality_score >= 80]
|
||||
elif smart_view == "cautious":
|
||||
result = [i for i in result if i.quality_score is not None and 60 <= i.quality_score < 80]
|
||||
elif smart_view == "risky":
|
||||
result = [
|
||||
i
|
||||
for i in result
|
||||
if (i.quality_score is not None and i.quality_score < 60) or __review_status(i) == "rejected"
|
||||
]
|
||||
elif smart_view == "unused":
|
||||
result = [i for i in result if __use_count(i) == 0]
|
||||
elif smart_view == "used":
|
||||
result = [i for i in result if __use_count(i) > 0]
|
||||
elif smart_view == "pending_review":
|
||||
result = [i for i in result if __review_status(i) == "pending_review"]
|
||||
return result
|
||||
|
||||
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
||||
if not needs_memory_filter:
|
||||
ft = kind_to_file_type.get(kind) if kind else None
|
||||
|
||||
# 模式1:指定 library_id
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
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)
|
||||
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)
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in items],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 模式2:指定 project_id
|
||||
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
|
||||
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)
|
||||
paged = items
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 模式3:跨项目(无 library_id/project_id)
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||
|
||||
project_ids = [p.id for p in projects]
|
||||
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: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
proj_total = asset_repository.count_by_project(pid, status=status_list)
|
||||
if offset >= proj_total:
|
||||
offset -= proj_total
|
||||
continue
|
||||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
|
||||
paged_items.extend(proj_items)
|
||||
remaining -= len(proj_items)
|
||||
offset = 0
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged_items],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ──
|
||||
if library_id:
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
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
|
||||
)
|
||||
else:
|
||||
all_items = asset_repository.find_by_library(library_id, status=status_list)
|
||||
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)
|
||||
else:
|
||||
try:
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
except Exception:
|
||||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||||
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))
|
||||
|
||||
# 应用 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]
|
||||
filtered = _apply_memory_filters(all_items)
|
||||
total = len(filtered)
|
||||
paged = filtered[skip : skip + limit]
|
||||
return ListAssetsResponse(
|
||||
items=[_to_asset_response(item) for item in paged],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
library = asset_library_repository.get(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||
require_workspace_member(library.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListAssetsUseCase(asset_repository)
|
||||
items = use_case.execute(library_id)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
|
||||
def _apply_asset_review_status(item, review_status: str):
|
||||
@@ -354,329 +77,55 @@ def update_asset_review_status(
|
||||
request: UpdateAssetReviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.get(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
_apply_asset_review_status(item, request.review_status)
|
||||
updated = asset_repository.update(item)
|
||||
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,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_delete(success_ids)
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-tag", response_model=BatchOperationResponse)
|
||||
def batch_tag_assets(
|
||||
request: BatchTagRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
# 校验标签存在且属于当前用户
|
||||
for tag_id in request.tag_ids:
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
return BatchOperationResponse(
|
||||
success_count=0,
|
||||
failed_ids=list(request.asset_ids),
|
||||
failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids},
|
||||
)
|
||||
if tag.user_id != user_id:
|
||||
return BatchOperationResponse(
|
||||
success_count=0,
|
||||
failed_ids=list(request.asset_ids),
|
||||
failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids},
|
||||
)
|
||||
|
||||
# 校验素材权限
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
if request.mode == "replace":
|
||||
asset_repository.batch_replace_tags(success_ids, request.tag_ids)
|
||||
else:
|
||||
asset_repository.batch_add_tags(success_ids, request.tag_ids)
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-classify", response_model=BatchOperationResponse)
|
||||
def batch_classify_assets(
|
||||
request: BatchClassifyRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量修改素材内容分类(person/scenic/product等),存在metadata.category中。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_update_metadata(success_ids, {"category": request.category})
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-mark", response_model=BatchOperationResponse)
|
||||
def batch_mark_assets(
|
||||
request: BatchMarkRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view})
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/smart-match", response_model=SmartMatchResponse)
|
||||
def smart_match_assets(
|
||||
request: SmartMatchRequest,
|
||||
@router.post("", response_model=AssetResponse)
|
||||
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),
|
||||
) -> SmartMatchResponse:
|
||||
"""智能选素材:根据素材库内容,按质量分+时长均衡+新鲜度+未使用偏好综合评分,返回 Top N 素材。"""
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> AssetResponse:
|
||||
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None or project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None:
|
||||
if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id:
|
||||
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
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
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,
|
||||
)
|
||||
else:
|
||||
filtered_assets = asset_repository.find_by_library(
|
||||
request.library_id, status=["ready"], limit=10000
|
||||
)
|
||||
total_candidates = len(filtered_assets)
|
||||
|
||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(
|
||||
filtered_assets,
|
||||
limit=request.limit,
|
||||
kind=None,
|
||||
)
|
||||
|
||||
items = [
|
||||
SmartMatchItem(
|
||||
asset=_to_asset_response(r.asset),
|
||||
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,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
return _to_asset_response(item)
|
||||
|
||||
|
||||
@router.put("/{asset_id}", response_model=AssetResponse)
|
||||
def update_asset(
|
||||
asset_id: str,
|
||||
request: UpdateAssetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 合并可修改字段
|
||||
if request.name is not None:
|
||||
item.name = request.name
|
||||
if request.metadata is not None:
|
||||
item.metadata = {**item.metadata, **request.metadata}
|
||||
if request.tags is not None:
|
||||
item.metadata = {**item.metadata, "tags": request.tags}
|
||||
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204, response_class=Response)
|
||||
def delete_asset(
|
||||
asset_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
asset_repository.delete(asset_id)
|
||||
|
||||
|
||||
@router.post("/{asset_id}/tags", response_model=AssetResponse)
|
||||
def tag_asset(
|
||||
asset_id: str,
|
||||
request: TagAssetsRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> AssetResponse:
|
||||
"""给素材打标签。"""
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
for tag_id in request.tag_ids:
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found")
|
||||
if tag.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}")
|
||||
item.add_tag(tag_id)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response)
|
||||
def untag_asset(
|
||||
asset_id: str,
|
||||
tag_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""取消素材的标签。"""
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||
item.remove_tag(tag_id)
|
||||
asset_repository.update(item)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset() -> None:
|
||||
"""
|
||||
已废弃接口。
|
||||
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
|
||||
)
|
||||
|
||||
Executable → Regular
+13
-371
@@ -5,24 +5,18 @@ The route layer is intentionally thin: repository construction lives in
|
||||
app.dependencies and authentication behavior lives in application use cases.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import settings
|
||||
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 fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
|
||||
from packages.application.auth.login_use_case import LoginUseCase
|
||||
from packages.application.auth.login_use_case import RefreshTokenRequest as RefreshTokenUseCaseRequest
|
||||
from packages.application.auth.login_use_case import RefreshTokenUseCase
|
||||
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
|
||||
from packages.application.auth.password_reset_use_case import (
|
||||
RequestPasswordResetUseCase,
|
||||
@@ -33,10 +27,6 @@ from packages.application.auth.register_user_use_case import RegisterUserRequest
|
||||
from packages.application.auth.register_user_use_case import RegisterUserUseCase, VerifyEmailRequest, VerifyEmailUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@@ -60,13 +50,8 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
email: str
|
||||
@@ -81,9 +66,6 @@ class CurrentUserResponse(BaseModel):
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -108,10 +90,10 @@ async def register(
|
||||
request: RegisterRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
) -> RegisterResponse:
|
||||
):
|
||||
use_case = RegisterUserUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url=settings.APP_BASE_URL,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
@@ -139,7 +121,7 @@ async def login(
|
||||
request: LoginRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
session_store=Depends(get_auth_session_store),
|
||||
) -> LoginResponse:
|
||||
):
|
||||
use_case = LoginUseCase(
|
||||
user_repository=user_repository,
|
||||
session_store=session_store,
|
||||
@@ -151,32 +133,6 @@ async def login(
|
||||
|
||||
return LoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
display_name=response.display_name,
|
||||
expires_in=response.expires_in,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(
|
||||
request: RefreshRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
session_store=Depends(get_auth_session_store),
|
||||
) -> LoginResponse:
|
||||
use_case = RefreshTokenUseCase(
|
||||
user_repository=user_repository,
|
||||
session_store=session_store,
|
||||
)
|
||||
response, error = use_case.execute(RefreshTokenUseCaseRequest(refresh_token=request.refresh_token))
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
email=response.email,
|
||||
username=response.username,
|
||||
@@ -197,7 +153,7 @@ def _verify_email_token(token: str, user_repository: UserRepository) -> MessageR
|
||||
async def verify_email(
|
||||
token: str,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> MessageResponse:
|
||||
):
|
||||
return _verify_email_token(token, user_repository)
|
||||
|
||||
|
||||
@@ -205,19 +161,20 @@ async def verify_email(
|
||||
async def verify_email_post(
|
||||
request: VerifyEmailRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> MessageResponse:
|
||||
):
|
||||
return _verify_email_token(request.token, user_repository)
|
||||
|
||||
|
||||
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def forgot_password(
|
||||
request: PasswordResetRequestModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
email_service=Depends(get_auth_email_service),
|
||||
) -> MessageResponse:
|
||||
):
|
||||
success, error = RequestPasswordResetUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url=settings.APP_BASE_URL,
|
||||
base_url="http://localhost:3000",
|
||||
email_service=email_service,
|
||||
).execute(PasswordResetUseCaseRequest(email=request.email))
|
||||
if not success:
|
||||
@@ -226,11 +183,12 @@ async def forgot_password(
|
||||
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
|
||||
|
||||
|
||||
@router.post("/password/reset", response_model=MessageResponse)
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
async def reset_password(
|
||||
request: ResetPasswordModel,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> MessageResponse:
|
||||
):
|
||||
success, error = ResetPasswordUseCase(user_repository=user_repository).execute(
|
||||
ResetPasswordRequest(token=request.token, new_password=request.new_password)
|
||||
)
|
||||
@@ -240,38 +198,17 @@ async def reset_password(
|
||||
return MessageResponse(message="密码重置成功")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> MessageResponse:
|
||||
"""登出 - 将当前 token 加入黑名单"""
|
||||
|
||||
if credentials:
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
exp = payload.get("exp", 0)
|
||||
blacklist_token(credentials.credentials, exp)
|
||||
except Exception as e:
|
||||
logger.warning(f"Operation failed in apps/api/app/api/routes/auth.py: {e}", exc_info=True)
|
||||
return MessageResponse(message="已登出")
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -291,298 +228,3 @@ def _translate_auth_error(error: str | None) -> str:
|
||||
"Display name is required": "显示名称不能为空",
|
||||
}
|
||||
return translations.get(error or "", error or "注册失败")
|
||||
|
||||
|
||||
class WechatSyncRequest(BaseModel):
|
||||
openid: str
|
||||
unionid: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
source: str = "miniapp"
|
||||
|
||||
|
||||
class WechatSyncResponse(BaseModel):
|
||||
access_token: str
|
||||
token: str
|
||||
refresh_token: str
|
||||
user_id: str
|
||||
user: dict
|
||||
user_info: dict
|
||||
is_new_user: bool
|
||||
expires_in: int
|
||||
|
||||
|
||||
def _get_internal_api_keys() -> list[str]:
|
||||
"""获取内部 API Key 列表
|
||||
|
||||
优先级:
|
||||
1. INTERNAL_API_KEYS 环境变量
|
||||
2. /app/generated/internal_api_keys.txt 文件 (volume 持久化)
|
||||
"""
|
||||
env_keys = os.environ.get("INTERNAL_API_KEYS", "")
|
||||
if env_keys:
|
||||
return [k.strip() for k in env_keys.split(",") if k.strip()]
|
||||
|
||||
# 从持久化文件读取
|
||||
try:
|
||||
with open("/app/generated/internal_api_keys.txt", "r") as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
return [k.strip() for k in content.split(",") if k.strip()]
|
||||
except Exception:
|
||||
logger.warning("无法读取内部 API 密钥文件,仅依赖环境变量配置", exc_info=True)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool:
|
||||
"""验证内部 API Key
|
||||
|
||||
- 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key
|
||||
- 未配置且非生产环境:放行(方便开发)
|
||||
- 未配置且生产环境:拒绝
|
||||
"""
|
||||
env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower()
|
||||
key_list = _get_internal_api_keys()
|
||||
|
||||
if not key_list:
|
||||
if env in ("production", "prod"):
|
||||
raise HTTPException(status_code=401, detail="内部接口未配置 API Key")
|
||||
return True
|
||||
|
||||
if x_api_key and x_api_key.strip() in key_list:
|
||||
return True
|
||||
|
||||
raise HTTPException(status_code=401, detail="无效的 API Key")
|
||||
|
||||
|
||||
@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False)
|
||||
async def wechat_sync(
|
||||
request: WechatSyncRequest,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
) -> WechatSyncResponse:
|
||||
"""
|
||||
微信同步登录/注册(系统级内部接口)
|
||||
|
||||
由 BFF 层通过 API Key 调用,不直接面向终端用户。
|
||||
根据 openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as UseCaseRequest
|
||||
from packages.application.auth.wechat_sync_use_case import (
|
||||
WechatSyncUseCase,
|
||||
)
|
||||
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
use_case_request = UseCaseRequest(
|
||||
openid=request.openid,
|
||||
unionid=request.unionid,
|
||||
nickname=request.nickname,
|
||||
avatar_url=request.avatar_url,
|
||||
source=request.source,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(use_case_request)
|
||||
if error:
|
||||
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,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatLoginResponse:
|
||||
"""微信登录回调处理"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
|
||||
|
||||
# 1. 用 code 换微信用户信息
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
sync_request = SyncRequest(
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid,
|
||||
nickname=wechat_user.nickname,
|
||||
avatar_url=wechat_user.avatar_url,
|
||||
source="web",
|
||||
)
|
||||
response, err = use_case.execute(sync_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
# 3. 判断绑定状态
|
||||
user = user_repository.find_by_id(response.user_id)
|
||||
binding_complete = False
|
||||
if user:
|
||||
binding_complete = (
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
|
||||
return WechatLoginResponse(
|
||||
access_token=response.access_token,
|
||||
refresh_token=response.refresh_token,
|
||||
user_id=response.user_id,
|
||||
display_name=response.nickname,
|
||||
avatar_url=response.avatar_url or wechat_user.avatar_url,
|
||||
is_new_user=response.is_new_user,
|
||||
binding_complete=binding_complete,
|
||||
expires_in=response.expires_in,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
class SendVerificationCodeRequest(BaseModel):
|
||||
target: str # phone / email
|
||||
value: str
|
||||
purpose: str # bind / login / reset_password
|
||||
|
||||
|
||||
class SendVerificationCodeResponse(BaseModel):
|
||||
expires_in: int
|
||||
resend_after: int
|
||||
|
||||
|
||||
class BindContactRequest(BaseModel):
|
||||
phone: str = ""
|
||||
phone_code: str = ""
|
||||
email: str = ""
|
||||
email_code: str = ""
|
||||
|
||||
|
||||
class BindContactResponse(BaseModel):
|
||||
success: bool
|
||||
user: dict
|
||||
|
||||
|
||||
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
||||
async def send_verification_code(
|
||||
request: SendVerificationCodeRequest,
|
||||
) -> SendVerificationCodeResponse:
|
||||
"""发送验证码(手机或邮箱)"""
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
from packages.adapters.sms.sms_service import get_sms_service
|
||||
from packages.adapters.smtp import get_email_service
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
)
|
||||
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
SendVerificationCodeUseCase,
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=repo)
|
||||
sms_service = get_sms_service()
|
||||
email_service = get_email_service()
|
||||
|
||||
use_case = SendVerificationCodeUseCase(
|
||||
verification_code_service=vc_service,
|
||||
sms_service=sms_service,
|
||||
email_service=email_service,
|
||||
)
|
||||
uc_request = UseCaseRequest(
|
||||
target=request.target,
|
||||
value=request.value,
|
||||
purpose=request.purpose,
|
||||
)
|
||||
response, err = use_case.execute(uc_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
return SendVerificationCodeResponse(
|
||||
expires_in=response.expires_in,
|
||||
resend_after=response.resend_after,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bind-contact", response_model=BindContactResponse)
|
||||
async def bind_contact(
|
||||
request: BindContactRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> BindContactResponse:
|
||||
"""绑定手机号和/或邮箱(需登录态)"""
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
)
|
||||
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
|
||||
from packages.application.auth.bind_contact_use_case import (
|
||||
BindContactUseCase,
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
vc_repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=vc_repo)
|
||||
|
||||
use_case = BindContactUseCase(
|
||||
user_repository=user_repository,
|
||||
verification_code_service=vc_service,
|
||||
)
|
||||
uc_request = UseCaseRequest(
|
||||
user_id=current_user.user.id,
|
||||
phone=request.phone,
|
||||
phone_code=request.phone_code,
|
||||
email=request.email,
|
||||
email_code=request.email_code,
|
||||
)
|
||||
response, err = use_case.execute(uc_request)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
return BindContactResponse(success=True, user=response.to_dict()["user"])
|
||||
|
||||
|
||||
# ==================== 当前用户信息扩展 ====================
|
||||
|
||||
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
|
||||
# 通过给 get_current_user_info 返回值补充字段实现
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""
|
||||
Chunked upload routes for large file uploads (up to 2GB).
|
||||
Supports chunked upload, resume, and automatic cleanup of expired uploads.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
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.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.schemas.chunked_upload import (
|
||||
ChunkedUploadCompleteRequest,
|
||||
ChunkedUploadCompleteResponse,
|
||||
ChunkedUploadInitRequest,
|
||||
ChunkedUploadInitResponse,
|
||||
ChunkedUploadStatusResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi.params import File
|
||||
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/mpeg",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mp3",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
CHUNK_STORAGE_ROOT = Path(tempfile.gettempdir()) / "chunked_uploads"
|
||||
|
||||
|
||||
def _get_chunk_dir(upload_id: str) -> Path:
|
||||
"""Get chunk storage directory"""
|
||||
return CHUNK_STORAGE_ROOT / upload_id
|
||||
|
||||
|
||||
def _get_upload_meta_path(upload_id: str) -> Path:
|
||||
"""Get upload metadata file path"""
|
||||
return CHUNK_STORAGE_ROOT / f"{upload_id}.meta.json"
|
||||
|
||||
|
||||
def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
|
||||
"""
|
||||
Atomically check if chunk is uploaded and record if not.
|
||||
Uses file locking to prevent race conditions.
|
||||
|
||||
Returns:
|
||||
True if chunk was newly recorded, False if already exists
|
||||
"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
CHUNK_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
meta = json.load(f)
|
||||
if chunk_index in meta["uploaded_chunks"]:
|
||||
return False
|
||||
meta["uploaded_chunks"].append(chunk_index)
|
||||
meta["status"] = "uploading"
|
||||
f.seek(0)
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
f.truncate()
|
||||
return True
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
||||
"""Load upload metadata"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if not meta_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
|
||||
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _save_upload_meta(upload_id: str, meta: dict[str, Any]) -> None:
|
||||
"""Save upload metadata"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
CHUNK_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _validate_file_type(content: bytes, filename: str) -> str:
|
||||
"""Validate file type"""
|
||||
try:
|
||||
import magic
|
||||
|
||||
detected_mime = magic.from_buffer(content, mime=True)
|
||||
except ImportError:
|
||||
import mimetypes
|
||||
|
||||
detected_mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}",
|
||||
)
|
||||
return detected_mime
|
||||
|
||||
|
||||
def _cleanup_expired_uploads() -> int:
|
||||
"""Cleanup expired uploads, returns number of cleaned uploads"""
|
||||
if not CHUNK_STORAGE_ROOT.exists():
|
||||
return 0
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
cleaned = 0
|
||||
|
||||
for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"):
|
||||
try:
|
||||
with open(meta_file, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
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":
|
||||
upload_id = meta["upload_id"]
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
if chunk_dir.exists():
|
||||
shutil.rmtree(chunk_dir)
|
||||
meta_file.unlink()
|
||||
cleaned += 1
|
||||
logger.info(f"Cleaned up expired upload: {upload_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup upload metadata {meta_file}: {e}")
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@router.post("/init", response_model=ChunkedUploadInitResponse)
|
||||
async def init_chunked_upload(
|
||||
request: ChunkedUploadInitRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> ChunkedUploadInitResponse:
|
||||
"""Initialize chunked upload"""
|
||||
|
||||
# Validate file size
|
||||
if request.file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File exceeds maximum size ({MAX_FILE_SIZE // (1024 * 1024 * 1024)}GB)",
|
||||
)
|
||||
|
||||
# Validate project exists
|
||||
project = GetProjectUseCase(project_repository).execute(request.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
# Verify asset library
|
||||
require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
project_repository,
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
# Calculate chunk size
|
||||
chunk_size = DEFAULT_CHUNK_SIZE
|
||||
expected_chunks = (request.file_size + chunk_size - 1) // chunk_size
|
||||
if expected_chunks != request.total_chunks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"total_chunks mismatch. Expected {expected_chunks} for file size {request.file_size} with chunk size {chunk_size}",
|
||||
)
|
||||
|
||||
# Cleanup expired uploads
|
||||
_cleanup_expired_uploads()
|
||||
|
||||
# Generate upload ID
|
||||
upload_id = uuid4().hex
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS)
|
||||
|
||||
# Create chunk directory
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
chunk_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save metadata
|
||||
meta = {
|
||||
"upload_id": upload_id,
|
||||
"filename": request.filename.replace("/", "_").replace("\\", "_"),
|
||||
"file_size": request.file_size,
|
||||
"total_chunks": request.total_chunks,
|
||||
"uploaded_chunks": [],
|
||||
"content_type": request.content_type,
|
||||
"project_id": request.project_id,
|
||||
"library_id": request.library_id,
|
||||
"status": "pending",
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
_save_upload_meta(upload_id, meta)
|
||||
|
||||
return ChunkedUploadInitResponse(
|
||||
upload_id=upload_id,
|
||||
chunk_size=chunk_size,
|
||||
total_chunks=request.total_chunks,
|
||||
filename=meta["filename"],
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
|
||||
async def get_upload_status(
|
||||
upload_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ChunkedUploadStatusResponse:
|
||||
"""Get upload status (for resume)"""
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return ChunkedUploadStatusResponse(
|
||||
upload_id=upload_id,
|
||||
filename=meta["filename"],
|
||||
file_size=meta["file_size"],
|
||||
total_chunks=meta["total_chunks"],
|
||||
uploaded_chunks=sorted(meta["uploaded_chunks"]),
|
||||
status=meta["status"],
|
||||
created_at=datetime.fromisoformat(meta["created_at"]),
|
||||
expires_at=datetime.fromisoformat(meta["expires_at"]),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/complete", response_model=ChunkedUploadCompleteResponse)
|
||||
async def complete_chunked_upload(
|
||||
upload_id: str,
|
||||
request: ChunkedUploadCompleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ChunkedUploadCompleteResponse:
|
||||
"""Complete chunked upload, merge chunks"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Verify project ID and library ID
|
||||
if request.project_id != meta["project_id"] or request.library_id != meta["library_id"]:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project or library ID mismatch")
|
||||
|
||||
# Verify all chunks are uploaded
|
||||
expected_chunks = set(range(meta["total_chunks"]))
|
||||
uploaded_chunks = set(meta["uploaded_chunks"])
|
||||
missing_chunks = expected_chunks - uploaded_chunks
|
||||
|
||||
if missing_chunks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Missing chunks: {sorted(missing_chunks)}. Please upload remaining chunks first.",
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
chunk_dir = _get_chunk_dir(upload_id)
|
||||
sample_chunk_path = chunk_dir / "chunk_000000"
|
||||
if sample_chunk_path.exists():
|
||||
with open(sample_chunk_path, "rb") as f:
|
||||
sample_data = f.read(8192) # Read first 8KB for type detection
|
||||
detected_mime = _validate_file_type(sample_data, meta["filename"])
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}",
|
||||
)
|
||||
|
||||
# Merge chunks to temp file
|
||||
temp_file_path = CHUNK_STORAGE_ROOT / f"{upload_id}_complete.tmp"
|
||||
try:
|
||||
with open(temp_file_path, "wb") as out_file:
|
||||
for i in range(meta["total_chunks"]):
|
||||
chunk_path = chunk_dir / f"chunk_{i:06d}"
|
||||
with open(chunk_path, "rb") as in_file:
|
||||
shutil.copyfileobj(in_file, out_file)
|
||||
|
||||
# Verify file size
|
||||
actual_size = temp_file_path.stat().st_size
|
||||
if actual_size != meta["file_size"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"File size mismatch. Expected {meta['file_size']}, got {actual_size}",
|
||||
)
|
||||
|
||||
# Upload to OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = meta["filename"]
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
file_url = storage_service.upload_file(
|
||||
str(temp_file_path),
|
||||
storage_key,
|
||||
content_type=meta["content_type"],
|
||||
)
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id="",
|
||||
url=file_url,
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# Create ingest job
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
project_id=meta["project_id"],
|
||||
library_id=meta["library_id"],
|
||||
storage_key=storage_key,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
# Update metadata status
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id=job.id,
|
||||
url=file_url,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temp file and chunks
|
||||
if temp_file_path.exists():
|
||||
temp_file_path.unlink()
|
||||
if chunk_dir.exists():
|
||||
shutil.rmtree(chunk_dir)
|
||||
# Delete metadata file
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
|
||||
@router.post("/{upload_id}/{chunk_index}")
|
||||
async def upload_chunk(
|
||||
upload_id: str,
|
||||
chunk_index: int,
|
||||
chunk: UploadFile = File(..., description="Chunk data"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a single chunk"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
|
||||
|
||||
# Validate chunk index
|
||||
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
|
||||
)
|
||||
|
||||
# Atomic check and record to prevent race conditions
|
||||
if not _atomic_check_and_record(upload_id, chunk_index):
|
||||
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
|
||||
|
||||
# Read chunk data
|
||||
chunk_data = await chunk.read()
|
||||
|
||||
# Validate chunk size (last chunk can be smaller than chunk_size)
|
||||
expected_size = DEFAULT_CHUNK_SIZE
|
||||
if chunk_index == meta["total_chunks"] - 1:
|
||||
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
|
||||
|
||||
if len(chunk_data) != expected_size:
|
||||
# Rollback the recorded chunk
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
meta = json.load(f)
|
||||
if chunk_index in meta["uploaded_chunks"]:
|
||||
meta["uploaded_chunks"].remove(chunk_index)
|
||||
f.seek(0)
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
|
||||
)
|
||||
|
||||
# Save chunk
|
||||
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
|
||||
with open(chunk_path, "wb") as f:
|
||||
f.write(chunk_data)
|
||||
|
||||
# Reload metadata for response
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return {
|
||||
"message": "Chunk uploaded successfully",
|
||||
"chunk_index": chunk_index,
|
||||
"uploaded_chunks": len(meta["uploaded_chunks"]),
|
||||
"total_chunks": meta["total_chunks"],
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
@@ -26,9 +27,10 @@ def get_classification_job(
|
||||
raise HTTPException(status_code=404, detail=f"ClassificationJob {job_id} not found")
|
||||
return ClassificationJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
asset_id=job.asset_id,
|
||||
status=job.status,
|
||||
status=job.status.value,
|
||||
classification=job.classification,
|
||||
confidence=job.confidence,
|
||||
error_message=job.error_message,
|
||||
@@ -43,6 +45,7 @@ def submit_classification_job(
|
||||
use_case = SubmitClassificationJobUseCase(classification_job_repository)
|
||||
job = use_case.execute(
|
||||
SubmitClassificationJobCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
asset_id=request.asset_id,
|
||||
)
|
||||
@@ -50,9 +53,10 @@ def submit_classification_job(
|
||||
celery_app.send_task("worker.classify_asset", args=[job.id])
|
||||
return ClassificationJobResponse(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
asset_id=job.asset_id,
|
||||
status=job.status,
|
||||
status=job.status.value,
|
||||
classification=job.classification,
|
||||
confidence=job.confidence,
|
||||
error_message=job.error_message,
|
||||
|
||||
@@ -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)
|
||||
@@ -1,303 +0,0 @@
|
||||
"""查重 API 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_duplication_repository
|
||||
from app.schemas.duplication import (
|
||||
DuplicateSegmentResponse,
|
||||
DuplicationDetailResponse,
|
||||
DuplicationRecordResponse,
|
||||
DuplicationUploadResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
|
||||
|
||||
from packages.application import (
|
||||
DeleteDuplicationRecordUseCase,
|
||||
GetDuplicationDetailUseCase,
|
||||
ListDuplicationRecordsUseCase,
|
||||
RetryDuplicationUseCase,
|
||||
UploadForDuplicationCommand,
|
||||
UploadForDuplicationUseCase,
|
||||
)
|
||||
from packages.domain.duplication import DuplicationRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
tags=["查重"],
|
||||
)
|
||||
|
||||
# 查重功能只接受视频文件
|
||||
ALLOWED_VIDEO_MIME_TYPES = frozenset(
|
||||
{
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_video_mime_type(content_type: str | None) -> str:
|
||||
"""验证视频文件的 MIME 类型,如果无效则抛出异常。"""
|
||||
if not content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
|
||||
)
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
|
||||
return DuplicationRecordResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
|
||||
return DuplicationDetailResponse(
|
||||
id=record.id,
|
||||
filename=record.filename,
|
||||
file_size=record.file_size,
|
||||
duration_seconds=record.duration_seconds,
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
segments=[
|
||||
DuplicateSegmentResponse(
|
||||
id=seg.id,
|
||||
source_start=seg.source_start,
|
||||
source_end=seg.source_end,
|
||||
matched_video_id=seg.matched_video_id,
|
||||
matched_video_name=seg.matched_video_name,
|
||||
matched_start=seg.matched_start,
|
||||
matched_end=seg.matched_end,
|
||||
similarity=seg.similarity,
|
||||
)
|
||||
for seg in record.segments
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DuplicationUploadResponse)
|
||||
async def upload_for_duplication(
|
||||
file: UploadFile = File(..., description="要查重的视频文件"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""上传视频进行查重。"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件名不能为空",
|
||||
)
|
||||
|
||||
# P0-1: 验证 MIME 类型(只接受视频文件)
|
||||
validated_content_type = _validate_video_mime_type(file.content_type)
|
||||
|
||||
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB)
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
|
||||
# 先检查 Content-Length header(如果可用)
|
||||
if file.size is not None and file.size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
|
||||
# 读取文件内容并上传到 OSS
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"duplication/{file_id}/{safe_filename}"
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# 再次检查实际文件大小
|
||||
if file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("读取查重文件失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件读取失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
storage_service.upload_file(
|
||||
content,
|
||||
storage_key,
|
||||
content_type=validated_content_type,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="文件上传失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
use_case = UploadForDuplicationUseCase(duplication_repository)
|
||||
record = use_case.execute(
|
||||
UploadForDuplicationCommand(
|
||||
user_id=authenticated_user.user.id,
|
||||
filename=file.filename,
|
||||
file_size=file_size,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Duplication upload: record=%s file=%s user=%s",
|
||||
record.id,
|
||||
file.filename,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=record.id,
|
||||
status=record.status,
|
||||
message=f'文件 "{file.filename}" 已上传,正在查重中...',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/records", response_model=list[DuplicationRecordResponse])
|
||||
def list_duplication_records(
|
||||
offset: int = Query(0, ge=0, description="分页偏移量"),
|
||||
limit: int = Query(50, ge=1, le=200, description="每页数量,最大 200"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> list[DuplicationRecordResponse]:
|
||||
"""
|
||||
获取当前用户的查重记录列表。
|
||||
|
||||
支持分页:通过 offset 和 limit 参数控制。
|
||||
返回按创建时间倒序排列的记录。
|
||||
"""
|
||||
use_case = ListDuplicationRecordsUseCase(duplication_repository)
|
||||
records = use_case.execute(user_id=authenticated_user.user.id, offset=offset, limit=limit)
|
||||
return [_to_record_response(r) for r in records]
|
||||
|
||||
|
||||
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
|
||||
def get_duplication_detail(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationDetailResponse:
|
||||
"""获取查重记录详情(含重复片段)。"""
|
||||
use_case = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = use_case.execute(record_id)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
if record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||||
)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> Response:
|
||||
"""删除查重记录。"""
|
||||
# 检查记录是否存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||||
use_case.execute(record_id)
|
||||
return
|
||||
|
||||
|
||||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||||
def retry_duplication(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
duplication_repository: Any = Depends(get_duplication_repository),
|
||||
) -> DuplicationUploadResponse:
|
||||
"""
|
||||
重新提交查重。
|
||||
|
||||
仅 failed 状态的记录允许重试,其他状态返回 400。
|
||||
"""
|
||||
# 检查记录存在且属于当前用户
|
||||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||||
record = detail_uc.execute(record_id)
|
||||
if record is None or record.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
use_case = RetryDuplicationUseCase(duplication_repository)
|
||||
try:
|
||||
updated = use_case.execute(record_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=updated.id,
|
||||
status=updated.status,
|
||||
message="已重新提交查重",
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.edit_plan import CreateEditPlanRequest, EditPlanClipResponse, EditPlanResponse, EditTemplateResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
|
||||
from packages.domain import AssetStatus
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
|
||||
|
||||
|
||||
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None or project.workspace_id != workspace_id:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_workspace_member(workspace_id, user, member_repository)
|
||||
return project
|
||||
|
||||
|
||||
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
|
||||
template = (
|
||||
session.query(EditTemplateModel)
|
||||
.filter(
|
||||
EditTemplateModel.project_id == project_id,
|
||||
EditTemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(EditTemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if template is not None:
|
||||
return template
|
||||
template = EditTemplateModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
name="基础节奏模板",
|
||||
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
|
||||
target_duration=30,
|
||||
clip_count=3,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return template
|
||||
|
||||
|
||||
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
|
||||
return EditTemplateResponse(
|
||||
id=template.id,
|
||||
workspace_id=template.workspace_id,
|
||||
project_id=template.project_id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
target_duration=float(template.target_duration or 0),
|
||||
clip_count=int(template.clip_count or 0),
|
||||
is_active=bool(template.is_active),
|
||||
created_at=template.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_plan_response(
|
||||
plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str]
|
||||
) -> EditPlanResponse:
|
||||
return EditPlanResponse(
|
||||
id=plan.id,
|
||||
workspace_id=plan.workspace_id,
|
||||
project_id=plan.project_id,
|
||||
template_id=plan.template_id,
|
||||
asset_library_id=plan.asset_library_id,
|
||||
title_id=plan.title_id,
|
||||
status=plan.status,
|
||||
summary=plan.summary,
|
||||
clips=[
|
||||
EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
asset_id=clip.asset_id,
|
||||
asset_name=asset_names.get(clip.asset_id, clip.asset_id),
|
||||
sequence=clip.sequence,
|
||||
start_time=float(clip.start_time or 0),
|
||||
duration=float(clip.duration or 0),
|
||||
reason=clip.reason,
|
||||
)
|
||||
for clip in clips
|
||||
],
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[EditTemplateResponse])
|
||||
def list_edit_templates(
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[EditTemplateResponse]:
|
||||
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
|
||||
templates = (
|
||||
session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True))
|
||||
.all()
|
||||
)
|
||||
return [_to_template_response(item) for item in templates or [template]]
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanResponse)
|
||||
def create_edit_plan(
|
||||
project_id: str,
|
||||
request: CreateEditPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
_ensure_project(
|
||||
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
|
||||
)
|
||||
template = (
|
||||
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
|
||||
if request.template_id
|
||||
else None
|
||||
)
|
||||
if template is None:
|
||||
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
|
||||
assets = [
|
||||
asset
|
||||
for asset in asset_repository.list_by_library(request.asset_library_id)
|
||||
if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/")
|
||||
]
|
||||
if not assets:
|
||||
raise HTTPException(status_code=422, detail="素材库暂无可用于剪辑计划的视频素材")
|
||||
selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[
|
||||
: max(1, int(template.clip_count or 3))
|
||||
]
|
||||
plan = EditPlanModel(
|
||||
id=uuid4().hex,
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=project_id,
|
||||
template_id=template.id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
title_id=request.title_id,
|
||||
status="draft",
|
||||
summary=f"按《{template.name}》自动选择 {len(selected)} 段素材,预计生成约 {int(template.target_duration or 30)} 秒成片。",
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(plan)
|
||||
clips: list[EditPlanClipModel] = []
|
||||
clip_duration = max(1, float(template.target_duration or 30) / len(selected))
|
||||
for index, asset in enumerate(selected, start=1):
|
||||
clip = EditPlanClipModel(
|
||||
id=uuid4().hex,
|
||||
edit_plan_id=plan.id,
|
||||
asset_id=asset.id,
|
||||
sequence=index,
|
||||
start_time=0,
|
||||
duration=min(float(asset.duration or clip_duration), clip_duration),
|
||||
reason="优先选择已就绪、质量分较高的视频素材。",
|
||||
)
|
||||
session.add(clip)
|
||||
clips.append(clip)
|
||||
session.commit()
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in selected})
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=EditPlanResponse)
|
||||
def get_edit_plan(
|
||||
project_id: str,
|
||||
plan_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> EditPlanResponse:
|
||||
plan = (
|
||||
session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first()
|
||||
)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Edit plan not found")
|
||||
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
|
||||
clips = (
|
||||
session.query(EditPlanClipModel)
|
||||
.filter(EditPlanClipModel.edit_plan_id == plan.id)
|
||||
.order_by(EditPlanClipModel.sequence.asc())
|
||||
.all()
|
||||
)
|
||||
assets = asset_repository.list_by_library(plan.asset_library_id)
|
||||
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in assets})
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Feature Flag 内部管理接口。
|
||||
|
||||
通过内部 API Key 鉴权,支持查看和修改 Feature Flag 配置。
|
||||
主要用于灰度发布期间的动态开关控制。
|
||||
|
||||
API:
|
||||
GET /api/v1/internal/feature-flags - 列出所有 flag
|
||||
GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
|
||||
PUT /api/v1/internal/feature-flags/{name} - 设置 flag 配置
|
||||
DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
|
||||
|
||||
鉴权:X-API-Key header,走内部 API Key 验证
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes.auth import _verify_internal_api_key
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
RedisFeatureFlagStore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||
|
||||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||
ALLOWED_FLAGS: set[str] = set()
|
||||
|
||||
|
||||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||
"""获取 Feature Flag 存储实例。"""
|
||||
return RedisFeatureFlagStore(redis_url=settings.REDIS_URL)
|
||||
|
||||
|
||||
class FeatureFlagUpdateRequest(BaseModel):
|
||||
"""Feature Flag 更新请求体。"""
|
||||
|
||||
enabled: bool = Field(..., description="是否启用")
|
||||
percentage: int = Field(0, ge=0, le=100, description="灰度百分比 (0-100)")
|
||||
whitelist: list[str] = Field(default_factory=list, description="白名单列表(如 user_id)")
|
||||
|
||||
|
||||
class FeatureFlagResponse(BaseModel):
|
||||
"""Feature Flag 响应。"""
|
||||
|
||||
name: str
|
||||
enabled: bool
|
||||
percentage: int
|
||||
whitelist: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: FeatureFlagConfig) -> "FeatureFlagResponse":
|
||||
return cls(
|
||||
name=config.name,
|
||||
enabled=config.enabled,
|
||||
percentage=config.percentage,
|
||||
whitelist=sorted(config.whitelist),
|
||||
)
|
||||
|
||||
|
||||
class FeatureFlagCheckResponse(BaseModel):
|
||||
"""Flag 激活检查响应。"""
|
||||
|
||||
name: str
|
||||
active: bool
|
||||
identifier: Optional[str] = None
|
||||
|
||||
|
||||
def _validate_flag_name(name: str) -> None:
|
||||
"""校验 flag 名称是否在允许列表中。"""
|
||||
if name not in ALLOWED_FLAGS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported flag: {name}. Allowed: {sorted(ALLOWED_FLAGS)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[FeatureFlagResponse])
|
||||
async def list_feature_flags(
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
) -> list[FeatureFlagResponse]:
|
||||
"""列出所有 Feature Flag。"""
|
||||
try:
|
||||
flags = store.list_all()
|
||||
# 同时返回预定义的 flag(即使未设置也显示默认值)
|
||||
result = []
|
||||
for name in sorted(ALLOWED_FLAGS):
|
||||
config = flags.get(name) or FeatureFlagConfig(name=name, enabled=False)
|
||||
result.append(FeatureFlagResponse.from_config(config))
|
||||
# 加上已存在但不在白名单中的 flag(只读展示)
|
||||
for name, config in flags.items():
|
||||
if name not in ALLOWED_FLAGS:
|
||||
result.append(FeatureFlagResponse.from_config(config))
|
||||
return sorted(result, key=lambda x: x.name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list feature flags: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||||
async def get_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
) -> FeatureFlagResponse:
|
||||
"""获取单个 Feature Flag 配置。"""
|
||||
try:
|
||||
config = store.get(name)
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to get feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||||
async def check_feature_flag(
|
||||
name: str,
|
||||
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
) -> FeatureFlagCheckResponse:
|
||||
"""检查某个标识符是否命中 Feature Flag。"""
|
||||
try:
|
||||
active = store.is_active(name, identifier=identifier)
|
||||
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to check feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||||
async def update_feature_flag(
|
||||
name: str,
|
||||
request: FeatureFlagUpdateRequest,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
) -> FeatureFlagResponse:
|
||||
"""更新 Feature Flag 配置。
|
||||
|
||||
只允许修改 ALLOWED_FLAGS 列表中的 flag。
|
||||
"""
|
||||
_validate_flag_name(name)
|
||||
|
||||
try:
|
||||
config = FeatureFlagConfig(
|
||||
name=name,
|
||||
enabled=request.enabled,
|
||||
percentage=request.percentage,
|
||||
whitelist=set(request.whitelist),
|
||||
)
|
||||
store.set(config)
|
||||
logger.info(
|
||||
"Feature flag updated: name=%s enabled=%s percentage=%d whitelist=%d",
|
||||
name,
|
||||
config.enabled,
|
||||
config.percentage,
|
||||
len(config.whitelist),
|
||||
)
|
||||
return FeatureFlagResponse.from_config(config)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to update feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
async def delete_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
):
|
||||
"""删除 Feature Flag。
|
||||
|
||||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||
"""
|
||||
_validate_flag_name(name)
|
||||
|
||||
try:
|
||||
deleted = store.delete(name)
|
||||
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
|
||||
@@ -0,0 +1,111 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoDownloadUrlResponse,
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
UpdateGeneratedVideoReviewRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
GetGeneratedVideoDownloadUrlUseCase,
|
||||
GetGeneratedVideoUseCase,
|
||||
ListGeneratedVideosUseCase,
|
||||
)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
status=item.status,
|
||||
review_status=item.review_status,
|
||||
generation_params=item.generation_params,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListGeneratedVideosResponse)
|
||||
def list_generated_videos(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListGeneratedVideosUseCase(generated_video_repository)
|
||||
items = use_case.execute(project_id)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
|
||||
def get_generated_video(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> GeneratedVideoResponse:
|
||||
use_case = GetGeneratedVideoUseCase(generated_video_repository)
|
||||
item = use_case.execute(video_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return _to_generated_video_response(item)
|
||||
|
||||
|
||||
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
|
||||
def update_generated_video_review_status(
|
||||
video_id: str,
|
||||
request: UpdateGeneratedVideoReviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> GeneratedVideoResponse:
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
|
||||
video.review_status = request.review_status
|
||||
updated = generated_video_repository.update(video)
|
||||
return _to_generated_video_response(updated)
|
||||
|
||||
|
||||
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
|
||||
def get_generated_video_download_url(
|
||||
video_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> GeneratedVideoDownloadUrlResponse:
|
||||
video = generated_video_repository.get(video_id)
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
|
||||
file_url = use_case.execute(video_id)
|
||||
if file_url is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
download_url = storage_service.get_download_url(file_url)
|
||||
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||
@@ -1,364 +0,0 @@
|
||||
"""封面生成路由 — Generation 模块.
|
||||
|
||||
端点:
|
||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
||||
|
||||
挂载路径: /api/v1/generation/generate-cover
|
||||
"""
|
||||
|
||||
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_generated_video_repository
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Generation"])
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GenerateCoverRequest(BaseModel):
|
||||
"""AI 封面生成请求体"""
|
||||
|
||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||
cover_type: str = Field(
|
||||
default="ai_frame",
|
||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||
)
|
||||
frame_time: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||
)
|
||||
cover_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
|
||||
)
|
||||
|
||||
|
||||
class GenerateCoverResponse(BaseModel):
|
||||
"""AI 封面生成响应体"""
|
||||
|
||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||
|
||||
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
||||
def generate_cover(
|
||||
body: GenerateCoverRequest,
|
||||
template_id: str = Query(..., description="模板 ID"),
|
||||
plan_id: str = Depends(get_draft_plan_id),
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> GenerateCoverResponse:
|
||||
"""AI 生成封面 — 从预览视频中抽帧.
|
||||
|
||||
流程(串行):
|
||||
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
||||
3. 帧图下载后上传到 OSS covers/ 路径
|
||||
"""
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
# ── upload 类型:直接保存前端上传的封面图片,不需要预览视频 ──────
|
||||
if body.cover_type == "upload":
|
||||
if not body.cover_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="cover_type=upload 时必须提供 cover_url",
|
||||
)
|
||||
cover_data = {
|
||||
"type": "upload",
|
||||
"image_url": body.cover_url,
|
||||
}
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
logger.info(
|
||||
"封面上传完成: plan_id=%s cover_url=%s by user=%s",
|
||||
plan_id,
|
||||
body.cover_url[:80] if body.cover_url else "",
|
||||
current_user.user.id,
|
||||
)
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
||||
# 第一步:从 plan.config 读取
|
||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
||||
|
||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
||||
if not rendered_storage_key:
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
logger.info(
|
||||
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
|
||||
)
|
||||
if generation_task_id:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(task.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(pt.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
rendered_storage_key[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
||||
if not rendered_storage_key:
|
||||
try:
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
if preview_tasks:
|
||||
completed_preview = preview_tasks[0]
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
videos = use_case.execute(completed_preview.id)
|
||||
if videos:
|
||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
||||
logger.info(
|
||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
completed_preview.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 仍然找不到才报 400
|
||||
if not rendered_storage_key:
|
||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="请先生成预览视频,再生成封面",
|
||||
)
|
||||
|
||||
# 回写到 plan.config
|
||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
||||
|
||||
# 使用裸 URL(rendered/* 已配置公开读)
|
||||
primary_video_url = None
|
||||
try:
|
||||
if rendered_storage_key.startswith("http"):
|
||||
primary_video_url = rendered_storage_key
|
||||
else:
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
storage_svc = get_shared_storage_service()
|
||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
||||
if primary_video_url:
|
||||
import re as _re
|
||||
|
||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
||||
logger.info(
|
||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
||||
plan_id,
|
||||
primary_video_url[:80] if primary_video_url else "",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取预览视频URL失败: {e}",
|
||||
) from e
|
||||
|
||||
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
|
||||
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
|
||||
if body.cover_type in ("ai_frame", "ai_regenerate"):
|
||||
cover_url_from_task = None
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
|
||||
# 步骤 A:通过 generation_task_id 直接查找
|
||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
||||
if generation_task_id:
|
||||
try:
|
||||
task = gen_task_repo.get(generation_task_id)
|
||||
if task and getattr(task, "cover_url", ""):
|
||||
cover_url_from_task = task.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
|
||||
plan_id,
|
||||
generation_task_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
|
||||
if not cover_url_from_task:
|
||||
try:
|
||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
||||
user_id=str(current_user.user.id),
|
||||
template_id=template_id,
|
||||
)
|
||||
for pt in preview_tasks:
|
||||
if getattr(pt, "cover_url", ""):
|
||||
cover_url_from_task = pt.cover_url
|
||||
logger.info(
|
||||
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
|
||||
plan_id,
|
||||
pt.id,
|
||||
cover_url_from_task[:80],
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
|
||||
plan_id,
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if cover_url_from_task:
|
||||
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
|
||||
cover_data = {
|
||||
"type": "ai_frame",
|
||||
"image_url": cover_url_from_task,
|
||||
"frame_time": 0.0,
|
||||
"confidence": 0.95,
|
||||
}
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
|
||||
logger.warning(
|
||||
"[封面生成] 统一管道未找到 cover_url: plan_id=%s",
|
||||
plan_id,
|
||||
)
|
||||
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
|
||||
)
|
||||
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
try:
|
||||
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
cover_type=body.cover_type,
|
||||
frame_time=body.frame_time,
|
||||
primary_video_url=primary_video_url,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
current_config = dict(plan.config) if plan.config else {}
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
|
||||
template_id,
|
||||
plan_id,
|
||||
body.cover_type,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
||||
@@ -1,408 +0,0 @@
|
||||
"""预览生成路由 — Phase 1:单版本预览接口(创建 + 查询)。
|
||||
|
||||
路径前缀:/api/v1/generation/preview(与 /generation/tasks 同体系)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
CreatePreviewGenerationTaskRequest,
|
||||
PreviewGenerationTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_template_repository import (
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# 模板 mode → 视频比例映射
|
||||
_TEMPLATE_MODE_TO_RATIO = {
|
||||
"pip": "9:16",
|
||||
"standard": "16:9",
|
||||
"square": "1:1",
|
||||
}
|
||||
|
||||
|
||||
def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||||
"""从模板 mode 推断视频比例,前端未传 video_ratio 时使用。
|
||||
|
||||
Returns:
|
||||
视频比例字符串(如 "9:16"),查询失败返回空字符串。
|
||||
"""
|
||||
if not template_id:
|
||||
return ""
|
||||
try:
|
||||
repo = SQLAlchemyTemplateRepository(db)
|
||||
template = repo.get(template_id, user_id)
|
||||
if template:
|
||||
mode = getattr(template, "mode", "") or ""
|
||||
ratio = _TEMPLATE_MODE_TO_RATIO.get(mode.strip(), "")
|
||||
if ratio:
|
||||
logger.info(
|
||||
"[预览生成] 从模板 mode=%s 推断 video_ratio=%s",
|
||||
mode,
|
||||
ratio,
|
||||
)
|
||||
return ratio
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 查询模板失败,跳过 video_ratio 推断: template_id=%s",
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
||||
"""从模板读取 editing_mode / mode 作为 strategy_id。
|
||||
|
||||
优先查新模板系统(EditTemplate.editing_mode),fallback 旧模板(Template.mode)。
|
||||
Worker 端使用 strategy_id 作为渲染 mode,为空则默认 one_take。
|
||||
"""
|
||||
if not template_id:
|
||||
return ""
|
||||
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
new_repo = SQLAlchemyEditTemplateRepository(db)
|
||||
new_template = new_repo.get(template_id)
|
||||
if new_template and getattr(new_template, "editing_mode", ""):
|
||||
mode = new_template.editing_mode.strip()
|
||||
if mode:
|
||||
logger.info(
|
||||
"[预览生成] 从新模板 editing_mode=%s (template_id=%s)",
|
||||
mode,
|
||||
template_id,
|
||||
)
|
||||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
||||
if mode in ("pip", "voice_pip"):
|
||||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
||||
mode = "one_take"
|
||||
return mode
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[预览生成] 新模板查询失败,尝试旧模板: template_id=%s",
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# fallback 旧模板系统
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id)
|
||||
if old_template:
|
||||
mode = getattr(old_template, "mode", "") or ""
|
||||
mode = mode.strip()
|
||||
if mode:
|
||||
logger.info(
|
||||
"[预览生成] 从旧模板 mode=%s (template_id=%s)",
|
||||
mode,
|
||||
template_id,
|
||||
)
|
||||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
||||
if mode in ("pip", "voice_pip"):
|
||||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
||||
mode = "one_take"
|
||||
return mode
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 旧模板查询也失败,strategy_id 留空: template_id=%s",
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
"""入队失败时将任务标记为 failed,避免产生僵尸 pending 数据。"""
|
||||
try:
|
||||
task.mark_failed(error_message=f"入队失败:{reason}")
|
||||
repo.update(task)
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
||||
|
||||
|
||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
||||
"""将领域任务对象转换为预览响应 DTO。
|
||||
|
||||
Args:
|
||||
task: GenerationTask 领域对象
|
||||
generated_videos: 生成的视频列表(可选),取第一个作为 video_url
|
||||
|
||||
Returns:
|
||||
PreviewGenerationTaskResponse
|
||||
"""
|
||||
video_url = ""
|
||||
duration = 0.0
|
||||
file_size = 0
|
||||
if generated_videos:
|
||||
first_video = generated_videos[0]
|
||||
raw_url = getattr(first_video, "file_url", "") or ""
|
||||
# rendered/* 已配置公开读,直接用裸 URL
|
||||
if raw_url.startswith("http"):
|
||||
video_url = raw_url
|
||||
else:
|
||||
storage = get_storage_service()
|
||||
video_url = storage.get_url(raw_url)
|
||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
||||
|
||||
# 从 extra_meta / metadata 中提取统计信息(如果有)
|
||||
extra_meta = getattr(task, "extra_meta", {}) or {}
|
||||
clip_count = int(extra_meta.get("clip_count", len(getattr(task, "asset_ids", [])) or 0))
|
||||
transition_count = int(extra_meta.get("transition_count", max(0, clip_count - 1)))
|
||||
material_usage = extra_meta.get("material_usage", {}) or {}
|
||||
|
||||
# 计算生成耗时
|
||||
generate_duration = 0.0
|
||||
started_at = getattr(task, "started_at", None)
|
||||
completed_at = getattr(task, "completed_at", None)
|
||||
if started_at and completed_at:
|
||||
generate_duration = (completed_at - started_at).total_seconds()
|
||||
|
||||
return PreviewGenerationTaskResponse(
|
||||
task_id=task.id,
|
||||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
||||
progress=float(task.progress or 0.0),
|
||||
is_preview=bool(getattr(task, "is_preview", True)),
|
||||
resolution=getattr(task, "resolution", "") or "",
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
file_size=file_size,
|
||||
clip_count=clip_count,
|
||||
transition_count=transition_count,
|
||||
material_usage=material_usage,
|
||||
error_message=task.error_message or "",
|
||||
created_at=task.created_at,
|
||||
started_at=started_at,
|
||||
finished_at=completed_at,
|
||||
generate_duration=generate_duration,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
|
||||
def create_preview_generation_task(
|
||||
request: CreatePreviewGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
asset_repo=Depends(get_asset_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务。
|
||||
|
||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
|
||||
|
||||
Args:
|
||||
request: 预览任务创建请求(template_id + asset_ids 等)
|
||||
|
||||
Returns:
|
||||
201 + 预览任务详情
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.preview_count,
|
||||
)
|
||||
|
||||
# 预检查队列限流
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + 1 > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
|
||||
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
# 确定视频比例:优先前端传入,否则从模板 mode 推断
|
||||
video_ratio = request.video_ratio or ""
|
||||
if not video_ratio and request.template_id:
|
||||
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
|
||||
|
||||
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
|
||||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
||||
|
||||
# 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker
|
||||
title_config = request.title_config or {}
|
||||
title_text = (title_config.get("text") or "").strip()
|
||||
custom_title_value = ""
|
||||
if title_text:
|
||||
# 将标题文本和样式配置序列化为 JSON 存入 custom_title
|
||||
# Worker 端会解析 JSON 获取完整标题配置
|
||||
custom_title_value = json.dumps(title_config, ensure_ascii=False)
|
||||
logger.info(
|
||||
"[预览生成] 标题配置: text=%s, config_keys=%s",
|
||||
title_text[:30],
|
||||
list(title_config.keys()),
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
voice_ids=list(request.voice_ids),
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode="",
|
||||
batch_id="",
|
||||
video_title=request.video_title,
|
||||
resolution="",
|
||||
bgm_config=request.bgm_config or {},
|
||||
auto_retry_enabled=False,
|
||||
auto_retry_max=0,
|
||||
is_preview=True,
|
||||
custom_title=custom_title_value,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("[预览生成] 创建失败: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
|
||||
|
||||
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
|
||||
if not task.source_edit_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
|
||||
_plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
|
||||
for _p in _plans:
|
||||
if (_p.created_by_user_id or "") == user_id:
|
||||
task.source_edit_plan_id = _p.id
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
|
||||
task.id,
|
||||
_p.id,
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[预览生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
|
||||
except UserPendingLimitExceeded as e:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return _to_preview_response(task)
|
||||
|
||||
|
||||
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
|
||||
def get_preview_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository=Depends(get_generation_task_repository),
|
||||
generated_video_repository=Depends(get_generated_video_repository),
|
||||
) -> PreviewGenerationTaskResponse:
|
||||
"""查询预览生成任务状态。
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
|
||||
Returns:
|
||||
预览任务详情(含状态、进度、结果 URL 等)
|
||||
"""
|
||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 权限校验:任务必须属于当前用户(统一转 str 比较,避免 UUID/str 类型差异)
|
||||
task_user_id = str(getattr(task, "created_by_user_id", "") or "")
|
||||
if not task_user_id or task_user_id != str(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该任务")
|
||||
|
||||
# 校验是否为预览任务
|
||||
if not getattr(task, "is_preview", False):
|
||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
||||
|
||||
# 查询生成的视频(取第一个)
|
||||
generated_videos = []
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if status_val == "completed":
|
||||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
generated_videos = list_use_case.execute(task_id)
|
||||
|
||||
return _to_preview_response(task, generated_videos=generated_videos)
|
||||
Executable → Regular
+50
-524
@@ -1,35 +1,24 @@
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.api.routes.permissions import require_workspace_member
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
GLOBAL_PENDING_LIMIT,
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
get_project_title_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
@@ -39,9 +28,7 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -49,37 +36,23 @@ router = APIRouter()
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
template_id=task.template_id,
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
edit_plan_id=task.edit_plan_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
|
||||
def _to_generated_video_response(item) -> GeneratedVideoResponse:
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
@@ -90,7 +63,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,81 +77,22 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
"""
|
||||
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
|
||||
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
if mode == "random":
|
||||
selected = (
|
||||
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
|
||||
)
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
limit = count if count > 0 else None
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
|
||||
return [r.asset.id for r in results]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
def _select_title_id(project_title_repository: Any, project_id: str) -> str:
|
||||
active_titles = project_title_repository.list_by_project(project_id, active_only=True)
|
||||
if not active_titles:
|
||||
return ""
|
||||
selected = sorted(
|
||||
active_titles,
|
||||
key=lambda title: (
|
||||
0 if getattr(title, "favorite", False) else 1,
|
||||
int(title.usage_count or 0),
|
||||
title.created_at,
|
||||
),
|
||||
)[0]
|
||||
return selected.id
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
asset_library_repository: Any,
|
||||
asset_repository: Any,
|
||||
authenticated_user: AuthenticatedUser,
|
||||
) -> tuple[str, str]:
|
||||
"""解析 project_id 和 asset_library_id。
|
||||
|
||||
支持两种模式:
|
||||
- 显式传入(向后兼容)
|
||||
- 从 asset_ids 反查 asset_library(模板模式)
|
||||
返回 (project_id, asset_library_id)。
|
||||
"""
|
||||
project_id = request.project_id.strip()
|
||||
asset_library_id = request.asset_library_id.strip()
|
||||
|
||||
# 模板模式:project_id 未提供时,从 asset_ids 反查所属 project
|
||||
if not project_id and request.asset_ids:
|
||||
first_asset_id = request.asset_ids[0]
|
||||
asset = asset_repository.find_by_id(first_asset_id)
|
||||
if asset is not None:
|
||||
project_id = asset.project_id
|
||||
if not asset_library_id:
|
||||
asset_library_id = asset.library_id
|
||||
|
||||
# 向后兼容校验:project_id 已提供时验证权限
|
||||
if project_id:
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
return project_id, asset_library_id
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -187,286 +100,33 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
authenticated_user.user.id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.asset_select_mode,
|
||||
request.count,
|
||||
)
|
||||
|
||||
try:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
)
|
||||
except HTTPException as e:
|
||||
logger.warning("[生成任务] 校验失败: %s", e.detail)
|
||||
raise
|
||||
|
||||
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
|
||||
resolved_asset_ids: list[str] = list(request.asset_ids)
|
||||
if asset_library_id:
|
||||
library = asset_library_repository.get(asset_library_id)
|
||||
if library is None or (project_id and library.project_id != project_id):
|
||||
logger.warning("[生成任务] 素材库不存在: library_id=%s", asset_library_id)
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
|
||||
|
||||
assets = asset_repository.find_by_library(asset_library_id)
|
||||
try:
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
except HTTPException as e:
|
||||
logger.warning("[生成任务] 素材校验失败: %s", e.detail)
|
||||
raise
|
||||
|
||||
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
|
||||
if not resolved_asset_ids:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
|
||||
assets = asset_repository.find_by_project(project_id)
|
||||
if assets:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
if not resolved_asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
|
||||
)
|
||||
project_title_repository: Any = Depends(get_project_title_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
if project is None or project.workspace_id != request.workspace_id:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
library = asset_library_repository.get(request.asset_library_id)
|
||||
if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found")
|
||||
assets = asset_repository.list_by_library(request.asset_library_id)
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
failed_tasks = []
|
||||
user_id = authenticated_user.user.id
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
# 预检查:批量提交前先看会不会超限,避免建一半才拒
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + count > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(
|
||||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||
)
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
|
||||
effective_strategy_id = request.strategy_id
|
||||
if effective_strategy_id in ("pip", "voice_pip"):
|
||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
||||
effective_strategy_id = "one_take"
|
||||
|
||||
try:
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=effective_strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
resolution=request.resolution,
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
is_preview=request.is_preview,
|
||||
source_task_id=request.source_task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[生成任务]",
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
else:
|
||||
failed_tasks.append(task)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from _e
|
||||
break
|
||||
except GlobalQueueFull as _e:
|
||||
failed_tasks.append(task)
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from _e
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
||||
def confirm_generation(
|
||||
task_id: str,
|
||||
request: ConfirmGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
|
||||
|
||||
预览已使用 1080p / CRF 23 / medium 渲染,品质与正式生成一致。
|
||||
确认时直接将预览任务标记为正式产出,无需重新渲染,实现秒出。
|
||||
仅当预览任务未完成时,才创建新的正式任务走渲染流程。
|
||||
"""
|
||||
# 1. 查找源预览任务
|
||||
source_task = generation_task_repository.get(task_id)
|
||||
if source_task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
|
||||
|
||||
# 2. 权限检查
|
||||
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
|
||||
if source_task.is_completed and getattr(source_task, "is_preview", False):
|
||||
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
|
||||
req_w = request.output_width or 0
|
||||
req_h = request.output_height or 0
|
||||
src_w = getattr(source_task, "output_width", 0) or 0
|
||||
src_h = getattr(source_task, "output_height", 0) or 0
|
||||
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
|
||||
|
||||
if resolution_match:
|
||||
source_task.mark_confirmed(
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
)
|
||||
generation_task_repository.update(source_task)
|
||||
logger.info(
|
||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(source_task)],
|
||||
total=1,
|
||||
)
|
||||
# 分辨率不一致,跳过复用,走新建任务流程
|
||||
logger.info(
|
||||
"[确认生成] 分辨率不一致,跳过复用: task_id=%s, src=%sx%s, req=%sx%s",
|
||||
task_id,
|
||||
src_w,
|
||||
src_h,
|
||||
req_w,
|
||||
req_h,
|
||||
)
|
||||
|
||||
# 4. 预览任务未完成,创建新的正式任务走渲染流程
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
new_task = use_case.execute(
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=source_task.project_id,
|
||||
asset_library_id=source_task.asset_library_id,
|
||||
strategy_id=source_task.strategy_id,
|
||||
voice_library_id=source_task.voice_library_id,
|
||||
template_id=source_task.template_id,
|
||||
asset_ids=source_task.asset_ids,
|
||||
title_ids=source_task.title_ids,
|
||||
voice_ids=source_task.voice_ids,
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id),
|
||||
voice_library_id=request.voice_library_id,
|
||||
edit_plan_id=request.edit_plan_id,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
||||
asset_select_mode=source_task.asset_select_mode,
|
||||
video_title=getattr(source_task, "video_title", ""),
|
||||
resolution=getattr(source_task, "resolution", ""),
|
||||
is_preview=False,
|
||||
source_task_id=task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 调度 worker
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
new_task,
|
||||
generation_task_repository,
|
||||
user_id=authenticated_user.user.id,
|
||||
log_prefix="[确认生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(new_task)],
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> ListGenerationTasksResponse:
|
||||
"""用户级生成任务列表(跨 project)。"""
|
||||
tasks = generation_task_repository.list_by_user(authenticated_user.user.id)
|
||||
items = [_to_generation_task_response(task) for task in tasks]
|
||||
return ListGenerationTasksResponse(items=items)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
|
||||
@@ -474,14 +134,13 @@ def get_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
if task.project_id:
|
||||
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
|
||||
@@ -491,145 +150,12 @@ def list_generation_results(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
generated_video_repository: Any = Depends(get_generated_video_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
if task.project_id:
|
||||
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
items = use_case.execute(task_id)
|
||||
responses = []
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
|
||||
def retry_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
"""简化重试:通过 task_id 直接重试失败任务。"""
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if status_val != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
# 预检查:创建前判断,>= 上限就拒绝
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.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="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
template_id=task.template_id,
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix="[生成任务]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
|
||||
def cancel_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
"""取消生成任务。
|
||||
|
||||
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
|
||||
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
|
||||
"""
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
|
||||
# 权限校验
|
||||
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
|
||||
# 终态不可取消
|
||||
if status_val in ("completed", "failed", "cancelled"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot cancel task in {status_val} status",
|
||||
)
|
||||
|
||||
# 执行取消
|
||||
try:
|
||||
task.mark_cancelled()
|
||||
task.append_log(
|
||||
stage="cancelled",
|
||||
message="用户主动取消任务",
|
||||
level="INFO",
|
||||
cancelled_by=authenticated_user.user.id,
|
||||
)
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
status_val,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
return _to_generation_task_response(task)
|
||||
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user