Compare commits

...

22 Commits

Author SHA1 Message Date
xiaoxia da792e3909 feat(ci): 新增auto-approve workflow,CI全绿自动审批
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 22s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 57s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m56s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m35s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m51s
2026-07-17 09:41:11 +08:00
xiaoxia 47e7018f2a feat: BGM背景音乐管理 - 配置API + 预设库API (#395)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 41s
CI/CD Pipeline / Unit Tests (push) Successful in 2m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m56s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 28s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m50s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 1m3s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m9s
feat: BGM背景音乐管理 - 配置API + 预设库API (#395)
2026-07-17 09:35:21 +08:00
xiaoxia ad089ebc12 fix(ci): 修复matrix并发构建时buildx builder命名冲突
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Unit Tests (push) Successful in 2m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m57s
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m56s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 28s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m50s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Waiting to run
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Waiting to run
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
三个matrix job(API/Web/Worker)共用GITHUB_JOB名称,并发运行时争抢同一个buildx builder实例导致失败。在builder名称中加入matrix.cache_name区分不同服务。
2026-07-17 09:29:01 +08:00
xiaoxia ee8c6c1a3b pr_title
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Unit Tests (push) Successful in 3m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 5m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m7s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 4m7s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m17s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 12m57s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
pr_body
2026-07-17 09:18:34 +08:00
xiaoxia 4159e95f10 pr_title
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
pr_body
2026-07-17 09:18:30 +08:00
xiaoxia e8312482d5 pr_title
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 2s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m23s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 2s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
pr_body
2026-07-17 09:18:23 +08:00
xiaoxia 00b6488b1b fix(ci): 修复YAML折叠导致两行docker login被合并为一行
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Unit Tests (push) Successful in 2m22s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m5s
CI/CD Pipeline / Integration Tests (push) Successful in 1m21s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 14s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 52s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 6m3s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
修复YAML单引号多行字符串折叠导致两行docker login被合并为一行的问题,在Gitea Registry登录行前后补空行以保留换行符。
2026-07-17 08:41:27 +08:00
xiaoxia 42bf441fa6 fix(ci): 修复Gitea Registry登录命令写法(改用-p参数) (#432)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 26s
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 34s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 51s
CI/CD Pipeline / Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m39s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
fix(ci): 修复Gitea Registry登录命令写法(改用-p参数)
2026-07-17 08:04:12 +08:00
xiaoxia 678f7f90fd fix(ci): 添加Gitea Container Registry登录,修复base镜像拉取401 (#431)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 27s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 30s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 36s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 43s
CI/CD Pipeline / Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m56s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
fix(ci): 添加Gitea Container Registry登录,修复base镜像拉取401
2026-07-17 07:54:25 +08:00
xiaoxia 92855189ad pr_title
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 25s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 29s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 49s
CI/CD Pipeline / Unit Tests (push) Successful in 2m22s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m8s
CI/CD Pipeline / Integration Tests (push) Successful in 1m17s
pr_body
2026-07-17 07:17:26 +08:00
xiaoxia 50adbed970 pr_title
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 23s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 28s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 23s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
pr_body
2026-07-17 07:16:59 +08:00
auto-approve-bot b891eced23 feat(ci): AI代码审查Bot - 修复3个bug
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 14m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14m39s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 14m41s
CI/CD Pipeline / Frontend Lint (push) Failing after 14m41s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 14m41s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 14m41s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m18s
feat(ci): AI代码审查Bot - 修复3个bug(过滤生效/评论去重/异常退出码)

Co-authored-by: auto-approve-bot <auto-approve-bot@xiaoxiajianji.com>
Co-committed-by: auto-approve-bot <auto-approve-bot@xiaoxiajianji.com>
2026-07-17 01:24:59 +08:00
xiaoxia 84d1673d31 feat(ci): 前端构建缓存优化 - 去重+BuildKit cache mount
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m21s
CI/CD Pipeline / Integration Tests (push) Successful in 1m25s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
PR #427: 删除host上重复的前端构建步骤,web.Dockerfile加BuildKit cache mount(npm下载+node_modules+TS增量编译三级缓存),预计Web构建降40%-60%。
2026-07-17 00:59:54 +08:00
xiaoxia 81f9a47210 pr_title
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 37s
CI/CD Pipeline / Unit Tests (push) Successful in 2m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
pr_body
2026-07-17 00:56:52 +08:00
xiaoxia 68d15d8731 feat(ci): 强制源码安装black+isort,根治格式化行为不一致
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m23s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
PR #404: black和isort改为pip源码安装(--no-binary),确保所有环境格式化行为一致,避免预编译版本差异导致的black格式化结果不一致问题。
2026-07-16 22:19:16 +08:00
xiaoxia ac2662b693 fix(ci): push触发只保留main/develop,根治PR双触发重复run
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #425: 删除push.branches中的feature/feat/bugfix/fix等11个通配符分支,只保留main和develop。PR分支CI完全靠pull_request触发,将每个PR的run数量从4个降到2个。
2026-07-16 22:18:48 +08:00
xiaoxia f89ce9f3e2 feat(ci): 纯前端PR自动跳过后端检查,节省CI时间 (#424)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Unit Tests (push) Successful in 3m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m36s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
Merge PR #424
2026-07-16 21:59:56 +08:00
xiaoxia 4c662cca32 feat(ci): staging部署脚本增加本地自动回滚能力
CI/CD Pipeline / Frontend Lint (push) Successful in 39s
CI/CD Pipeline / Unit Tests (push) Successful in 2m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m46s
CI/CD Pipeline / Integration Tests (push) Successful in 4m18s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
PR #385: Staging本地自动回滚

- 部署前自动备份当前版本容器
- 健康检查失败自动回滚到上一版本
- 无需人工介入,提升部署安全性
2026-07-16 21:26:44 +08:00
xiaoxia 28e2bba5c3 feat(ci): Runner分层调度 - L1/L2/L3按任务类型路由
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #401: Runner分层调度

- L1层(轻量): Validate/Frontend Lint,快速反馈
- L2层(标准): Unit Tests/Integration Tests
- L3层(重): Build/Deploy/E2E,独占资源
- 按任务类型路由到对应标签的runner池
2026-07-16 21:26:26 +08:00
xiaoxia c19e0d9387 feat(ci): staging部署脚本增加Registry登录和拉镜像重试机制
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #390: Registry登录与拉镜像重试

- staging部署前自动登录本地Registry
- docker pull增加重试机制,应对网络抖动
- 提升部署稳定性
2026-07-16 21:26:11 +08:00
xiaoxia d9000fee54 feat(ci): deploy job增加独立concurrency,部署排队不打断
CI/CD Pipeline / Frontend Lint (push) Successful in 59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 7m40s
CI/CD Pipeline / Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
PR #423: deploy job独立concurrency

- deploy-staging和deploy-production各加独立concurrency group
- cancel-in-progress: false,部署排队不打断
- 全局CI构建仍保持打断模式高效迭代
2026-07-16 21:04:06 +08:00
xiaoxia ffc9706a85 fix(ci): 修复concurrency group变量名,根治run堆积
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
PR #422: 修复concurrency group变量名

- 顶层concurrency group从github.workflow改为硬编码ci-cd前缀
- 使用gitea.event_name和gitea.ref确保跨workflow一致性
- 新push自动取消旧run,根治run堆积问题
2026-07-16 21:03:21 +08:00
27 changed files with 4128 additions and 126 deletions
+152
View File
@@ -0,0 +1,152 @@
name: Auto Approve CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review]
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-l1
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
steps:
- name: Check if frontend-only change
id: frontend-only
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -eu
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端CI检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "🔧 包含后端/公共变更,检查全部CI"
fi
- name: Wait for CI and auto approve
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
SKIP_BACKEND: ${{ steps.frontend-only.outputs.skip_backend }}
run: |
set -eu
# 定义需要检查的status context
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 获取当前commit的所有status
STATUSES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/statuses?per_page=100")
for ctx in "${CONTEXTS[@]}"; do
STATE=$(echo "$STATUSES" | python3 -c "
import sys, json
statuses = json.load(sys.stdin)
target = '$ctx'
# 找最新的对应context
latest = None
for s in statuses:
if s.get('context') == target:
latest = s
break # API返回的是倒序,第一个就是最新的
print(latest.get('state', 'pending') if latest else 'pending')
")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 先检查是否已有审批,避免重复审批
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "
import sys, json
reviews = json.load(sys.stdin)
for r in reviews:
if r.get('state') == 'APPROVED' and r.get('user',{}).get('login') == 'xiaoxia':
print('yes')
break
else:
print('no')
")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 提交审批
HTTP_CODE=$(curl -s -o /tmp/approve_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVE", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
echo "审批API HTTP状态: $HTTP_CODE"
cat /tmp/approve_resp.json 2>/dev/null || true
echo
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
echo "✅ 自动审批成功"
exit 0
else
echo "❌ 自动审批失败"
exit 1
fi
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0 # 正常退出,不算workflow失败
fi
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0 # 超时也正常退出,不产生告警
+27 -28
View File
@@ -4,17 +4,6 @@ on:
branches:
- main
- develop
- feature/**
- feat/**
- bugfix/**
- fix/**
- hotfix/**
- release/**
- refactor/**
- perf/**
- docs/**
- chore/**
- ci/**
tags:
- v*
pull_request:
@@ -35,7 +24,7 @@ concurrency:
jobs:
build-staging:
name: Build Staging ${{ matrix.service_display }} Image
runs-on: host
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
strategy:
@@ -120,9 +109,13 @@ jobs:
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: 'set -eu
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}"
echo "Docker login successful"
@@ -130,13 +123,9 @@ jobs:
- name: Setup cache strategy
shell: sh
run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n"
- name: Build frontend assets (npm build)
if: matrix.service == 'web'
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete: $(ls apps/web/dist/ | head -5)\"\n"
- name: Setup buildx builder (docker-container driver)
shell: sh
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
- name: Build and push ${{ matrix.service_display }} image (buildx cache)
shell: sh
run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nEXTRA_BUILD_ARGS=\"APP_VERSION=\\\"${GITHUB_SHA}\\\"\"\nif [ \"${{ matrix.service }}\" = \"web\" ]; then\n EXTRA_BUILD_ARGS=\"$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf\"\nfi\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" $EXTRA_BUILD_ARGS\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\""
@@ -157,8 +146,11 @@ jobs:
'
deploy-staging:
name: Deploy Staging (Watchtower auto-deploy)
runs-on: saas
runs-on: runtime-builder
timeout-minutes: 15
concurrency:
group: deploy-staging-${{ gitea.ref }}
cancel-in-progress: false
needs:
- build-staging
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
@@ -232,9 +224,13 @@ jobs:
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: 'set -eu
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}"
echo "Docker login successful"
@@ -308,7 +304,7 @@ jobs:
'
staging-e2e:
name: Staging E2E Tests
runs-on: saas
runs-on: runtime-builder
timeout-minutes: 15
if: github.ref_name == 'develop' || github.ref_name == 'main'
needs: deploy-staging
@@ -388,7 +384,7 @@ jobs:
'
staging-api-tests:
name: Staging API Integration Tests
runs-on: saas
runs-on: runtime-builder
timeout-minutes: 10
if: github.ref_name == 'develop' || github.ref_name == 'main'
needs: deploy-staging
@@ -468,7 +464,7 @@ jobs:
'
build-production:
name: Build Production ${{ matrix.service_display }} Image
runs-on: host
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
if: startsWith(github.ref, 'refs/tags/v')
@@ -554,20 +550,20 @@ jobs:
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: 'set -eu
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}"
echo "Docker login successful"
'
- name: Build frontend assets (npm build)
if: matrix.service == 'web'
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete\"\n"
- name: Setup buildx builder (docker-container driver)
shell: sh
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
- name: Build and push ${{ matrix.service_display }} image (buildx cache)
shell: sh
run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" APP_VERSION=\"${GITHUB_SHA}\"\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\""
@@ -592,8 +588,11 @@ jobs:
'
deploy-production:
name: Deploy Production
runs-on: saas
runs-on: runtime-builder
timeout-minutes: 30
concurrency:
group: deploy-production-${{ gitea.ref }}
cancel-in-progress: false
if: startsWith(github.ref, 'refs/tags/v')
needs:
- build-production
@@ -735,7 +734,7 @@ jobs:
'
production-e2e:
name: Production Browser E2E
runs-on: saas
runs-on: runtime-builder
timeout-minutes: 15
if: startsWith(github.ref, 'refs/tags/v')
needs: deploy-production
+51 -18
View File
@@ -4,17 +4,6 @@ on:
branches:
- main
- develop
- feature/**
- feat/**
- bugfix/**
- fix/**
- hotfix/**
- release/**
- refactor/**
- perf/**
- docs/**
- chore/**
- ci/**
tags:
- v*
pull_request:
@@ -30,12 +19,48 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
check-frontend-only:
name: Check if frontend-only change
runs-on: ci-l1
if: github.event_name == 'pull_request'
outputs:
skip_backend: ${{ steps.check.outputs.skip_backend }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Check changed files
id: check
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "🔧 包含后端/公共变更,运行完整CI"
fi
validate:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Validate Code Quality And Tests
runs-on: host
runs-on: ci-l1
timeout-minutes: 10
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
@@ -77,6 +102,10 @@ jobs:
python3 -m pip install -q -r requirements-dev.txt
# Force source install of black/isort to ensure consistent formatting
# across compiled/source installations on different machines
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
python3 -m black --version
python3 -m isort --version-number
@@ -190,8 +219,10 @@ jobs:
'
unit-tests:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
name: Unit Tests
runs-on: host
runs-on: ci-l2
timeout-minutes: 8
env:
USE_IN_MEMORY_DB: 'true'
@@ -262,10 +293,12 @@ jobs:
'
integration-tests:
name: Integration Tests
runs-on: host
runs-on: ci-l2
timeout-minutes: 30
if: always()
needs: validate
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
needs:
- check-frontend-only
- validate
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: 'false'
@@ -379,7 +412,7 @@ jobs:
'
frontend-lint:
name: Frontend Lint
runs-on: host
runs-on: ci-l1
timeout-minutes: 10
steps:
- name: Checkout code
+53
View File
@@ -0,0 +1,53 @@
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: ubuntu-latest
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install dependencies
run: |
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 }}
# 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
# 审查脚本异常不影响 CI 通过
continue-on-error: true
+185 -1
View File
@@ -67,7 +67,9 @@ class EditPlanUpdateRequest(BaseModel):
class CopyPlanRequest(BaseModel):
"""复制剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」")
name: Optional[str] = Field(
default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」"
)
project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目")
@@ -258,6 +260,16 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
)
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配
from .edit_plans_export import router as export_router
from .edit_plans_filter import router as filter_router
router.include_router(export_router)
router.include_router(filter_router)
# ── CRUD Routes ───────────────────────────────────────────────────────────────
@@ -506,6 +518,178 @@ def copy_plan(
current_user.user.id,
)
return _to_response(new_plan)
# ── BGM 背景音乐 ───────────────────────────────────────────────────────────
class BGMConfigUpdateRequest(BaseModel):
"""更新BGM配置请求体"""
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
@router.get(
"/{plan_id}/bgm",
response_model=dict[str, Any],
summary="获取剪辑计划的 BGM 配置",
)
def get_plan_bgm(
plan_id: str,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""获取指定剪辑计划的 BGM 配置。"""
service = EditPlanService(db)
plan = service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
config = plan.config or {}
bgm_config = config.get("bgm", {})
return {
"plan_id": plan.id,
"bgm": bgm_config,
}
@router.put(
"/{plan_id}/bgm",
response_model=dict[str, Any],
summary="更新剪辑计划的 BGM 配置",
)
def update_plan_bgm(
plan_id: str,
body: BGMConfigUpdateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repo=Depends(get_project_repository),
) -> dict[str, Any]:
"""更新剪辑计划的 BGM 配置。
支持部分更新,只传需要修改的字段即可。
启用 BGM 后需要指定来源(asset_id / preset_id / audio_url 三选一)。
"""
service = EditPlanService(db)
plan = service.get_plan(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(project_repo, current_user, plan.project_id)
# 读取当前 BGM 配置,合并更新
config = dict(plan.config) if plan.config else {}
current_bgm = dict(config.get("bgm", {}))
update_data = body.model_dump(exclude_none=True)
current_bgm.update(update_data)
# 校验:启用 BGM 时至少有一个有效来源
if current_bgm.get("enabled"):
has_source = any(current_bgm.get(key) for key in ("asset_id", "preset_id", "audio_url") if current_bgm.get(key))
if not has_source:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url",
)
# 保存到 plan.config.bgm
config["bgm"] = current_bgm
updated_plan = service.update_plan_config(plan_id, config)
logger.info(
"更新BGM配置: plan_id=%s enabled=%s by user=%s",
plan_id,
current_bgm.get("enabled", False),
current_user.user.id,
)
return {
"plan_id": updated_plan.id,
"bgm": current_bgm,
}
# ── BGM 预设库 ────────────────────────────────────────────────────────────────
@router.get(
"/bgm/presets",
response_model=dict[str, Any],
summary="获取预设 BGM 列表",
)
def list_bgm_presets(
style: Optional[str] = Query(default=None, description="按风格筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
skip: int = Query(default=0, ge=0, description="分页偏移"),
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
) -> dict[str, Any]:
"""获取预设 BGM 列表,支持按风格筛选和关键词搜索。
风格可选: upbeat(轻快)、relax(治愈)、tech(科技)、commerce(电商)、
emotional(情感)、cinematic(电影)
"""
from packages.domain.preset_bgm import (
BGM_STYLES,
PRESET_BGM_LIBRARY,
list_preset_bgm_by_style,
search_preset_bgm,
)
bgm_list = PRESET_BGM_LIBRARY
if keyword:
bgm_list = search_preset_bgm(keyword)
elif style:
bgm_list = list_preset_bgm_by_style(style)
total = len(bgm_list)
paged = bgm_list[skip : skip + limit]
return {
"total": total,
"skip": skip,
"limit": limit,
"styles": BGM_STYLES,
"items": [
{
"id": bgm.id,
"name": bgm.name,
"style": bgm.style,
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
"duration": bgm.duration,
"artist": bgm.artist,
"description": bgm.description,
"tags": bgm.tags,
"audio_url": bgm.audio_url,
}
for bgm in paged
],
}
# ── 保存为模板 ────────────────────────────────────────────────────────────────
+137
View File
@@ -276,3 +276,140 @@ def delete_clip(
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return None
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
class SplitClipRequest(BaseModel):
"""分割片段请求体"""
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
class MergeClipsRequest(BaseModel):
"""合并片段请求体"""
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
@router.post(
"/{clip_id}/split",
response_model=dict[str, Any],
summary="分割片段",
status_code=status.HTTP_200_OK,
)
def split_clip(
plan_id: str,
clip_id: str,
body: SplitClipRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将一个片段从指定时间点分割为两个片段。
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {clip_id}",
)
try:
result = svc.split_clip(clip_id, body.split_time)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
left = result["left_clip"]
right = result["right_clip"]
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return {
"left_clip": {
"id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
}
@router.post(
"/merge",
response_model=dict[str, Any],
summary="合并多个连续片段",
status_code=status.HTTP_200_OK,
)
def merge_clips(
plan_id: str,
body: MergeClipsRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
db: Session = Depends(get_db_session),
project_repository: Any = Depends(get_project_repository),
) -> dict[str, Any]:
"""将多个连续的同类型片段合并为一个片段。
合并要求:
- 至少 2 个片段
- 属于同一剪辑计划
- order 连续
- 类型相同
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 校验所有片段都属于该 plan
for cid in body.clip_ids:
clip = svc.get_clip(cid)
if clip is None or clip.plan_id != plan_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"片段不存在: {cid}",
)
try:
merged = svc.merge_clips(body.clip_ids)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
logger.info(
"合并片段: plan_id=%s clip_count=%d by user=%s",
plan_id,
len(body.clip_ids),
current_user.user.id,
)
return {
"id": merged.id,
"plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
}
+274
View File
@@ -0,0 +1,274 @@
"""导出设置 API.
- GET /{plan_id}/export 获取导出配置
- PUT /{plan_id}/export 更新导出配置
- GET /export-presets 导出预设列表
"""
from __future__ import annotations
import logging
import re
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, validator
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── 导出预设 ──────────────────────────────────────────────────────────────────
EXPORT_PRESETS = [
{
"id": "export_1080p_30",
"name": "1080P 高清",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 8000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "balanced",
"description": "竖屏高清,适合短视频平台",
"size_hint": "约 10MB/分钟",
},
{
"id": "export_1080p_60",
"name": "1080P 高帧率",
"resolution": "1080x1920",
"fps": 60,
"video_bitrate": 12000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "high",
"description": "60帧高帧率,流畅运动画面",
"size_hint": "约 18MB/分钟",
},
{
"id": "export_720p_30",
"name": "720P 流畅",
"resolution": "720x1280",
"fps": 30,
"video_bitrate": 4000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "fast",
"description": "快速导出,文件较小",
"size_hint": "约 5MB/分钟",
},
{
"id": "export_4k_30",
"name": "4K 超清",
"resolution": "2160x3840",
"fps": 30,
"video_bitrate": 20000,
"audio_bitrate": 192,
"format": "mp4",
"quality_preset": "best",
"description": "4K超清画质,专业品质",
"size_hint": "约 30MB/分钟",
},
{
"id": "export_1080p_30_mov",
"name": "1080P ProRes",
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 15000,
"audio_bitrate": 256,
"format": "mov",
"quality_preset": "high",
"description": "MOV格式,适合后期剪辑",
"size_hint": "约 25MB/分钟",
},
]
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
VALID_FORMATS = {"mp4", "mov"}
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
# ── Schemas ──────────────────────────────────────────────────────────────────
class ExportConfigResponse(BaseModel):
"""导出配置响应"""
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
watermark_enabled: bool
watermark_text: str
class ExportUpdateRequest(BaseModel):
"""更新导出配置请求"""
resolution: Optional[str] = None
fps: Optional[int] = Field(default=None, ge=15, le=60)
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
format: Optional[str] = None
quality_preset: Optional[str] = None
watermark_enabled: Optional[bool] = None
watermark_text: Optional[str] = None
@validator("resolution")
def validate_resolution(cls, v):
if v is None:
return v
if not RESOLUTION_PATTERN.match(v):
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
w, h = v.split("x")
if int(w) < 100 or int(h) < 100:
raise ValueError("分辨率数值过小")
if int(w) > 4096 or int(h) > 4096:
raise ValueError("分辨率数值过大,最大 4096x4096")
return v
@validator("format")
def validate_format(cls, v):
if v is None:
return v
if v not in VALID_FORMATS:
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
return v
@validator("quality_preset")
def validate_quality_preset(cls, v):
if v is None:
return v
if v not in VALID_QUALITY_PRESETS:
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
return v
class ExportPresetItem(BaseModel):
"""导出预设条目"""
id: str
name: str
resolution: str
fps: int
video_bitrate: int
audio_bitrate: int
format: str
quality_preset: str
description: str
size_hint: str
class ExportPresetListResponse(BaseModel):
"""导出预设列表响应"""
items: List[ExportPresetItem]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _get_export_config(plan_config: dict) -> dict:
e = plan_config.get("export", {})
if not isinstance(e, dict):
e = {}
return {
"resolution": e.get("resolution", "1080x1920"),
"fps": e.get("fps", 30),
"video_bitrate": e.get("video_bitrate", 8000),
"audio_bitrate": e.get("audio_bitrate", 128),
"format": e.get("format", "mp4"),
"quality_preset": e.get("quality_preset", "balanced"),
"watermark_enabled": e.get("watermark_enabled", False),
"watermark_text": e.get("watermark_text", ""),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/export-presets", response_model=ExportPresetListResponse)
def list_export_presets(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ExportPresetListResponse:
"""获取导出预设列表"""
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
return ExportPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
def get_export_config(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""获取导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_export_config(plan.config or {})
return ExportConfigResponse(**config)
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
def update_export_config(
plan_id: str,
body: ExportUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ExportConfigResponse:
"""更新导出配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 合并更新
current = _get_export_config(plan.config or {})
updates = body.model_dump(exclude_none=True)
new_export = {**current, **updates}
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["export"] = new_export
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
result = _get_export_config(updated_plan.config or {})
logger.info(
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
plan_id,
result["resolution"],
result["fps"],
current_user.user.id,
)
return ExportConfigResponse(**result)
+198
View File
@@ -0,0 +1,198 @@
"""滤镜调色 API.
- GET /filter-presets 滤镜预设列表
- GET /{plan_id}/filter 获取全局滤镜配置
- PUT /{plan_id}/filter 更新全局滤镜配置
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.filter_presets import (
FilterPreset,
build_ffmpeg_filter,
get_filter_preset,
list_filter_presets,
)
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class FilterPresetResponse(BaseModel):
"""滤镜预设响应"""
id: str
name: str
category: str
description: str
tags: List[str] = Field(default_factory=list)
class FilterConfigResponse(BaseModel):
"""滤镜配置响应"""
enabled: bool
preset_id: str
intensity: int
brightness: float
contrast: float
saturation: float
warmth: float
class FilterUpdateRequest(BaseModel):
"""更新滤镜配置请求"""
enabled: Optional[bool] = None
preset_id: Optional[str] = None
intensity: Optional[int] = Field(default=None, ge=0, le=100)
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
class FilterPresetListResponse(BaseModel):
"""滤镜预设列表响应"""
items: List[FilterPresetResponse]
total: int
# ── Helpers ──────────────────────────────────────────────────────────────────
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
return FilterPresetResponse(
id=p.id,
name=p.name,
category=p.category,
description=p.description,
tags=list(p.tags),
)
def _get_filter_config(plan_config: dict) -> dict:
"""从 plan.config 中提取滤镜配置"""
f = plan_config.get("filter", {})
if not isinstance(f, dict):
f = {}
return {
"enabled": f.get("enabled", False),
"preset_id": f.get("preset_id", "filter_none"),
"intensity": f.get("intensity", 100),
"brightness": f.get("brightness", 0.0),
"contrast": f.get("contrast", 1.0),
"saturation": f.get("saturation", 1.0),
"warmth": f.get("warmth", 0.0),
}
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("/filter-presets", response_model=FilterPresetListResponse)
def list_presets(
category: Optional[str] = Query(default=None, description="按分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> FilterPresetListResponse:
"""获取滤镜预设列表"""
presets = list_filter_presets(category=category, keyword=keyword)
items = [_preset_to_response(p) for p in presets]
return FilterPresetListResponse(items=items, total=len(items))
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
def get_filter(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""获取剪辑计划的全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
config = _get_filter_config(plan.config or {})
return FilterConfigResponse(**config)
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
def update_filter(
plan_id: str,
body: FilterUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> FilterConfigResponse:
"""更新全局滤镜配置"""
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证 preset_id
updates = body.model_dump(exclude_none=True)
if "preset_id" in updates:
preset = get_filter_preset(updates["preset_id"])
if preset is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的滤镜预设: {updates['preset_id']}",
)
# 合并更新
current = _get_filter_config(plan.config or {})
new_filter = {**current, **updates}
# 如果设为原图 preset,自动关闭
if new_filter["preset_id"] == "filter_none":
new_filter["enabled"] = False
# 更新到 plan.config
current_config = dict(plan.config or {})
current_config["filter"] = new_filter
normalized = normalize_plan_config(current_config)
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
result = _get_filter_config(updated_plan.config or {})
logger.info(
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
plan_id,
result["preset_id"],
result["intensity"],
current_user.user.id,
)
return FilterConfigResponse(**result)
+50
View File
@@ -427,3 +427,53 @@ def retry_generation_task(
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)
+169
View File
@@ -504,6 +504,175 @@ class EditPlanService:
return created
# ── 渲染生成流程 ────────────────────────────────────────────────────────
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
"""将一个片段从指定位置分割为两个片段
Args:
clip_id: 要分割的片段 ID
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
Returns:
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
Raises:
ValueError: 片段不存在、分割时间越界
"""
clip = self.get_clip_or_raise(clip_id)
plan_id = clip.plan_id
if split_time <= 0 or split_time >= clip.duration:
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
self._auto_resume_editing(plan_id)
original_duration = clip.duration
left_duration = round(split_time, 3)
right_duration = round(original_duration - split_time, 3)
original_order = clip.order
# 更新左半部分(原片段)
clip.duration = left_duration
left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > original_order and c.id != clip_id:
c.order += 1
self._clip_repo.update(c)
# 创建右半部分新片段(继承原片段的大部分属性)
right_config = dict(clip.config) if clip.config else {}
# 素材裁剪信息
if clip.asset_id:
# 右半部分从 split_time 开始播放
right_config["trim_start"] = left_duration
# 左半部分在 split_time 处结束
left_config = dict(left_clip.config) if left_clip.config else {}
left_config["trim_end"] = right_duration
left_clip.config = left_config
left_clip = self._clip_repo.update(left_clip)
right_clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip.clip_type,
order=original_order + 1,
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time + left_duration,
duration=right_duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
config=right_config,
)
created_right = self._clip_repo.create(right_clip)
logger.info(
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
clip_id,
plan_id,
split_time,
left_duration,
right_duration,
)
return {
"left_clip": left_clip,
"right_clip": created_right,
}
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
"""合并多个连续片段为一个片段
Args:
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
Returns:
EditPlanClip: 合并后的新片段
Raises:
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
"""
if len(clip_ids) < 2:
raise ValueError("至少需要 2 个片段才能合并")
# 读取所有片段
clips = []
for cid in clip_ids:
clip = self.get_clip_or_raise(cid)
clips.append(clip)
# 校验:同一计划
plan_id = clips[0].plan_id
for c in clips[1:]:
if c.plan_id != plan_id:
raise ValueError("只能合并同一计划下的片段")
# 按 order 排序
clips.sort(key=lambda c: c.order)
# 校验:order 连续
for i in range(1, len(clips)):
if clips[i].order != clips[i - 1].order + 1:
raise ValueError(f"片段不连续:order {clips[i-1].order}{clips[i].order}")
# 校验:类型一致
clip_type = clips[0].clip_type
for c in clips[1:]:
if c.clip_type != clip_type:
raise ValueError("只能合并相同类型的片段")
self._auto_resume_editing(plan_id)
# 计算合并后的属性
first_clip = clips[0]
total_duration = round(sum(c.duration for c in clips), 3)
first_order = first_clip.order
# 合并文案(用换行连接)
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
# 合并 config(后面的覆盖前面的)
merged_config: Dict[str, Any] = {}
for c in clips:
if c.config:
merged_config.update(c.config)
# 清理 trim 相关字段(合并后就是完整片段了)
merged_config.pop("trim_start", None)
merged_config.pop("trim_end", None)
# 更新第一个片段(保留它作为合并结果)
first_clip.duration = total_duration
first_clip.text_content = merged_text
first_clip.config = merged_config
# 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段
for c in clips[1:]:
self._clip_repo.delete(c.id)
# 后面的片段 order 前移 (len - 1) 位
shift = len(clips) - 1
all_clips = self._clip_repo.list_by_plan(plan_id)
for c in all_clips:
if c.order > first_order and c.id != merged_clip.id:
c.order -= shift
self._clip_repo.update(c)
logger.info(
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id,
len(clips),
total_duration,
)
return merged_clip
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
"""获取计划及其所有片段
Regular → Executable
+7 -1
View File
@@ -20,7 +20,7 @@ import type {
/** 剪辑计划状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed";
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled";
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
@@ -453,6 +453,11 @@ export async function getGenerationTaskResults(
return response.data.items || response.data || [];
}
/** 取消生成任务 */
export async function cancelGeneration(planId: string): Promise<void> {
await apiClient.post(`/edit-plans/${planId}/cancel`);
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
@@ -553,6 +558,7 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
/** 质量分筛选选项 */
+45 -2
View File
@@ -24,12 +24,14 @@ import {
DeleteOutlined,
FileTextOutlined,
ThunderboltOutlined,
StopOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getEditPlans,
deleteEditPlan,
generateEditPlan,
cancelGeneration,
type EditPlan,
type EditPlanStatus,
type EditPlanListParams,
@@ -47,6 +49,7 @@ const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
{ key: "rendering", label: "渲染中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
];
/** 状态标签配置 */
@@ -79,6 +82,11 @@ const STATUS_CONFIG: Record<
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <StopOutlined />,
},
};
/* ──────────── 工具函数 ──────────── */
@@ -184,6 +192,18 @@ export default function EditPlans() {
},
});
// 取消生成
const cancelMutation = useMutation({
mutationFn: cancelGeneration,
onSuccess: () => {
message.success("已提交取消请求");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("取消失败,请稍后重试");
},
});
// 跳转到剪辑编辑器
const handleEdit = useCallback(
(plan: EditPlan) => {
@@ -285,7 +305,7 @@ export default function EditPlans() {
{
title: "操作",
key: "action",
width: 180,
width: 240,
fixed: "right",
render: (_: unknown, record: EditPlan) => (
<div className="plan-actions">
@@ -298,7 +318,30 @@ export default function EditPlans() {
>
</Button>
{(record.status === "failed" || record.status === "completed") && (
{record.status === "rendering" && (
<Popconfirm
title="确认取消生成"
description="确定要取消当前生成任务吗?此操作不可恢复。"
onConfirm={() => cancelMutation.mutate(record.id)}
okText="确定"
cancelText="再等等"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
loading={cancelMutation.isPending}
className="plan-action-btn plan-cancel-btn"
>
</Button>
</Popconfirm>
)}
{(record.status === "failed" ||
record.status === "completed" ||
record.status === "cancelled") && (
<Popconfirm
title="确认重新生成"
description="确定要重新生成这个剪辑计划吗?"
+26
View File
@@ -6048,3 +6048,29 @@
color: #ef4444;
background: #fef2f2;
}
/* ── 生成历史取消按钮 ── */
.ep-gh-td-action {
width: 60px;
text-align: center;
}
.ep-gh-cancel-btn {
background: none;
border: none;
color: var(--color-error, #ff4d4f);
cursor: pointer;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.ep-gh-cancel-btn:hover:not(:disabled) {
background: rgba(255, 77, 79, 0.1);
}
.ep-gh-cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ep-gh-action-placeholder {
color: var(--text-tertiary, #bfbfbf);
}
@@ -37,6 +37,7 @@ import {
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
cancelGeneration,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type {
@@ -81,8 +82,8 @@ import TimelinePanel from "./components/TimelinePanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import BgmSelector from "./components/BgmSelector";
import SubtitleStylePanel from "./components/SubtitleStylePanel";
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
import type { SubtitleStyleConfig } from "./types/subtitle";
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle";
import TransitionSelector from "./components/TransitionSelector";
import SpeedPanel from "./components/SpeedPanel";
import TtsPanel from "./components/TtsPanel";
@@ -270,6 +271,7 @@ const EditingPlanner: React.FC = () => {
const [generated, setGenerated] = useState(false);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [genError, setGenError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* ── 播放 ── */
@@ -558,11 +560,14 @@ const EditingPlanner: React.FC = () => {
if (selectedClipId === clipId) setSelectedClipId(null);
};
const handleClipUpdate = (clipId: string, data: Partial<ClipData>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
);
};
const handleClipUpdate = useCallback(
(clipId: string, data: Partial<ClipData>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
);
},
[setClips],
);
/**
* 添加片段(不绑定任何素材)
@@ -670,7 +675,7 @@ const EditingPlanner: React.FC = () => {
}
// 同时更新全局默认转场(供新片段使用)
},
[transitionTargetClipId],
[transitionTargetClipId, handleClipUpdate],
);
/* ── 打开转场选择器 ── */
@@ -686,7 +691,7 @@ const EditingPlanner: React.FC = () => {
handleClipUpdate(speedTargetClipId, { speed: config });
}
},
[speedTargetClipId],
[speedTargetClipId, handleClipUpdate],
);
/* ── 打开调速面板 ── */
@@ -701,7 +706,7 @@ const EditingPlanner: React.FC = () => {
if (!ttsTargetClipId) return;
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
},
[ttsTargetClipId],
[ttsTargetClipId, handleClipUpdate],
);
/* ── 打开 TTS 配音面板 ── */
@@ -711,10 +716,13 @@ const EditingPlanner: React.FC = () => {
}, []);
/* ── 调速应用到所有片段 ── */
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
message.success("已应用到所有片段");
}, []);
const handleApplySpeedAll = useCallback(
(config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
message.success("已应用到所有片段");
},
[setClips],
);
/* ── 水印配置变更 ── */
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
@@ -1041,6 +1049,13 @@ const EditingPlanner: React.FC = () => {
return; // 停止轮询
}
if (status.plan_status === "cancelled") {
setGenerating(false);
setGenError("生成已取消");
message.info("生成任务已取消");
return; // 停止轮询
}
// 继续轮询
genTimerRef.current = setTimeout(poll, 2000);
} catch (err) {
@@ -1060,6 +1075,33 @@ const EditingPlanner: React.FC = () => {
};
}, []);
/** 取消生成任务 */
const handleCancelGeneration = async () => {
const targetId = loadedPlanId;
if (!targetId) return;
Modal.confirm({
title: "确认取消生成",
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
okText: "确认取消",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
try {
setCancelling(true);
await cancelGeneration(targetId);
message.success("已提交取消请求");
// 轮询会继续运行直到检测到 cancelled 状态
} catch (err) {
console.error("[取消失败]", err);
message.error("取消失败,请稍后重试");
} finally {
setCancelling(false);
}
},
});
};
/* 查看生成历史 */
const handleViewGenHistory = async () => {
const targetId = loadedPlanId || loadedTemplateId;
@@ -1284,6 +1326,28 @@ const EditingPlanner: React.FC = () => {
loading={genHistoryLoading}
history={genHistory}
onClose={() => setGenHistoryOpen(false)}
onCancel={async () => {
Modal.confirm({
title: "确认取消生成",
content: "确定要取消这个生成任务吗?此操作不可恢复。",
okText: "确认取消",
cancelText: "再等等",
okButtonProps: { danger: true },
onOk: async () => {
if (!loadedPlanId) return;
try {
await cancelGeneration(loadedPlanId);
message.success("已提交取消请求");
// 刷新历史列表
handleViewGenHistory();
} catch (err) {
console.error("[取消失败]", err);
message.error("取消失败,请稍后重试");
}
},
});
}}
cancelLoading={cancelling}
/>
{/* ═══ 生成进度弹窗 ═══ */}
@@ -1298,6 +1362,7 @@ const EditingPlanner: React.FC = () => {
onClick={() => {
setGenerated(false);
setGenerating(false);
setGenError(null);
}}
>
@@ -1324,7 +1389,30 @@ const EditingPlanner: React.FC = () => {
</Button>
),
]
: null
: generating
? [
<Button
key="cancel"
danger
loading={cancelling}
onClick={handleCancelGeneration}
>
</Button>,
]
: genError
? [
<Button
key="close"
onClick={() => {
setGenError(null);
setGenerating(false);
}}
>
</Button>,
]
: null
}
closable={!generating}
maskClosable={false}
+22
View File
@@ -12,6 +12,8 @@ interface GenerationHistoryModalProps {
loading: boolean;
history: EditPlanGeneration[];
onClose: () => void;
onCancel?: (taskId: string) => void;
cancelLoading?: boolean;
}
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
@@ -19,6 +21,8 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
loading,
history,
onClose,
onCancel,
cancelLoading,
}) => {
if (!open) return null;
@@ -57,11 +61,14 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
<th className="ep-gh-th"></th>
<th className="ep-gh-th"></th>
<th className="ep-gh-th"></th>
{onCancel && <th className="ep-gh-th"></th>}
</tr>
</thead>
<tbody>
{history.map((gen) => {
const statusClass = `ep-gh-status-tag--${gen.status}`;
const canCancel =
gen.status === "rendering" || gen.status === "editing";
return (
<tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id">
@@ -82,6 +89,21 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
? new Date(gen.updated_at).toLocaleString("zh-CN")
: "—"}
</td>
{onCancel && (
<td className="ep-gh-td ep-gh-td-action">
{canCancel ? (
<button
className="ep-gh-cancel-btn"
onClick={() => onCancel(gen.id)}
disabled={cancelLoading}
>
</button>
) : (
<span className="ep-gh-action-placeholder"></span>
)}
</td>
)}
</tr>
);
})}
+1 -40
View File
@@ -5,46 +5,7 @@
import React from "react";
import { Drawer, Slider, ColorPicker, Select } from "antd";
import type { Color } from "antd/es/color-picker";
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};
import type { SubtitleStyleConfig } from "../types/subtitle";
/* ──────────── 选项常量 ──────────── */
+46
View File
@@ -0,0 +1,46 @@
/**
* 字幕样式相关类型与常量
* 单独抽离以满足 react-refresh/only-export-components 规则
*/
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
/* ──────────── 默认值 ──────────── */
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};
@@ -491,6 +491,28 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task_repo.update(gen_task)
# 4. 根据引擎选择渲染方式
# 取消检查:素材下载完后,确认任务没有被用户取消
if generation_task_id:
current_task = gen_task_repo.get(generation_task_id)
if current_task:
task_status = (
current_task.status.value
if hasattr(current_task.status, "value")
else str(current_task.status)
)
if task_status == "cancelled":
logger.info("任务已被取消,中止渲染: plan_id=%s task_id=%s", plan_id, generation_task_id)
# 计划回到 editing 状态,用户可以继续编辑
from packages.domain.edit_plan import EditPlanStatus
if plan.status.value == "rendering":
try:
plan.resume_editing()
plan_repo.update(plan)
except ValueError:
pass
return {"status": "cancelled", "plan_id": plan_id, "message": "任务已取消"}
if engine == "unified":
result = _render_with_unified(
plan=plan,
+17 -2
View File
@@ -3,12 +3,27 @@ FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
WORKDIR /app
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
ENV VITE_API_URL=$VITE_API_URL
# 先拷依赖清单(缓存友好:依赖不变时直接命中缓存层)
COPY apps/web/package.json apps/web/package-lock.json ./apps/web/
WORKDIR /app/apps/web
RUN npm config set registry https://registry.npmmirror.com \
# 安装依赖:用BuildKit cache mount缓存npm下载和node_modules
# sharing=locked 防止并发构建竞争写缓存
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
--mount=type=cache,target=/app/apps/web/node_modules,sharing=locked \
npm config set registry https://registry.npmmirror.com \
&& npm ci
# 再拷源码
COPY apps/web/ ./
RUN npm run build
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
RUN --mount=type=cache,target=/app/apps/web/node_modules,sharing=locked \
--mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
mkdir -p .tscache \
&& npx tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
&& npx vite build
# Production stage with nginx
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
+59
View File
@@ -140,6 +140,42 @@ class BGMConfig(BaseModel):
sidechain_threshold: float = Field(default=-25.0, ge=-60.0, le=0.0, description="人声闪避触发阈值(dB")
class ExportConfig(BaseModel):
"""导出配置
视频输出参数设置。
"""
resolution: str = Field(default="1080x1920", description="输出分辨率,如 1080x1920 / 720x1280 / 2160x3840")
fps: int = Field(default=30, ge=15, le=60, description="输出帧率 15~60")
video_bitrate: int = Field(default=8000, ge=1000, le=20000, description="视频码率(kbps")
audio_bitrate: int = Field(default=128, ge=64, le=320, description="音频码率(kbps")
format: str = Field(default="mp4", description="输出格式:mp4 / mov")
quality_preset: str = Field(
default="balanced",
description="质量预设:ultra_fast / fast / balanced / high / best",
)
watermark_enabled: bool = Field(default=False, description="是否启用水印")
watermark_text: str = Field(default="", description="水印文字")
class FilterConfig(BaseModel):
"""滤镜调色配置
支持全局滤镜和按片段覆盖。
强度 0-100,0 表示不应用,100 表示全量应用预设。
"""
enabled: bool = Field(default=False, description="是否启用滤镜")
preset_id: str = Field(default="filter_none", description="滤镜预设 ID")
intensity: int = Field(default=100, ge=0, le=100, description="滤镜强度 0-100")
# 自定义微调参数(在预设基础上叠加调整)
brightness: float = Field(default=0.0, ge=-1.0, le=1.0, description="亮度微调")
contrast: float = Field(default=1.0, ge=0.0, le=2.0, description="对比度微调(倍率)")
saturation: float = Field(default=1.0, ge=0.0, le=3.0, description="饱和度微调(倍率)")
warmth: float = Field(default=0.0, ge=-1.0, le=1.0, description="色温微调(正=暖,负=冷)")
# ── 完整 config 模型 ─────────────────────────────────────────────────────────
@@ -155,6 +191,8 @@ class EditPlanConfigSchema(BaseModel):
title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置")
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置")
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置")
export: ExportConfig = Field(default_factory=ExportConfig, description="导出配置")
filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜调色配置")
editing_mode: str = Field(default="one_take", description="剪辑模式")
@@ -169,6 +207,8 @@ class EditTemplateConfigSchema(BaseModel):
title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置")
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置")
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置")
export: ExportConfig = Field(default_factory=ExportConfig, description="导出默认配置")
filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜默认配置")
editing_mode: str = Field(default="one_take", description="剪辑模式")
transition_enabled: bool = Field(default=True, description="是否启用转场")
@@ -218,6 +258,25 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
"sidechain_release": 0.5,
"sidechain_threshold": -25.0,
},
"export": {
"resolution": "1080x1920",
"fps": 30,
"video_bitrate": 8000,
"audio_bitrate": 128,
"format": "mp4",
"quality_preset": "balanced",
"watermark_enabled": False,
"watermark_text": "",
},
"filter": {
"enabled": False,
"preset_id": "filter_none",
"intensity": 100,
"brightness": 0.0,
"contrast": 1.0,
"saturation": 1.0,
"warmth": 0.0,
},
"editing_mode": "one_take",
}
+357
View File
@@ -0,0 +1,357 @@
"""滤镜预设库 — 视频调色滤镜预设清单.
每个滤镜预设对应一组 FFmpeg 滤镜参数,用于视频调色。
所有参数均可调整强度(0-100),0表示原图,100表示全量应用。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass(frozen=True)
class FilterPreset:
"""滤镜预设条目"""
id: str
name: str
category: str # 分类:basic / cinematic / vintage / bw / style
description: str = ""
tags: List[str] = field(default_factory=list)
# FFmpeg eq 滤镜参数(基准值,实际应用时乘以强度系数)
brightness: float = 0.0 # -1.0 ~ 1.0
contrast: float = 1.0 # 0.0 ~ 2.01.0为原值
saturation: float = 1.0 # 0.0 ~ 3.01.0为原值
gamma: float = 1.0 # 0.1 ~ 10.01.0为原值
gamma_r: float = 1.0 # 红通道伽马
gamma_g: float = 1.0 # 绿通道伽马
gamma_b: float = 1.0 # 蓝通道伽马
hue: float = 0.0 # 色相偏移 -180 ~ 180度
# 可选的颜色查找表 LUT(后续扩展)
lut_url: str = ""
# ── 预设库清单 ────────────────────────────────────────────────────────────────
FILTER_PRESET_LIBRARY: List[FilterPreset] = [
# ── 基础 basic ─────────────────────────────────────────────────────
FilterPreset(
id="filter_none",
name="原图",
category="basic",
description="不应用任何滤镜,保持原始画面",
tags=["原图", ""],
),
FilterPreset(
id="filter_brighten",
name="明亮",
category="basic",
description="提升画面亮度,适合偏暗的素材",
tags=["提亮", "基础"],
brightness=0.12,
contrast=1.05,
saturation=1.05,
gamma=1.1,
),
FilterPreset(
id="filter_warm",
name="暖色",
category="basic",
description="暖色调,增加温暖感",
tags=["暖色", "温馨"],
gamma_r=1.1,
gamma_g=1.02,
gamma_b=0.9,
saturation=1.05,
),
FilterPreset(
id="filter_cool",
name="冷色",
category="basic",
description="冷色调,清凉干净",
tags=["冷色", "清新"],
gamma_r=0.9,
gamma_g=1.0,
gamma_b=1.1,
saturation=1.02,
),
FilterPreset(
id="filter_contrast",
name="高对比",
category="basic",
description="增强对比度,画面更通透",
tags=["对比", "通透"],
contrast=1.25,
saturation=1.1,
gamma=0.95,
),
FilterPreset(
id="filter_saturate",
name="鲜艳",
category="basic",
description="提升饱和度,色彩更浓郁",
tags=["鲜艳", "浓郁"],
saturation=1.4,
contrast=1.05,
),
# ── 电影感 cinematic ─────────────────────────────────────────────
FilterPreset(
id="filter_cinematic",
name="电影感",
category="cinematic",
description="经典电影色调,青橙对比",
tags=["电影", "青橙", "质感"],
contrast=1.2,
saturation=0.9,
gamma_r=1.15,
gamma_g=0.95,
gamma_b=0.85,
brightness=-0.03,
),
FilterPreset(
id="filter_teal_orange",
name="青橙色调",
category="cinematic",
description="好莱坞经典青橙对比色",
tags=["青橙", "好莱坞", "对比"],
contrast=1.15,
saturation=1.1,
gamma_r=1.2,
gamma_g=0.9,
gamma_b=0.8,
),
# ── 复古 vintage ─────────────────────────────────────────────────
FilterPreset(
id="filter_vintage",
name="复古",
category="vintage",
description="复古胶片色调,怀旧感",
tags=["复古", "怀旧", "胶片"],
saturation=0.8,
contrast=0.9,
gamma_r=1.1,
gamma_g=1.0,
gamma_b=0.85,
brightness=-0.02,
),
FilterPreset(
id="filter_retro",
name="怀旧",
category="vintage",
description="80年代复古感",
tags=["怀旧", "80年代"],
saturation=0.75,
contrast=0.95,
gamma_r=1.2,
gamma_g=1.05,
gamma_b=0.9,
),
FilterPreset(
id="filter_sepia",
name="棕褐色",
category="vintage",
description="老照片棕褐色调",
tags=["棕褐", "老照片", "复古"],
saturation=0.3,
gamma_r=1.3,
gamma_g=1.1,
gamma_b=0.8,
contrast=0.95,
),
# ── 黑白 bw ──────────────────────────────────────────────────────
FilterPreset(
id="filter_bw",
name="黑白",
category="bw",
description="经典黑白",
tags=["黑白", "经典"],
saturation=0.0,
contrast=1.1,
),
FilterPreset(
id="filter_bw_high",
name="高对比黑白",
category="bw",
description="高对比度黑白,戏剧感强",
tags=["黑白", "高对比", "戏剧"],
saturation=0.0,
contrast=1.4,
gamma=0.9,
),
FilterPreset(
id="filter_bw_soft",
name="柔和黑白",
category="bw",
description="柔和灰度过渡,细腻质感",
tags=["黑白", "柔和", "细腻"],
saturation=0.0,
contrast=0.9,
gamma=1.1,
),
# ── 风格化 style ────────────────────────────────────────────────
FilterPreset(
id="filter_japanese",
name="日系",
category="style",
description="日系清新,低对比高明度",
tags=["日系", "清新", "干净"],
contrast=0.85,
brightness=0.08,
saturation=0.85,
gamma_r=0.98,
gamma_g=1.02,
gamma_b=1.08,
),
FilterPreset(
id="filter_hk",
name="港风",
category="style",
description="90年代港风,暖黄+高饱和",
tags=["港风", "复古", "浓郁"],
saturation=1.25,
contrast=1.1,
gamma_r=1.2,
gamma_g=1.05,
gamma_b=0.85,
brightness=-0.02,
),
FilterPreset(
id="filter_cyberpunk",
name="赛博朋克",
category="style",
description="赛博朋克风,青紫霓虹",
tags=["赛博", "霓虹", "未来感"],
contrast=1.2,
saturation=1.3,
gamma_r=1.3,
gamma_g=0.7,
gamma_b=1.2,
brightness=-0.05,
),
FilterPreset(
id="filter_fresh",
name="清新",
category="style",
description="清新自然,通透干净",
tags=["清新", "自然", "通透"],
brightness=0.05,
saturation=1.05,
contrast=1.05,
gamma_g=1.03,
gamma_b=1.05,
),
FilterPreset(
id="filter_dramatic",
name="戏剧感",
category="style",
description="强对比暗角,戏剧化氛围",
tags=["戏剧", "暗角", "氛围"],
contrast=1.35,
saturation=0.9,
brightness=-0.08,
gamma=0.85,
),
FilterPreset(
id="filter_dreamy",
name="梦幻",
category="style",
description="柔光梦幻感,低对比",
tags=["梦幻", "柔光", "唯美"],
contrast=0.8,
brightness=0.1,
saturation=1.1,
gamma=1.15,
),
]
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def get_filter_preset(preset_id: str) -> Optional[FilterPreset]:
"""根据 ID 获取滤镜预设"""
for p in FILTER_PRESET_LIBRARY:
if p.id == preset_id:
return p
return None
def list_filter_presets(
*,
category: Optional[str] = None,
keyword: Optional[str] = None,
) -> List[FilterPreset]:
"""筛选滤镜预设列表
Args:
category: 按分类筛选
keyword: 关键词搜索(名称/标签/描述)
Returns:
筛选后的预设列表
"""
results = FILTER_PRESET_LIBRARY
if category:
results = [p for p in results if p.category == category]
if keyword:
kw = keyword.lower()
results = [
p
for p in results
if kw in p.name.lower() or kw in p.description.lower() or any(kw in t.lower() for t in p.tags)
]
return results
def build_ffmpeg_filter(preset_id: str, intensity: int = 100) -> str:
"""根据预设和强度生成 FFmpeg eq 滤镜字符串.
Args:
preset_id: 滤镜预设 ID
intensity: 强度 0-1000=原图,100=全量
Returns:
FFmpeg eq 滤镜参数字符串
"""
preset = get_filter_preset(preset_id)
if preset is None or intensity <= 0:
return ""
if intensity >= 100:
intensity = 100
factor = intensity / 100.0
# 计算插值后的参数(向原值插值)
brightness = preset.brightness * factor
contrast = 1.0 + (preset.contrast - 1.0) * factor
saturation = 1.0 + (preset.saturation - 1.0) * factor
gamma = 1.0 + (preset.gamma - 1.0) * factor
gamma_r = 1.0 + (preset.gamma_r - 1.0) * factor
gamma_g = 1.0 + (preset.gamma_g - 1.0) * factor
gamma_b = 1.0 + (preset.gamma_b - 1.0) * factor
parts = []
if abs(brightness) > 0.001:
parts.append(f"brightness={brightness:.3f}")
if abs(contrast - 1.0) > 0.001:
parts.append(f"contrast={contrast:.3f}")
if abs(saturation - 1.0) > 0.001:
parts.append(f"saturation={saturation:.3f}")
if abs(gamma - 1.0) > 0.001:
parts.append(f"gamma={gamma:.3f}")
if abs(gamma_r - 1.0) > 0.001:
parts.append(f"gamma_r={gamma_r:.3f}")
if abs(gamma_g - 1.0) > 0.001:
parts.append(f"gamma_g={gamma_g:.3f}")
if abs(gamma_b - 1.0) > 0.001:
parts.append(f"gamma_b={gamma_b:.3f}")
if not parts:
return ""
return f"eq={':'.join(parts)}"
+627
View File
@@ -0,0 +1,627 @@
#!/usr/bin/env python3
"""
CI Code Review Script
- 从 Gitea 获取 PR diff
- 调用 LLM 进行代码审查
- 将审查结果写回 PR 评论
"""
import argparse
import json
import logging
import os
import sys
from typing import Optional, Tuple
import requests
# ============== 日志配置 ==============
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("ci_code_review")
# ============== 常量配置 ==============
# diff 最大字符数(超过则截断)
MAX_DIFF_CHARS = int(os.getenv("MAX_DIFF_CHARS", "30000"))
# LLM 调用超时时间(秒)
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120"))
# Gitea API 超时时间(秒)
GITEA_TIMEOUT = int(os.getenv("GITEA_TIMEOUT", "30"))
# 最大重试次数
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2"))
# LLM 提供商: openai (OpenAI兼容) / coze (扣子原生Bot API)
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "coze").lower()
# ============== 工具函数 ==============
def truncate_diff(diff_text: str, max_chars: int) -> Tuple[str, bool]:
"""
截断过大的 diff 内容,避免超出 LLM 上下文限制。
优先保留文件头和前面的变更,末尾加提示。
"""
if len(diff_text) <= max_chars:
return diff_text, False
# 找到一个合适的截断位置(尽量在文件边界)
truncated = diff_text[:max_chars]
# 尝试在最后一个 "diff --git" 处截断,避免截断到一半
last_file_boundary = truncated.rfind("\ndiff --git ")
if last_file_boundary > max_chars // 2:
truncated = truncated[:last_file_boundary]
truncated += (
f"\n\n... [DIFF TRUNCATED] 原始 diff 共 {len(diff_text)} 字符,"
f"已截断至 {len(truncated)} 字符,仅审查前半部分。\n"
)
return truncated, True
def get_env_or_fail(name: str) -> str:
"""从环境变量获取值,不存在则报错退出。"""
value = os.getenv(name)
if not value:
logger.error(f"环境变量 {name} 未设置")
sys.exit(1)
return value
# ============== Gitea API 相关 ==============
class GiteaClient:
"""Gitea API 客户端"""
def __init__(self, base_url: str, token: str, repo: str):
# 确保 base_url 以 / 结尾
self.base_url = base_url.rstrip("/") + "/"
self.token = token
self.repo = repo # 格式: owner/repo
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"token {token}",
"Accept": "application/json",
"Content-Type": "application/json",
}
)
def _api_url(self, path: str) -> str:
"""拼接 API 路径"""
return f"{self.base_url}api/v1/repos/{self.repo}/{path.lstrip('/')}"
def get_pr_diff(self, pr_number: int) -> str:
"""
获取 PR 的 diff 内容。
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}.diff
"""
url = self._api_url(f"pulls/{pr_number}.diff")
logger.info(f"获取 PR #{pr_number} diff: {url}")
resp = self.session.get(
url,
timeout=GITEA_TIMEOUT,
headers={
"Accept": "text/plain",
},
)
if resp.status_code != 200:
logger.error(f"获取 diff 失败: HTTP {resp.status_code} - {resp.text[:200]}")
raise RuntimeError(f"Failed to get PR diff: HTTP {resp.status_code}")
diff_text = resp.text
logger.info(f"获取到 diff,共 {len(diff_text)} 字符")
return diff_text
def get_pr_files(self, pr_number: int) -> list:
"""
获取 PR 修改的文件列表。
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}/files
"""
url = self._api_url(f"pulls/{pr_number}/files")
logger.info(f"获取 PR #{pr_number} 文件列表")
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
if resp.status_code != 200:
logger.warning(f"获取文件列表失败: HTTP {resp.status_code}")
return []
files = resp.json()
logger.info(f"PR 修改了 {len(files)} 个文件")
return files
def post_pr_comment(self, pr_number: int, body: str) -> bool:
"""
在 PR 上发布评论。
Gitea API: POST /repos/{owner}/{repo}/issues/{index}/comments
Gitea 中 PR 评论走 issues 接口)
"""
url = self._api_url(f"issues/{pr_number}/comments")
logger.info(f"发布 PR 评论: {url}")
payload = {"body": body}
resp = self.session.post(
url,
data=json.dumps(payload),
timeout=GITEA_TIMEOUT,
)
if resp.status_code not in (200, 201):
logger.error(f"发布评论失败: HTTP {resp.status_code} - {resp.text[:200]}")
return False
logger.info(f"评论发布成功,评论 ID: {resp.json().get('id', 'unknown')}")
return True
def get_existing_review_comments(self, pr_number: int, marker: str) -> list:
"""
获取 PR 上已有的 AI 审查评论 ID 列表(带标识 marker)。
"""
url = self._api_url(f"issues/{pr_number}/comments")
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
if resp.status_code != 200:
logger.warning(f"获取评论列表失败: HTTP {resp.status_code}")
return []
comments = resp.json()
review_comment_ids = []
for c in comments:
body = c.get("body", "")
if marker in body:
review_comment_ids.append(c.get("id"))
logger.info(f"找到 {len(review_comment_ids)} 条旧的 AI 审查评论")
return review_comment_ids
def delete_pr_comment(self, pr_number: int, comment_id: int) -> bool:
"""
删除 PR 上的指定评论。
"""
url = self._api_url(f"issues/comments/{comment_id}")
resp = self.session.delete(url, timeout=GITEA_TIMEOUT)
if resp.status_code not in (200, 204):
logger.warning(f"删除评论 {comment_id} 失败: HTTP {resp.status_code}")
return False
return True
def call_llm_openai(
prompt: str,
llm_base_url: str,
llm_api_key: str,
llm_model: str,
) -> Optional[str]:
"""OpenAI 兼容模式调用"""
base_url = llm_base_url.rstrip("/") + "/"
api_url = f"{base_url}chat/completions"
headers = {
"Authorization": f"Bearer {llm_api_key}",
"Content-Type": "application/json",
}
payload = {
"model": llm_model,
"messages": [
{
"role": "system",
"content": "你是一位严谨的资深代码审查专家,擅长发现代码中的逻辑错误、安全隐患和性能问题。",
},
{
"role": "user",
"content": prompt,
},
],
"temperature": 0.3,
"max_tokens": 2048,
}
logger.info(f"调用 LLM (OpenAI兼容): {api_url}, model={llm_model}")
last_error = None
for attempt in range(MAX_RETRIES + 1):
try:
resp = requests.post(
api_url,
headers=headers,
json=payload,
timeout=LLM_TIMEOUT,
)
if resp.status_code != 200:
logger.warning(f"LLM 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:200]}")
last_error = f"HTTP {resp.status_code}"
continue
data = resp.json()
choices = data.get("choices", [])
if not choices:
logger.warning(f"LLM 返回空结果 (第 {attempt + 1} 次)")
last_error = "empty choices"
continue
content = choices[0].get("message", {}).get("content", "")
if not content.strip():
logger.warning(f"LLM 返回空内容 (第 {attempt + 1} 次)")
last_error = "empty content"
continue
logger.info(f"LLM 审查完成,结果长度: {len(content)} 字符")
return content
except requests.Timeout:
logger.warning(f"LLM 调用超时 (第 {attempt + 1} 次)")
last_error = "timeout"
except requests.RequestException as e:
logger.warning(f"LLM 调用异常 (第 {attempt + 1} 次): {e}")
last_error = str(e)
logger.error(f"LLM 调用最终失败: {last_error}")
return None
def call_llm_coze(
prompt: str,
llm_base_url: str,
llm_api_key: str,
llm_model: str,
coze_bot_id: str,
) -> Optional[str]:
"""扣子(Coze)原生 Bot API 调用(支持异步轮询)"""
import time
base_url = llm_base_url.rstrip("/") + "/"
api_url = f"{base_url}v3/chat"
headers = {
"Authorization": f"Bearer {llm_api_key}",
"Content-Type": "application/json",
}
payload = {
"bot_id": coze_bot_id,
"user_id": "ci-code-review-bot",
"stream": False,
"additional_messages": [
{
"role": "user",
"content": prompt,
"content_type": "text",
}
],
}
logger.info(f"调用 LLM (Coze): {api_url}, bot_id={coze_bot_id}")
last_error = None
for attempt in range(MAX_RETRIES + 1):
try:
resp = requests.post(
api_url,
headers=headers,
json=payload,
timeout=LLM_TIMEOUT,
)
if resp.status_code != 200:
logger.warning(f"Coze 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:300]}")
last_error = f"HTTP {resp.status_code}"
continue
data = resp.json()
chat_data = data.get("data", {})
chat_id = chat_data.get("id", "")
conversation_id = chat_data.get("conversation_id", "")
status = chat_data.get("status", "")
# Coze v3 API 异步:先返回 in_progress,需要轮询
if status == "in_progress" and conversation_id and chat_id:
logger.info(f"Coze 异步处理中,开始轮询... (chat_id={chat_id[:12]}...)")
# 轮询 message 列表接口(GET + query参数),最多等 LLM_TIMEOUT 秒
poll_url = f"{base_url}v3/chat/message/list"
poll_start = time.time()
poll_interval = 3 # 每3秒轮询一次
while time.time() - poll_start < LLM_TIMEOUT:
time.sleep(poll_interval)
poll_params = {
"chat_id": chat_id,
"conversation_id": conversation_id,
}
poll_resp = requests.get(
poll_url,
headers=headers,
params=poll_params,
timeout=GITEA_TIMEOUT,
)
if poll_resp.status_code != 200:
logger.debug(f"轮询返回 HTTP {poll_resp.status_code}: {poll_resp.text[:100]}")
continue
poll_data = poll_resp.json()
if poll_data.get("code", 0) != 0:
logger.debug(f"轮询返回错误: {poll_data.get('msg', '')}")
continue
messages = poll_data.get("data", []) or []
# 找assistant的answer消息
content = None
for msg in messages:
if msg.get("role") == "assistant" and msg.get("type") == "answer":
content = msg.get("content", "")
break
if content and content.strip():
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
return content
logger.warning(f"Coze 轮询超时 ({LLM_TIMEOUT}s),未拿到结果")
last_error = "poll timeout"
continue
# 同步返回的情况(兼容)
content = None
messages = chat_data.get("messages", []) or data.get("messages", [])
for msg in messages:
if msg.get("role") == "assistant" and msg.get("type") == "answer":
content = msg.get("content", "")
break
if not content:
content = chat_data.get("content") or data.get("content")
if not content:
choices = data.get("choices", [])
if choices:
content = choices[0].get("message", {}).get("content", "")
if not content or not content.strip():
logger.warning(f"Coze 返回空内容 (第 {attempt + 1} 次): {str(data)[:200]}")
last_error = "empty content"
continue
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
return content
except requests.Timeout:
logger.warning(f"Coze 调用超时 (第 {attempt + 1} 次)")
last_error = "timeout"
except requests.RequestException as e:
logger.warning(f"Coze 调用异常 (第 {attempt + 1} 次): {e}")
last_error = str(e)
logger.error(f"Coze 调用最终失败: {last_error}")
return None
def call_llm_for_review(
diff_text: str,
pr_number: int,
file_list: list,
llm_base_url: str,
llm_api_key: str,
llm_model: str,
coze_bot_id: str = "",
) -> Optional[str]:
"""
调用 LLM 进行代码审查,返回审查结果文本。
失败时返回 None。
根据 LLM_PROVIDER 环境变量选择调用方式。
"""
prompt = build_review_prompt(diff_text, pr_number, file_list)
logger.info(f"Prompt 长度: {len(prompt)} 字符")
provider = LLM_PROVIDER
if provider == "coze":
return call_llm_coze(prompt, llm_base_url, llm_api_key, llm_model, coze_bot_id)
else:
# 默认 OpenAI 兼容
return call_llm_openai(prompt, llm_base_url, llm_api_key, llm_model)
# ============== 主流程 ==============
def main():
parser = argparse.ArgumentParser(description="CI AI 代码审查脚本")
parser.add_argument("--pr", type=int, help="PR 编号(也可通过 PR_NUMBER 环境变量)")
parser.add_argument("--repo", type=str, help="仓库名 owner/repo(也可通过 REPO_NAME 环境变量)")
parser.add_argument("--gitea-url", type=str, help="Gitea 地址(也可通过 GITEA_API_URL 环境变量)")
parser.add_argument("--gitea-token", type=str, help="Gitea Token(也可通过 GITEA_TOKEN 环境变量)")
parser.add_argument("--dry-run", action="store_true", help="只输出审查结果,不发表评论")
args = parser.parse_args()
# 读取配置
gitea_url = args.gitea_url or os.getenv("GITEA_API_URL") or os.getenv("GITEA_SERVER_URL")
gitea_token = args.gitea_token or os.getenv("GITEA_TOKEN")
repo_name = args.repo or os.getenv("REPO_NAME") or os.getenv("GITEA_REPO")
pr_number = args.pr or int(os.getenv("PR_NUMBER") or os.getenv("GITEA_PR_NUMBER") or 0)
llm_base_url = os.getenv("LLM_BASE_URL")
llm_api_key = os.getenv("LLM_API_KEY")
llm_model = os.getenv("LLM_MODEL", "")
coze_bot_id = os.getenv("COZE_BOT_ID", os.getenv("COZE_BOTID", ""))
# 根据 provider 设置默认值
provider = LLM_PROVIDER
if provider == "coze":
# 扣子模式:默认国内站,key 兼容多种环境变量名
if not llm_base_url:
llm_base_url = "https://api.coze.cn"
if not llm_api_key:
llm_api_key = os.getenv("COZE_API_KEY", "") or os.getenv("COZE_PAT", "")
else:
# OpenAI兼容模式:默认模型
if not llm_model:
llm_model = "gpt-4o-mini"
# 必要参数校验
missing = []
if not gitea_url:
missing.append("GITEA_API_URL")
if not gitea_token:
missing.append("GITEA_TOKEN")
if not repo_name:
missing.append("REPO_NAME")
if not pr_number:
missing.append("PR_NUMBER")
if not llm_base_url:
missing.append("LLM_BASE_URL")
if not llm_api_key:
missing.append("LLM_API_KEY")
if provider == "coze" and not coze_bot_id:
missing.append("COZE_BOT_ID (扣子模式需要)")
if missing:
logger.error(f"缺少必要配置: {', '.join(missing)}")
sys.exit(1)
logger.info(f"开始审查 PR #{pr_number},仓库: {repo_name}")
logger.info(f"Gitea: {gitea_url}")
logger.info(f"LLM: {llm_base_url} (model={llm_model})")
try:
# 1. 初始化 Gitea 客户端
gitea = GiteaClient(gitea_url, gitea_token, repo_name)
# 2. 获取 PR diff 和文件列表
try:
diff_text = gitea.get_pr_diff(pr_number)
file_list = gitea.get_pr_files(pr_number)
except Exception as e:
logger.error(f"获取 PR 信息失败: {e}")
sys.exit(1)
# 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件、二进制文件等)
skip_extensions = (
".lock",
".sum",
".min.js",
".min.css",
".map",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".ico",
".woff",
".woff2",
".ttf",
".eot",
)
skipped_files = []
if file_list:
skipped_files = [
f.get("filename")
for f in file_list
if f.get("filename", "").endswith(skip_extensions) or f.get("status") == "removed"
]
if skipped_files:
logger.info(f"跳过 {len(skipped_files)} 个非文本/已删除文件: {', '.join(skipped_files[:5])}...")
# 实际从 diff 中移除跳过的文件(按文件边界切割)
if skipped_files:
diff_lines = diff_text.split("\n")
filtered_lines = []
current_file = None
skip_current = False
i = 0
while i < len(diff_lines):
line = diff_lines[i]
# 检测新文件开始: diff --git a/xxx b/xxx
if line.startswith("diff --git "):
# 提取文件名
parts = line.split(" ")
if len(parts) >= 4:
# b/ 后面的是目标文件名
current_file = parts[3][2:] if parts[3].startswith("b/") else parts[3]
skip_current = any(current_file == sf for sf in skipped_files) or any(
current_file.endswith(ext) for ext in skip_extensions
)
else:
skip_current = False
if not skip_current:
filtered_lines.append(line)
i += 1
original_len = len(diff_text)
diff_text = "\n".join(filtered_lines)
logger.info(f"Diff 过滤后: {original_len} -> {len(diff_text)} 字符 (减少 {original_len - len(diff_text)})")
# 4. 截断过大的 diff
diff_text, was_truncated = truncate_diff(diff_text, MAX_DIFF_CHARS)
if was_truncated:
logger.warning(f"Diff 过大,已截断至 {len(diff_text)} 字符")
# 5. 如果 diff 为空,直接跳过
if not diff_text.strip():
logger.info("Diff 为空,无需审查")
sys.exit(0)
# 6. 调用 LLM 审查
review_result = call_llm_for_review(
diff_text=diff_text,
pr_number=pr_number,
file_list=file_list,
llm_base_url=llm_base_url,
llm_api_key=llm_api_key,
llm_model=llm_model,
coze_bot_id=coze_bot_id,
)
if not review_result:
logger.error("LLM 审查失败")
sys.exit(1)
# 7. 加上审查时间和标识(便于识别是自动审查)
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
marker = "<!-- AI_CODE_REVIEW_AUTO_COMMENT -->"
full_comment = f"""{review_result}
---
<sub>🤖 由 AI 代码审查机器人自动生成 | {timestamp} | 模型: {llm_model}</sub>
{marker}
"""
# 8. 输出审查结果到日志
logger.info("=" * 60)
logger.info("审查结果:")
for line in review_result.split("\n")[:30]:
logger.info(line)
if len(review_result.split("\n")) > 30:
logger.info(f"... 共 {len(review_result.split(chr(10)))}")
logger.info("=" * 60)
# 9. 发布评论(先删除旧的审查评论,避免刷屏)
if args.dry_run:
logger.info("--dry-run 模式,跳过发布评论")
print(full_comment)
else:
# 去重:删除之前的 AI 审查评论
old_comments = gitea.get_existing_review_comments(pr_number, marker)
if old_comments:
logger.info(f"找到 {len(old_comments)} 条旧的 AI 审查评论,先删除")
for cid in old_comments:
gitea.delete_pr_comment(pr_number, cid)
# 发布新评论
success = gitea.post_pr_comment(pr_number, full_comment)
if not success:
logger.error("评论发布失败")
sys.exit(1)
# 10. 判断是否有严重问题(可选阻断)
# 目前只做建议,不阻断合并,始终返回 0
has_critical = "问题" in review_result and ("" in review_result or "需修改" in review_result)
if has_critical:
logger.warning("检测到需修改的问题,但当前配置为仅建议,不阻断合并")
logger.info("代码审查完成")
sys.exit(0)
except Exception as e:
logger.exception(f"审查脚本发生未预期的异常: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+208 -19
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# ===========================================
# Staging 部署脚本(SSH 模式)
# Staging 部署脚本(SSH 模式,支持自动回滚
# ===========================================
# 通过 SSH 在 staging 服务器上执行
#
@@ -12,9 +12,44 @@
# ENV_FILE - 环境变量文件路径
# GENERATED_DIR - 生成文件目录
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false
set -eu
# ---- 重试工具函数 ----
retry_cmd() {
local max_attempts=$1
local backoff=$2
shift 2
local attempt=1
while [ $attempt -le $max_attempts ]; do
if "$@"; then
return 0
fi
echo " attempt $attempt/$max_attempts failed, retrying in ${backoff}s..."
sleep $backoff
backoff=$((backoff * 2))
attempt=$((attempt + 1))
done
echo " ERROR: failed after $max_attempts retries"
return 1
}
retry_docker_login() {
echo "Logging in to registry (up to 3 retries)"
if retry_cmd 3 5 sh -c "printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin"; then
return 0
fi
echo "WARN: docker login failed after retries, will try pull anyway"
return 0
}
retry_docker_pull() {
local image=$1
echo "Pulling $image (up to 3 retries)"
retry_cmd 3 10 docker pull "$image"
}
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
@@ -25,6 +60,7 @@ GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
@@ -35,17 +71,165 @@ test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
echo "=========================================="
echo "==========================================="
echo " Staging 部署 - $IMAGE_TAG"
echo "=========================================="
echo "==========================================="
# ---- 记录当前运行的镜像版本(用于回滚) ----
echo "Recording current image versions for rollback..."
PREV_API_IMAGE=""
PREV_WORKER_IMAGE=""
PREV_WEB_IMAGE=""
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
if docker inspect "$c" >/dev/null 2>&1; then
img=$(docker inspect -f '{{.Config.Image}}' "$c")
case "$c" in
xiaoxia-api-staging) PREV_API_IMAGE="$img" ;;
xiaoxia-worker-staging) PREV_WORKER_IMAGE="$img" ;;
xiaoxia-web-staging) PREV_WEB_IMAGE="$img" ;;
esac
echo " $c -> $img"
else
echo " $c -> (not running)"
fi
done
# ---- 回滚函数 ----
rollback() {
echo ""
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo " 部署失败,正在自动回滚到上一版本..."
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo ""
if [ "$SKIP_ROLLBACK" = "true" ]; then
echo "SKIP_ROLLBACK=true,跳过自动回滚"
exit 1
fi
# 停止当前(失败的)新容器
echo "Stopping new containers..."
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# 恢复 API
if [ -n "$PREV_API_IMAGE" ]; then
echo "Rolling back API to: $PREV_API_IMAGE"
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=staging \
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$PREV_API_IMAGE"
else
echo "No previous API image to roll back to"
fi
# 恢复 Worker
if [ -n "$PREV_WORKER_IMAGE" ]; then
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$PREV_WORKER_IMAGE"
else
echo "No previous Worker image to roll back to"
fi
# 恢复 Web
if [ -n "$PREV_WEB_IMAGE" ]; then
echo "Rolling back Web to: $PREV_WEB_IMAGE"
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$PREV_WEB_IMAGE"
else
echo "No previous Web image to roll back to"
fi
# 等待 API 回滚后恢复健康
if [ -n "$PREV_API_IMAGE" ]; then
echo "Waiting for rolled-back API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "Rolled-back API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "WARN: Rolled-back API did not become healthy within 120s"
docker logs --tail 30 xiaoxia-api-staging
fi
fi
echo ""
echo "==========================================="
echo " 回滚完成"
echo "==========================================="
echo "Previous API: ${PREV_API_IMAGE:-none}"
echo "Previous Worker: ${PREV_WORKER_IMAGE:-none}"
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
echo ""
echo "部署失败,已自动回滚到上一版本"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
exit 1
}
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
echo "=========================================="
echo " Login to Registry (with retries)"
echo "=========================================="
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
retry_docker_login
fi
# ---- Pull 新版本镜像 ----
@@ -57,12 +241,12 @@ LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
echo "=========================================="
echo " Pull images (with retries)"
echo "=========================================="
retry_docker_pull "$REGISTRY_API"
retry_docker_pull "$REGISTRY_WORKER"
retry_docker_pull "$REGISTRY_WEB"
# Re-tag 成本地名
docker tag "$REGISTRY_API" "$LOCAL_API"
@@ -117,7 +301,12 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
"$LOCAL_API" sh -c "cd /app && alembic upgrade head" || {
echo "ERROR: Database migration failed"
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
echo "Please manually check and fix the migration, then redeploy"
exit 1
}
echo "Migrations completed."
else
echo "Skipping migrations (SKIP_MIGRATION=true)"
@@ -151,7 +340,7 @@ docker run -d \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
"$LOCAL_API" || rollback
# ---- 启动 Worker ----
echo "Starting Worker container..."
@@ -174,7 +363,7 @@ docker run -d \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
"$LOCAL_WORKER" || rollback
# ---- 启动 Web ----
LEGACY_VOLUME=""
@@ -197,7 +386,7 @@ docker run -d \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
"$LOCAL_WEB" || rollback
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
@@ -215,7 +404,7 @@ done
if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
docker logs --tail 50 xiaoxia-api-staging
exit 1
rollback
fi
# ---- 等待 Web 健康 ----
@@ -234,7 +423,7 @@ done
if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
docker logs --tail 30 xiaoxia-web-staging
exit 1
rollback
fi
# ---- 清理旧镜像 ----
+350
View File
@@ -0,0 +1,350 @@
"""
导出设置 API 单元测试
覆盖:
- GET /export-presets - 导出预设列表
- GET /{plan_id}/export - 获取导出配置
- PUT /{plan_id}/export - 更新导出配置
"""
from __future__ import annotations
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
# ---------------------------------------------------------------------------
# Stub Repository
# ---------------------------------------------------------------------------
class StubEditPlanRepository:
def __init__(self, plans: dict[str, EditPlan] | None = None):
self._plans = plans or {}
def get(self, plan_id: str) -> Optional[EditPlan]:
return self._plans.get(plan_id)
def create(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def list_all(self, *, status=None, skip=0, limit=50):
return list(self._plans.values())[skip : skip + limit]
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
def delete(self, plan_id: str) -> bool:
return self._plans.pop(plan_id, None) is not None
def count(self, *, status=None, template_id=None):
return len(self._plans)
class StubEditPlanClipRepository:
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
return []
def count(self, plan_id, *, status=None):
return 0
def get(self, clip_id: str):
return None
def create(self, clip):
return clip
def update(self, clip):
return clip
def delete(self, clip_id: str) -> bool:
return False
def delete_by_plan(self, plan_id: str) -> int:
return 0
# ---------------------------------------------------------------------------
# Test Fixtures
# ---------------------------------------------------------------------------
def _make_sample_plan(plan_id="plan-001"):
return EditPlan(
id=plan_id,
template_id="tpl-001",
name="测试计划",
status=EditPlanStatus.EDITING,
total_duration=30.0,
config=normalize_plan_config({}),
project_id="",
created_by_user_id="user-001",
created_at=datetime(2026, 7, 16, 10, 0, 0),
updated_at=datetime(2026, 7, 16, 10, 0, 0),
)
def _create_test_app():
import app.services.edit_plan_service as service_module
from app.api.routes.edit_plans import router
plan = _make_sample_plan()
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
stub_clip_repo = StubEditPlanClipRepository()
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
app = FastAPI()
app.include_router(router, prefix="/api/v1/edit-plans")
def _mock_auth():
mock = MagicMock()
mock.user.id = "user-001"
return mock
import app.api.routes._helpers as helpers_module
original_check = helpers_module.check_project_access
helpers_module.check_project_access = lambda *a, **kw: None
from app.api.routes import edit_plans as main_module
app.dependency_overrides[main_module.get_current_user] = _mock_auth
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
import app.api.routes.edit_plans_export as export_module
app.dependency_overrides[export_module.get_current_user] = _mock_auth
app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock()
app.dependency_overrides[export_module.get_project_repository] = lambda: MagicMock()
def cleanup():
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
helpers_module.check_project_access = original_check
return app, stub_plan_repo, cleanup
@pytest.fixture
def export_client():
app, plan_repo, cleanup = _create_test_app()
yield TestClient(app), plan_repo
cleanup()
# ---------------------------------------------------------------------------
# Export Presets 测试
# ---------------------------------------------------------------------------
class TestExportPresets:
def test_list_all_presets(self, export_client):
c, _ = export_client
resp = c.get("/api/v1/edit-plans/export-presets")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 5
assert len(data["items"]) == data["total"]
first = data["items"][0]
assert "id" in first
assert "name" in first
assert "resolution" in first
assert "fps" in first
assert "video_bitrate" in first
assert "format" in first
assert "description" in first
assert "size_hint" in first
def test_preset_has_valid_resolution(self, export_client):
c, _ = export_client
resp = c.get("/api/v1/edit-plans/export-presets")
data = resp.json()
for item in data["items"]:
assert "x" in item["resolution"]
assert item["fps"] >= 15
assert item["fps"] <= 60
assert item["format"] in ("mp4", "mov")
# ---------------------------------------------------------------------------
# GET /{plan_id}/export 测试
# ---------------------------------------------------------------------------
class TestGetExportConfig:
def test_default_export_config(self, export_client):
c, _ = export_client
resp = c.get("/api/v1/edit-plans/plan-001/export")
assert resp.status_code == 200
data = resp.json()
assert data["resolution"] == "1080x1920"
assert data["fps"] == 30
assert data["video_bitrate"] == 8000
assert data["audio_bitrate"] == 128
assert data["format"] == "mp4"
assert data["quality_preset"] == "balanced"
assert data["watermark_enabled"] is False
assert data["watermark_text"] == ""
def test_export_not_found(self, export_client):
c, _ = export_client
resp = c.get("/api/v1/edit-plans/plan-nonexist/export")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT /{plan_id}/export 测试
# ---------------------------------------------------------------------------
class TestUpdateExportConfig:
def test_update_resolution_and_fps(self, export_client):
c, plan_repo = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"resolution": "720x1280", "fps": 60},
)
assert resp.status_code == 200
data = resp.json()
assert data["resolution"] == "720x1280"
assert data["fps"] == 60
plan = plan_repo.get("plan-001")
assert plan.config["export"]["resolution"] == "720x1280"
assert plan.config["export"]["fps"] == 60
def test_update_bitrate(self, export_client):
c, plan_repo = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"video_bitrate": 12000, "audio_bitrate": 192},
)
assert resp.status_code == 200
data = resp.json()
assert data["video_bitrate"] == 12000
assert data["audio_bitrate"] == 192
def test_update_format(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"format": "mov"},
)
assert resp.status_code == 200
assert resp.json()["format"] == "mov"
def test_invalid_format(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"format": "avi"},
)
assert resp.status_code == 422
def test_update_quality_preset(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"quality_preset": "best"},
)
assert resp.status_code == 200
assert resp.json()["quality_preset"] == "best"
def test_invalid_quality_preset(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"quality_preset": "ultimate"},
)
assert resp.status_code == 422
def test_update_watermark(self, export_client):
c, plan_repo = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"watermark_enabled": True, "watermark_text": "我的视频"},
)
assert resp.status_code == 200
data = resp.json()
assert data["watermark_enabled"] is True
assert data["watermark_text"] == "我的视频"
plan = plan_repo.get("plan-001")
assert plan.config["export"]["watermark_enabled"] is True
assert plan.config["export"]["watermark_text"] == "我的视频"
def test_invalid_resolution_format(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"resolution": "1080*1920"},
)
assert resp.status_code == 422
def test_resolution_too_large(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"resolution": "8000x8000"},
)
assert resp.status_code == 422
def test_fps_out_of_range(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-001/export",
json={"fps": 120},
)
assert resp.status_code == 422
def test_export_not_found(self, export_client):
c, _ = export_client
resp = c.put(
"/api/v1/edit-plans/plan-nonexist/export",
json={"fps": 30},
)
assert resp.status_code == 404
def test_partial_update_preserves_other_fields(self, export_client):
c, _ = export_client
# 先修改一个
c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"})
# 再修改另一个
resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60})
data = resp.json()
# 分辨率应该保持
assert data["resolution"] == "720x1280"
# fps 更新了
assert data["fps"] == 60
# 其他默认值不变
assert data["format"] == "mp4"
assert data["video_bitrate"] == 8000
+476
View File
@@ -0,0 +1,476 @@
"""
滤镜调色 API 单元测试
覆盖:
- GET /filter-presets - 滤镜预设列表
- GET /{plan_id}/filter - 获取滤镜配置
- PUT /{plan_id}/filter - 更新滤镜配置
"""
from __future__ import annotations
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY, build_ffmpeg_filter
# ---------------------------------------------------------------------------
# Stub Repository
# ---------------------------------------------------------------------------
class StubEditPlanRepository:
def __init__(self, plans: dict[str, EditPlan] | None = None):
self._plans = plans or {}
def list_all(self, *, status=None, skip=0, limit=50):
items = list(self._plans.values())
if status is not None:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
items = [p for p in self._plans.values() if p.template_id == template_id]
if status is not None:
items = [p for p in items if p.status == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def get(self, plan_id: str) -> Optional[EditPlan]:
return self._plans.get(plan_id)
def create(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def delete(self, plan_id: str) -> bool:
if plan_id in self._plans:
del self._plans[plan_id]
return True
return False
def count(self, *, status=None, template_id=None):
items = list(self._plans.values())
if status is not None:
items = [p for p in items if p.status == status]
if template_id is not None:
items = [p for p in items if p.template_id == template_id]
return len(items)
class StubEditPlanClipRepository:
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
return []
def count(self, plan_id, *, status=None):
return 0
def get(self, clip_id: str):
return None
def create(self, clip):
return clip
def update(self, clip):
return clip
def delete(self, clip_id: str) -> bool:
return False
def delete_by_plan(self, plan_id: str) -> int:
return 0
# ---------------------------------------------------------------------------
# Test Fixtures
# ---------------------------------------------------------------------------
def _make_sample_plan(plan_id="plan-001", config=None):
if config is None:
config = normalize_plan_config({})
return EditPlan(
id=plan_id,
template_id="tpl-001",
name="测试计划",
status=EditPlanStatus.EDITING,
total_duration=30.0,
config=config,
project_id="",
created_by_user_id="user-001",
created_at=datetime(2026, 7, 16, 10, 0, 0),
updated_at=datetime(2026, 7, 16, 10, 0, 0),
)
def _create_test_app():
import app.api.routes.edit_plans_filter as filter_module
import app.services.edit_plan_service as service_module
from app.api.routes.edit_plans import router
plan = _make_sample_plan()
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
stub_clip_repo = StubEditPlanClipRepository()
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
app = FastAPI()
app.include_router(router, prefix="/api/v1/edit-plans")
# Mock 认证
def _mock_auth():
mock = MagicMock()
mock.user.id = "user-001"
return mock
# Mock 项目访问检查
import app.api.routes._helpers as helpers_module
original_check = helpers_module.check_project_access
helpers_module.check_project_access = lambda *a, **kw: None
# 主路由的依赖覆盖
from app.api.routes import edit_plans as main_module
app.dependency_overrides[main_module.get_current_user] = _mock_auth
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
# 滤镜路由的依赖覆盖
app.dependency_overrides[filter_module.get_current_user] = _mock_auth
app.dependency_overrides[filter_module.get_db_session] = lambda: MagicMock()
app.dependency_overrides[filter_module.get_project_repository] = lambda: MagicMock()
def cleanup():
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
helpers_module.check_project_access = original_check
return app, stub_plan_repo, cleanup
@pytest.fixture
def filter_client():
app, plan_repo, cleanup = _create_test_app()
yield TestClient(app), plan_repo
cleanup()
# ---------------------------------------------------------------------------
# Filter Presets 测试
# ---------------------------------------------------------------------------
class TestFilterPresets:
def test_list_all_presets(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == len(FILTER_PRESET_LIBRARY)
assert data["total"] > 10
assert len(data["items"]) == data["total"]
# 验证字段
first = data["items"][0]
assert "id" in first
assert "name" in first
assert "category" in first
assert "description" in first
assert "tags" in first
def test_filter_by_category_basic(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets?category=basic")
assert resp.status_code == 200
data = resp.json()
assert data["total"] > 0
for item in data["items"]:
assert item["category"] == "basic"
def test_filter_by_category_bw(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets?category=bw")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 3
for item in data["items"]:
assert item["category"] == "bw"
def test_filter_by_keyword(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=电影")
assert resp.status_code == 200
data = resp.json()
assert data["total"] > 0
# 至少包含电影感滤镜
names = [item["name"] for item in data["items"]]
assert any("电影" in n for n in names)
def test_filter_by_keyword_japanese(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=日系")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert data["items"][0]["name"] == "日系"
def test_filter_empty_result(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=不存在的滤镜")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["items"] == []
def test_filter_invalid_category(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/filter-presets?category=nonexistent")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
# ---------------------------------------------------------------------------
# GET /{plan_id}/filter 测试
# ---------------------------------------------------------------------------
class TestGetFilter:
def test_get_default_filter(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/plan-001/filter")
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is False
assert data["preset_id"] == "filter_none"
assert data["intensity"] == 100
assert data["brightness"] == 0.0
assert data["contrast"] == 1.0
assert data["saturation"] == 1.0
assert data["warmth"] == 0.0
def test_get_filter_not_found(self, filter_client):
c, _ = filter_client
resp = c.get("/api/v1/edit-plans/plan-nonexist/filter")
assert resp.status_code == 404
def test_get_filter_with_custom_config(self, filter_client):
c, plan_repo = filter_client
plan = plan_repo.get("plan-001")
new_config = dict(plan.config)
new_config["filter"] = {
"enabled": True,
"preset_id": "filter_cinematic",
"intensity": 80,
"brightness": 0.1,
"contrast": 1.2,
"saturation": 0.9,
"warmth": 0.3,
}
plan.config = new_config
plan_repo.update(plan)
resp = c.get("/api/v1/edit-plans/plan-001/filter")
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["preset_id"] == "filter_cinematic"
assert data["intensity"] == 80
assert data["brightness"] == 0.1
# ---------------------------------------------------------------------------
# PUT /{plan_id}/filter 测试
# ---------------------------------------------------------------------------
class TestUpdateFilter:
def test_enable_filter(self, filter_client):
c, plan_repo = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"enabled": True, "preset_id": "filter_cinematic"},
)
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["preset_id"] == "filter_cinematic"
plan = plan_repo.get("plan-001")
assert plan.config["filter"]["enabled"] is True
assert plan.config["filter"]["preset_id"] == "filter_cinematic"
def test_adjust_intensity(self, filter_client):
c, plan_repo = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"enabled": True, "preset_id": "filter_cinematic", "intensity": 50},
)
assert resp.status_code == 200
data = resp.json()
assert data["intensity"] == 50
plan = plan_repo.get("plan-001")
assert plan.config["filter"]["intensity"] == 50
def test_invalid_intensity_returns_422(self, filter_client):
c, _ = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"intensity": 150},
)
assert resp.status_code == 422
def test_invalid_preset_returns_400(self, filter_client):
c, _ = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"preset_id": "nonexistent_filter"},
)
assert resp.status_code == 400
assert "无效的滤镜预设" in resp.json()["detail"]
def test_filter_not_found(self, filter_client):
c, _ = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-nonexist/filter",
json={"enabled": True},
)
assert resp.status_code == 404
def test_set_none_preset_disables_filter(self, filter_client):
c, plan_repo = filter_client
# 先启用一个滤镜
c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"enabled": True, "preset_id": "filter_cinematic"},
)
# 再设为原图
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"preset_id": "filter_none"},
)
assert resp.status_code == 200
data = resp.json()
assert data["preset_id"] == "filter_none"
assert data["enabled"] is False # 原图自动关闭
def test_partial_update(self, filter_client):
c, plan_repo = filter_client
# 先设置完整配置
c.put(
"/api/v1/edit-plans/plan-001/filter",
json={
"enabled": True,
"preset_id": "filter_warm",
"intensity": 70,
"brightness": 0.05,
},
)
# 只修改强度,其他保持不变
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"intensity": 90},
)
assert resp.status_code == 200
data = resp.json()
assert data["intensity"] == 90
assert data["preset_id"] == "filter_warm" # 保持不变
assert data["enabled"] is True # 保持不变
assert data["brightness"] == 0.05 # 保持不变
def test_custom_adjustments(self, filter_client):
c, _ = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={
"enabled": True,
"preset_id": "filter_cinematic",
"brightness": 0.1,
"contrast": 1.3,
"saturation": 1.2,
"warmth": 0.2,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["brightness"] == 0.1
assert data["contrast"] == 1.3
assert data["saturation"] == 1.2
assert data["warmth"] == 0.2
def test_invalid_brightness_returns_422(self, filter_client):
c, _ = filter_client
resp = c.put(
"/api/v1/edit-plans/plan-001/filter",
json={"brightness": 2.0},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# FFmpeg 滤镜生成测试
# ---------------------------------------------------------------------------
class TestBuildFFmpegFilter:
def test_no_filter(self):
assert build_ffmpeg_filter("filter_none", 100) == ""
def test_zero_intensity(self):
assert build_ffmpeg_filter("filter_cinematic", 0) == ""
def test_invalid_preset(self):
assert build_ffmpeg_filter("nonexistent", 100) == ""
def test_cinematic_full(self):
result = build_ffmpeg_filter("filter_cinematic", 100)
assert result.startswith("eq=")
assert "contrast=" in result
assert "saturation=" in result
assert "gamma_r=" in result
def test_cinematic_half(self):
full = build_ffmpeg_filter("filter_cinematic", 100)
half = build_ffmpeg_filter("filter_cinematic", 50)
assert full != half
# 50% 强度的参数应该更接近原值
assert "eq=" in half
def test_bw_filter(self):
result = build_ffmpeg_filter("filter_bw", 100)
assert "saturation=0" in result
def test_warm_filter(self):
result = build_ffmpeg_filter("filter_warm", 100)
assert "gamma_r=" in result
assert "gamma_b=" in result
+252
View File
@@ -727,3 +727,255 @@ class TestResumeEditingAndRegenerate:
p = EditPlan.create("tpl-001", "测试")
with pytest.raises(ValueError):
p.resume_editing()
# ===========================================================================
# 片段分割与合并测试
# ===========================================================================
class TestClipSplit:
"""片段分割测试"""
def test_split_basic(self):
"""基础分割:10秒片段在第3秒处分割"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip = svc.create_clip(p.id, "main", 0, duration=10.0, text_content="测试文案")
result = svc.split_clip(clip.id, 3.0)
assert result["left_clip"].duration == 3.0
assert result["left_clip"].order == 0
assert result["right_clip"].duration == 7.0
assert result["right_clip"].order == 1
assert result["right_clip"].clip_type == "main"
assert result["right_clip"].text_content == "测试文案"
# 总片段数 = 2
assert svc.count_clips(p.id) == 2
def test_split_preserves_clip_properties(self):
"""分割后属性继承正确"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip = svc.create_clip(
p.id,
"intro",
0,
duration=10.0,
transition_effect="fade",
playback_speed=1.5,
config={"filter": "vivid"},
)
result = svc.split_clip(clip.id, 5.0)
right = result["right_clip"]
assert right.clip_type == "intro"
assert right.transition_effect == "fade"
assert right.playback_speed == 1.5
assert right.config.get("filter") == "vivid"
def test_split_shifts_following_clips(self):
"""分割后,后面的片段 order 自动 +1"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
svc.split_clip(clip1.id, 2.0)
# clip0: order 0
# clip1(left): order 1
# new right: order 2
# clip2: order 3
clips = svc.list_clips(p.id)
order_map = {c.id: c.order for c in clips}
assert order_map[clip0.id] == 0
assert order_map[clip1.id] == 1
assert order_map[clip2.id] == 3
assert len(clips) == 4
def test_split_at_boundary_raises(self):
"""分割点为0或等于时长时,报错"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
with pytest.raises(ValueError, match="分割时间"):
svc.split_clip(clip.id, 0.0)
with pytest.raises(ValueError, match="分割时间"):
svc.split_clip(clip.id, 10.0)
def test_split_negative_time_raises(self):
"""负数分割点报错"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
with pytest.raises(ValueError, match="分割时间"):
svc.split_clip(clip.id, -1.0)
def test_split_nonexistent_clip_raises(self):
"""不存在的片段报错"""
svc = _make_service()
with pytest.raises(ValueError, match="片段不存在"):
svc.split_clip("nonexistent", 5.0)
def test_split_with_asset_adds_trim_info(self):
"""有素材的片段分割后,添加trim_start/trim_end"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001")
result = svc.split_clip(clip.id, 3.0)
left = result["left_clip"]
right = result["right_clip"]
# 左半部分有 trim_end
assert left.config.get("trim_end") == 7.0
# 右半部分有 trim_start
assert right.config.get("trim_start") == 3.0
# 右半部分也关联同一个素材
assert right.asset_id == "asset-001"
class TestClipMerge:
"""片段合并测试"""
def test_merge_two_clips(self):
"""基础合并:两个5秒片段合并为10秒"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段")
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段")
merged = svc.merge_clips([clip1.id, clip2.id])
assert merged.duration == 10.0
assert merged.order == 0
assert merged.clip_type == "main"
assert "第一段" in merged.text_content
assert "第二段" in merged.text_content
# 总片段数 = 1
assert svc.count_clips(p.id) == 1
def test_merge_shifts_following_clips(self):
"""合并后,后面的片段 order 前移"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
clip3 = svc.create_clip(p.id, "main", 3, duration=5.0)
svc.merge_clips([clip1.id, clip2.id])
clips = svc.list_clips(p.id)
order_map = {c.id: c.order for c in clips}
assert order_map[clip0.id] == 0
assert order_map[clip3.id] == 2 # 原来order=3,前移1位=2
assert len(clips) == 3
def test_merge_three_clips(self):
"""合并3个片段"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clips = []
for i in range(3):
c = svc.create_clip(p.id, "main", i, duration=3.0)
clips.append(c)
merged = svc.merge_clips([c.id for c in clips])
assert merged.duration == 9.0
assert svc.count_clips(p.id) == 1
def test_merge_different_types_raises(self):
"""不同类型片段不能合并"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0)
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0)
with pytest.raises(ValueError, match="相同类型"):
svc.merge_clips([clip1.id, clip2.id])
def test_merge_non_contiguous_raises(self):
"""不连续的片段不能合并"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
svc.create_clip(p.id, "main", 1, duration=5.0)
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
with pytest.raises(ValueError, match="不连续"):
svc.merge_clips([clip0.id, clip2.id])
def test_merge_single_clip_raises(self):
"""单个片段不能合并"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip = svc.create_clip(p.id, "main", 0, duration=5.0)
with pytest.raises(ValueError, match="至少需要 2 个"):
svc.merge_clips([clip.id])
def test_merge_different_plans_raises(self):
"""不同计划的片段不能合并"""
svc = _make_service()
p1 = svc.create_plan("tpl-001", "计划1")
p2 = svc.create_plan("tpl-001", "计划2")
svc.transition_status(p1.id, EditPlanStatus.EDITING)
svc.transition_status(p2.id, EditPlanStatus.EDITING)
clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0)
clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0)
with pytest.raises(ValueError, match="同一计划"):
svc.merge_clips([clip1.id, clip2.id])
def test_merge_clears_trim_fields(self):
"""合并后清理trim字段"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"})
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0})
merged = svc.merge_clips([clip1.id, clip2.id])
assert "trim_start" not in merged.config
assert "trim_end" not in merged.config
# 非 trim 字段保留(后面的覆盖前面的)
assert merged.config.get("filter") == "vivid"
def test_split_then_merge_recovers(self):
"""分割后再合并,时长基本恢复(浮点精度内)"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试计划")
svc.transition_status(p.id, EditPlanStatus.EDITING)
original = svc.create_clip(p.id, "main", 0, duration=10.0)
result = svc.split_clip(original.id, 3.5)
left = result["left_clip"]
right = result["right_clip"]
merged = svc.merge_clips([left.id, right.id])
assert abs(merged.duration - 10.0) < 0.001
assert svc.count_clips(p.id) == 1
+214
View File
@@ -507,3 +507,217 @@ class TestDeletePlan:
resp = c.delete("/api/v1/edit-plans/nonexistent")
assert resp.status_code == 404
assert "剪辑计划不存在" in resp.json()["detail"]
# ---------------------------------------------------------------------------
# BGM 配置测试
# ---------------------------------------------------------------------------
class TestBGMConfig:
"""BGM 配置 API 测试"""
def test_get_bgm_default_empty(self, client):
"""新计划 BGM 默认为空"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
repo.create(plan)
resp = c.get(f"/api/v1/edit-plans/{plan.id}/bgm")
assert resp.status_code == 200
data = resp.json()
assert data["plan_id"] == plan.id
assert data["bgm"] == {}
def test_update_bgm_volume(self, client):
"""更新 BGM 音量"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
repo.create(plan)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}/bgm",
json={"volume": 0.5, "fade_in": 2.0, "fade_out": 3.0},
)
assert resp.status_code == 200
data = resp.json()
assert data["bgm"]["volume"] == 0.5
assert data["bgm"]["fade_in"] == 2.0
assert data["bgm"]["fade_out"] == 3.0
def test_enable_bgm_with_preset(self, client):
"""启用 BGM 并指定 preset_id"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
repo.create(plan)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}/bgm",
json={
"enabled": True,
"source": "library",
"preset_id": "bgm_upbeat_001",
"volume": 0.3,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["bgm"]["enabled"] is True
assert data["bgm"]["preset_id"] == "bgm_upbeat_001"
def test_enable_bgm_without_source_returns_400(self, client):
"""启用 BGM 但不指定来源,返回 400"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
repo.create(plan)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}/bgm",
json={"enabled": True, "volume": 0.3},
)
assert resp.status_code == 400
assert "素材来源" in resp.json()["detail"]
def test_enable_bgm_with_asset_id(self, client):
"""启用 BGM 并指定 asset_id"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
repo.create(plan)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}/bgm",
json={
"enabled": True,
"source": "upload",
"asset_id": "asset-audio-001",
"loop_enabled": True,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["bgm"]["enabled"] is True
assert data["bgm"]["asset_id"] == "asset-audio-001"
assert data["bgm"]["loop_enabled"] is True
def test_update_bgm_not_found(self, client):
"""不存在的计划返回 404"""
c, _ = client
resp = c.put(
"/api/v1/edit-plans/nonexistent/bgm",
json={"volume": 0.5},
)
assert resp.status_code == 404
def test_get_bgm_not_found(self, client):
"""不存在的计划返回 404"""
c, _ = client
resp = c.get("/api/v1/edit-plans/nonexistent/bgm")
assert resp.status_code == 404
def test_partial_update_preserves_existing(self, client):
"""部分更新保留原有配置"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
plan.config = {"bgm": {"volume": 0.5, "fade_in": 1.0}}
repo.create(plan)
# 只改音量
resp = c.put(
f"/api/v1/edit-plans/{plan.id}/bgm",
json={"volume": 0.8},
)
assert resp.status_code == 200
data = resp.json()
assert data["bgm"]["volume"] == 0.8
assert data["bgm"]["fade_in"] == 1.0 # 保留
def test_sidechain_config(self, client):
"""人声闪避配置更新"""
c, repo = client
plan = EditPlan.create("tpl-001", "测试")
repo.create(plan)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}/bgm",
json={
"enabled": True,
"preset_id": "bgm_relax_001",
"sidechain_enabled": True,
"sidechain_ratio": 0.4,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["bgm"]["sidechain_enabled"] is True
assert data["bgm"]["sidechain_ratio"] == 0.4
# ---------------------------------------------------------------------------
# BGM 预设库测试
# ---------------------------------------------------------------------------
class TestBGMPresets:
"""BGM 预设列表 API 测试"""
def test_list_all_presets(self, client):
"""获取所有预设 BGM"""
c, _ = client
resp = c.get("/api/v1/edit-plans/bgm/presets")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "styles" in data
assert data["total"] >= 10 # 至少有 10 首预设
assert len(data["items"]) == data["total"]
def test_filter_by_style(self, client):
"""按风格筛选"""
c, _ = client
resp = c.get("/api/v1/edit-plans/bgm/presets?style=upbeat")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 3
for item in data["items"]:
assert item["style"] == "upbeat"
def test_search_by_keyword(self, client):
"""关键词搜索"""
c, _ = client
resp = c.get("/api/v1/edit-plans/bgm/presets?keyword=钢琴")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
for item in data["items"]:
has_piano = (
"钢琴" in item["name"] or "钢琴" in item["description"] or any("钢琴" in tag for tag in item["tags"])
)
assert has_piano
def test_pagination(self, client):
"""分页功能"""
c, _ = client
resp = c.get("/api/v1/edit-plans/bgm/presets?skip=0&limit=3")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 3
assert data["skip"] == 0
assert data["limit"] == 3
def test_preset_structure(self, client):
"""预设条目字段完整"""
c, _ = client
resp = c.get("/api/v1/edit-plans/bgm/presets?limit=1")
assert resp.status_code == 200
item = resp.json()["items"][0]
assert "id" in item
assert "name" in item
assert "style" in item
assert "style_label" in item
assert "duration" in item
assert "artist" in item
assert "description" in item
assert "tags" in item
assert isinstance(item["tags"], list)