Compare commits

...

16 Commits

Author SHA1 Message Date
saas-backend-agent dc5165bd5f fix(backend): #1896 修复字体映射补充开源字体文件
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 11s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m19s
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 10m8s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 10m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 12m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m23s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 33m11s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 20s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m44s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 41h52m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 42h23m34s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 42h23m50s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 42h26m4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 42h26m11s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 42h25m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 42h25m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 42h25m41s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 42h25m42s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 42h25m44s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 41h52m23s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 41h52m23s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 41h52m24s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 41h52m17s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 42h23m4s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 42h23m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 42h23m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 42h23m31s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 42h23m31s
问题:DRAWTEXT_FONT_MAP把所有前端字体(思源宋体/苹方/微软雅黑/楷体/华康俪金黑)全部映射到 NotoSansSC,导致无论选什么字体成片都渲染为思源黑体;FONT_NAME_MAP 将楷体错误映射到 Noto Serif CJK SC(宋体)。

修复:
- packages/domain/video_filter_builder.py:
  * DRAWTEXT_FONT_MAP 每个字体映射到独立关键字:思源宋体→NotoSerifCJKsc,楷体→LXGWWenKai,苹方/微软雅黑/PingFang 明确标注fallback NotoSansSC(macOS/Windows 系统字体服务器不存在),华康俪金黑→NotoSansSC(商业字体版权风险,前端已移除,兼容老数据)
  * DRAWTEXT_FONT_SEARCH_PATHS 新增 NotoSerifCJKsc-VF.otf 和 LXGWWenKai-Regular.ttf 路径
- packages/domain/ass_subtitle_builder.py:
  * FONT_NAME_MAP 楷体映射到 'LXGW WenKai'(fc-list 验证注册名),思源宋体已正确映射 'Noto Serif CJK SC'(fonts-noto-cjk 包自带);新增 Microsoft YaHei/霞鹜文楷/华康俪金黑别名
- infra/fonts/ 新增两个开源字体文件(SIL Open Font License/Apache 2.0 免费可商用):
  * NotoSerifCJKsc-VF.otf(53MB,思源宋体可变字体,含多字重)
  * LXGWWenKai-Regular.ttf(25MB,霞鹜文楷开源楷体,v1.522)
- infra/docker/worker-base.Dockerfile + api-base.Dockerfile:
  * 保留 Serif .ttc(宋体fallback),仅删除 Sans .ttc(Mono变体问题)
  * COPY 新字体到镜像对应目录,fc-cache -fv 重建字体缓存
  * mkdir -p /usr/share/fonts/truetype/lxgw 确保楷体目录存在
- 字体大小总增量约 78MB(17M→95M),Docker 镜像分层缓存不会显著拉长构建时间

验证:
- 视频合成/ASS字幕/字幕滤镜三条渲染路径字体映射均独立正确
- fc-list 在本地实测可正确识别 'LXGW WenKai' / 'Noto Serif CJK SC' 注册名
- 苹方/微软雅黑等系统字体通过 NotoSansSC fallback 正常显示中文
- 存量数据(老数据选华康俪金黑/苹方等)继续渲染不中断
- 899 个字幕/滤镜/合成相关单测全通过,190 个writeback/filter/compose单测全通过
2026-09-14 20:54:26 +08:00
xiaoxia 937f751f68 Merge pull request 'fix(backend): #1901 统一title_config字段名修复渲染不一致' (#1909) from fix/1901-title-config-unify into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m42s
CI/CD Pipeline / Integration Tests (push) Successful in 8m40s
CI/CD Pipeline / Validate - Style (push) Successful in 9m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 11m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 10m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI/CD Pipeline / Unit Tests (push) Successful in 14m57s
CI/CD Pipeline / Validate - Security (push) Successful in 29m26s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 42h36m49s
CI/CD Pipeline / Build Production Web Image (push) Failing after 42h36m54s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 43h5m49s
CI/CD Pipeline / PR Build API Image (push) Failing after 43h6m25s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 43h5m56s
CI/CD Pipeline / PR Build Web Image (push) Failing after 43h5m56s
CI/CD Pipeline / Build Production API Image (push) Failing after 42h36m25s
CI/CD Pipeline / CI Gate (push) Failing after 42h36m25s
CI/CD Pipeline / Frontend Lint (push) Failing after 43h5m51s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 43h5m57s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 42h36m25s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 43h5m20s
CI/CD Pipeline / Canary Release to Production (push) Failing after 42h36m20s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 43h5m20s
2026-09-14 20:17:19 +08:00
xiaoxia afcfcf45ee fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射 (#1908)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14s
CI/CD Pipeline / Build Staging API Image (push) Successful in 46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m50s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 43h10m14s
CI/CD Pipeline / PR Build Web Image (push) Failing after 43h11m24s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 43h10m56s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 43h9m45s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 43h10m55s
CI/CD Pipeline / PR Build API Image (push) Failing after 43h10m55s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 43h9m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 43h10m50s
fix(frontend): #1896 删除侵权华康字体修复PreviewPlayer字体映射

问题:
1. "华康俪金黑"@font-face字体文件缺失(404)且有版权风险
2. PreviewPlayer直接用中文font_preset/font作为fontFamily,浏览器不识别

修改:
- 删除"华康俪金黑"字体选项(FONT_OPTIONS+FONT_FAMILY_MAP)和@font-face声明
- 统一 editing-planner/constants/clipProperties.ts 和 subtitleStyle.ts 从 generate/constants 重新导出 FONT_OPTIONS,避免多处维护
- PreviewPlayer.tsx 标题/字幕 fontFamily 通过 getFontFamily() 映射为浏览器可识别字体栈
- FrontendPreviewPlayer.tsx 标题 fontFamily 同样通过 getFontFamily() 映射
- TitleStylePanel/PanelTitleConfig/SubtitleStylePanel 等组件因复用统一常量自动生效
- 最终保留5个字体:思源黑体、思源宋体、苹方、微软雅黑、楷体
- PingFang 别名保留在 FONT_FAMILY_MAP 中以兼容旧数据,但不在 UI 选项中显示

Refs #1896
2026-09-14 20:12:21 +08:00
CI Bot ffb40038d3 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m33s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13m46s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 43h19m12s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h43m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h44m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h43m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h43m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h44m21s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 43h18m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h43m52s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h43m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h43m52s
2026-09-14 11:38:53 +00:00
saas-backend-agent 30094bc591 fix(backend): #1901 统一title字段名修复标题渲染不一致
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m12s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 8m19s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h50m31s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h50m37s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h51m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h50m21s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h49m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h50m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h50m3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h51m28s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h52m50s
问题:plan.config中标题配置字段名双轨不一致——Worker sync_configs_to_plan 写 plan.config['title'](归一化 size/font/color),API writeback_edit_plan_config 写 plan.config['title_config'](不归一化),三个渲染引擎读取不一致:
- video_compose_service(FFmpeg drawtext):已读 title+fallback title_config 
- unified_render_service(ASS字幕):只 title 无 fallback 
- subtitle_render_engine(字幕渲染):只读 title_config 

修复:
- packages/application/generation_common.py writeback_edit_plan_config:统一写 plan.config['title'](而非 title_config),并做字段名归一化 font_size→size/font_preset→font/font_color→color,与 Worker sync_configs_to_plan 行为一致
- apps/worker/video_processing/subtitle_render_engine.py:_resolve_title_config 先读 plan.config['title'],fallback plan.config['title_config'](兼容存量老数据)
- apps/worker/video_processing/unified_render_service.py:同样先读 'title',text 为空时 fallback 'title_config'
- apps/api/app/api/routes/generation_cover.py:封面E2标题配置读取加 title_config fallback
- 单测适配:test_generation_common.py/test_writeback_edit_plan_config.py 断言改 'title' 并验证归一化
- 所有渲染路径(智能剪辑/AI数字人/封面E2)统一读 'title',老数据通过 fallback 继续渲染
2026-09-14 19:27:29 +08:00
xiaoxia 939a49d1d4 fix(frontend): #1897 声音克隆页增加情绪语速设置 (#1906)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m32s
CI/CD Pipeline / Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Validate - Style (push) Successful in 3m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m45s
CI/CD Pipeline / Validate - Security (push) Successful in 6m12s
CI/CD Pipeline / Unit Tests (push) Successful in 8m16s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 37m7s
CI/CD Pipeline / CI Gate (push) Failing after 44h4m26s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 44h12m10s
CI/CD Pipeline / Frontend Lint (push) Failing after 44h12m40s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 44h12m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 44h12m16s
CI/CD Pipeline / PR Build API Image (push) Failing after 44h12m16s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h3m57s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h3m57s
CI/CD Pipeline / Canary Release to Production (push) Failing after 43h33m50s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h3m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 44h11m41s
CI/CD Pipeline / Deploy Production (push) Failing after 44h3m55s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 44h11m41s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 44h12m17s
fix(frontend): #1897 声音克隆页增加情绪语速设置

- getVoiceClonePreview 增加 speed/emotion 参数透传
- 新增 VoiceClonePreviewPanel:音色选择/试听文本/SpeedControl/EmotionControl
- 复用 TTS SpeedControl 和 EmotionControl 组件
- 声音克隆页在无加载且有就绪音色时展示试听面板

Refs #1897
2026-09-14 19:10:59 +08:00
xiaoxia 8ad44ad045 fix(backend): #1897 声音克隆preview接口支持情绪语速参数 (#1907)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m39s
CI/CD Pipeline / Validate - Style (push) Successful in 3m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m53s
CI/CD Pipeline / Validate - Security (push) Successful in 6m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m30s
CI/CD Pipeline / Unit Tests (push) Successful in 8m12s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 44h18m37s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h18m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 44h26m51s
CI/CD Pipeline / PR Build API Image (push) Failing after 44h26m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 44h26m23s
CI/CD Pipeline / PR Build Web Image (push) Failing after 44h26m23s
CI/CD Pipeline / Deploy Production (push) Failing after 44h18m4s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h18m8s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h18m8s
CI/CD Pipeline / Canary Release to Production (push) Failing after 44h18m4s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 44h24m15s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 44h24m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 44h26m25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 44h24m15s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:56:52 +08:00
xiaoxia 26f3abab72 fix(backend): #1892 画面插入按句号分句 (#1905)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Style (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 8m48s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h58m30s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h6m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h8m6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h7m38s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h7m39s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h5m53s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h5m53s
CI/CD Pipeline / CI Gate (push) Failing after 44h58m2s
CI/CD Pipeline / Deploy Production (push) Failing after 44h57m58s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h58m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h7m40s
CI/CD Pipeline / Canary Release to Production (push) Failing after 44h57m58s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h58m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h7m34s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:15:38 +08:00
xiaoxia 4f377d4fd3 fix(frontend): #1898 配音库AI配音增加情绪语言选择 (#1904)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m6s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m3s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 10m22s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h15m46s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h17m31s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h17m8s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h18m15s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h15m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h15m19s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h16m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h17m41s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 18:05:01 +08:00
xiaoxia 35b18e5e16 docs: #1895 会员+积分替代订阅制方案设计 (#1903)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m45s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Validate - Style (push) Successful in 4m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
CI/CD Pipeline / Unit Tests (push) Successful in 10m43s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h29m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h29m47s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h29m19s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h29m19s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h28m43s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h28m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h29m14s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h29m19s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 17:53:57 +08:00
xiaoxia e9dd33e2f6 Merge pull request 'fix(frontend): #1892 画面插入按句号分句' (#1902) from fix/1892-sentence-split-no-comma into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m15s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m17s
CI/CD Pipeline / Integration Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Style (push) Successful in 5m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m38s
CI/CD Pipeline / Validate - Security (push) Successful in 10m41s
CI/CD Pipeline / Unit Tests (push) Successful in 12m47s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 45h30m18s
CI/CD Pipeline / Build Production Web Image (push) Failing after 45h30m20s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h39m1s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h41m26s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h40m59s
CI/CD Pipeline / Build Production API Image (push) Failing after 45h29m52s
CI/CD Pipeline / CI Gate (push) Failing after 45h29m51s
CI/CD Pipeline / Canary Release to Production (push) Failing after 45h29m50s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 45h29m51s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h40m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h43m15s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h38m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h38m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h41m4s
2026-09-14 17:39:59 +08:00
saas-frontend-agent 7f18b1b35a fix(frontend): #1892 画面插入按句号分句,去掉逗号分句
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 44s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m35s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 10s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 45h45m11s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 45h57m44s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 45h57m46s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 45h57m46s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 45h57m15s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 45h57m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 45h57m17s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 45h57m18s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 45h57m19s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 45h57m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 45h44m45s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 45h57m22s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 45h44m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 45h44m45s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 45h44m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 45h57m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 45h57m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 45h57m13s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 45h57m19s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 46h32m35s
2026-09-14 17:25:36 +08:00
xiaoxia 880a7106d4 fix(ci): staging deploy SSH host to internal IP for Docker runner (#1891)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m36s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m9s
CI/CD Pipeline / Integration Tests (push) Successful in 3m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Validate - Style (push) Successful in 4m23s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m9s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
CI/CD Pipeline / Unit Tests (push) Successful in 9m23s
CI/CD Pipeline / Validate - Security (push) Successful in 12m33s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 47h52m31s
CI/CD Pipeline / Build Production API Image (push) Failing after 47h52m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 48h2m27s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 48h4m44s
CI/CD Pipeline / PR Build Web Image (push) Failing after 48h4m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 48h4m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 48h2m0s
CI/CD Pipeline / CI Gate (push) Failing after 47h52m5s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 47h52m5s
CI/CD Pipeline / Canary Release to Production (push) Failing after 47h52m4s
CI/CD Pipeline / Build Production Web Image (push) Failing after 47h52m6s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 48h2m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 48h4m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h4m40s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 15:18:31 +08:00
xiaoxia d478a9ffbc fix(ci): push :dev tag for watchtower + fix staging deploy host to 116 (#1889)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 27s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 56s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m31s
CI/CD Pipeline / Validate - Style (push) Successful in 4m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m23s
CI/CD Pipeline / Unit Tests (push) Successful in 9m50s
CI/CD Pipeline / Validate - Security (push) Successful in 13m31s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 48h27m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 48h38m53s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 48h40m31s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 48h39m25s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 48h39m25s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 48h40m30s
CI/CD Pipeline / PR Build Web Image (push) Failing after 48h40m30s
CI/CD Pipeline / Build Production Web Image (push) Failing after 48h26m55s
CI/CD Pipeline / Build Production API Image (push) Failing after 48h26m55s
CI/CD Pipeline / CI Gate (push) Failing after 48h26m54s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 48h38m25s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 48h26m54s
CI/CD Pipeline / Canary Release to Production (push) Failing after 48h26m52s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 48h38m25s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 48h39m24s
CI/CD Pipeline / PR Build API Image (push) Failing after 48h40m30s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h40m25s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 14:42:46 +08:00
xiaoxia 628fc6c4e6 fix(ci): push :dev tag to registry for watchtower auto-update (#1888)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 56s
CI/CD Pipeline / Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Validate - Security (push) Successful in 10m34s
CI/CD Pipeline / Unit Tests (push) Successful in 12m4s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 48h45m22s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 48h55m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 48h57m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 48h56m9s
CI/CD Pipeline / PR Build Web Image (push) Failing after 48h57m12s
CI/CD Pipeline / PR Build API Image (push) Failing after 48h57m13s
CI/CD Pipeline / Deploy Production (push) Failing after 48h44m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 48h44m55s
CI/CD Pipeline / CI Gate (push) Failing after 48h44m55s
CI/CD Pipeline / Canary Release to Production (push) Failing after 48h44m51s
CI/CD Pipeline / Build Production API Image (push) Failing after 48h44m55s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 48h55m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 48h55m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 48h56m9s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 48h56m9s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h57m7s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 48h57m14s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 14:26:05 +08:00
xiaoxia f651422582 fix(deploy): 修复生产部署脚本502——worker/web容器启动失败 (#1887)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m16s
CI/CD Pipeline / Validate - Security (push) Successful in 8m17s
CI/CD Pipeline / Unit Tests (push) Successful in 8m42s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m38s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 18m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 5m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m1s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 51h35m15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 51h44m59s
CI/CD Pipeline / PR Build Web Image (push) Failing after 51h53m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 51h53m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 51h53m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 51h53m20s
CI/CD Pipeline / Deploy Production (push) Failing after 51h44m31s
CI/CD Pipeline / Build Production Web Image (push) Failing after 51h44m33s
CI/CD Pipeline / Build Production API Image (push) Failing after 51h44m33s
CI/CD Pipeline / CI Gate (push) Failing after 51h44m33s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 51h34m50s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 51h34m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 51h26m28s
CI/CD Pipeline / Frontend Lint (push) Failing after 51h53m16s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-14 11:29:58 +08:00
46 changed files with 1964 additions and 155 deletions
+17 -5
View File
@@ -1022,12 +1022,22 @@ jobs:
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
# develop 分支额外推送 :dev tagWatchtower 监听的滚动更新 tag
if [ "${GITHUB_REF_NAME}" = "develop" ]; then
DEV_TAG="${IMAGE_FULL}:dev"
EXTRA_TAGS="$BRANCH_TAG $DEV_TAG"
PUSHED_TAGS_SUMMARY="${BRANCH_TAG} + ${DEV_TAG}"
else
EXTRA_TAGS="$BRANCH_TAG"
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
fi
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
if EXTRA_TAGS="$EXTRA_TAGS" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
@@ -1040,7 +1050,7 @@ jobs:
fi
done
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${PUSHED_TAGS_SUMMARY})"
- name: Job duration summary
if: always()
@@ -1244,9 +1254,11 @@ jobs:
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
set -eux
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
staging_user="${STAGING_SSH_USER:-root}"
staging_port="${STAGING_SSH_PORT:-22222}"
staging_port="${STAGING_SSH_PORT:-22}"
echo "Host: $staging_host"
echo "Port: $staging_port"
@@ -1470,7 +1482,6 @@ jobs:
- validate-security
- validate-python
- unit-tests
- frontend-lint
- frontend-unit-test
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
strategy:
@@ -2124,3 +2135,4 @@ jobs:
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
# CI retry trigger
+1
View File
@@ -0,0 +1 @@
retrigger3
+1
View File
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
---
**License**: MIT
<!-- CI trigger: 1788229339 -->
@@ -766,7 +766,12 @@ def generate_cover(
storage_svc = get_shared_storage_service()
mk_client = get_mediakit_client()
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
# #1901 统一读 "title",兼容老数据 "title_config"
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
if not isinstance(_e2_title_cfg, dict) or not (_e2_title_cfg.get("text") or "").strip():
_alt = (plan.config or {}).get("title_config", {}) or {}
if isinstance(_alt, dict):
_e2_title_cfg = _alt
if not isinstance(_e2_title_cfg, dict):
_e2_title_cfg = {}
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
+20 -6
View File
@@ -287,10 +287,15 @@ def retry_voice_clone(
return _to_response(profile)
_ALLOWED_PREVIEW_EMOTIONS = {"", "natural", "excited", "calm", "friendly"}
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
def get_voice_clone_preview(
clone_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
emotion: str = Query("", description="情绪:natural/excited/calm/friendly,空字符串为默认自然"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
@@ -298,11 +303,17 @@ def get_voice_clone_preview(
"""获取克隆音色试听音频(实时 TTS 合成)。
- 克隆音色必须处于 ready 状态
- 使用默认试听文本时,结果缓存 7 天
- 可传入自定义 text 参数试听不同文本
- 使用默认试听文本时,结果缓存 7 天(仅默认 text+speed=1.0+emotion=空 组合缓存)
- 可传入自定义 text/speed/emotion 试听不同效果
"""
import time
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的 emotion 值: {emotion},可选: natural/excited/calm/friendly 或留空",
)
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, authenticated_user.user.id)
@@ -315,8 +326,8 @@ def get_voice_clone_preview(
detail=f"Voice clone is not ready (current status: {profile.status})",
)
# 有自定义文本时不缓存
use_cache = not text.strip()
# 仅默认试听文本 + 默认 speed + 默认 emotion 时使用缓存
use_cache = (not text.strip()) and abs(speed - 1.0) < 1e-6 and (not emotion)
if use_cache and clone_id in _clone_preview_cache:
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
@@ -337,12 +348,15 @@ def get_voice_clone_preview(
text=preview_text,
voice_id=profile.voice_id,
format="mp3",
speed=1.0,
speed=speed,
emotion=emotion,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
# 缓存(仅默认试听文本
# 缓存(仅默认参数组合
if use_cache:
_clone_preview_cache[clone_id] = (
result.audio_url,
+17 -3
View File
@@ -82,8 +82,12 @@ def writeback_edit_plan_config(
merged["generation_task_id"] = task_id
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
# #1901 统一字段名为 "title"worker sync_configs_to_plan 写的是 "title"
# 先读取新旧两个 key,判断标题文字是否变化
old_title_cfg = merged.get("title", {}) or {}
if not isinstance(old_title_cfg, dict) or not (old_title_cfg.get("text") or "").strip():
old_title_cfg = merged.get("title_config", {}) or {}
old_title_text = (old_title_cfg.get("text") or "").strip() if isinstance(old_title_cfg, dict) else ""
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
if "cover" in merged:
@@ -94,7 +98,17 @@ def writeback_edit_plan_config(
old_title_text,
new_title_text,
)
merged["title_config"] = title_config
# 字段名归一化(font_size→size, font_preset→font, font_color→color),与 worker sync_configs_to_plan 保持一致
normalized = dict(title_config)
if "font_size" in normalized and "size" not in normalized:
normalized["size"] = normalized["font_size"]
if "font_preset" in normalized and "font" not in normalized:
normalized["font"] = normalized["font_preset"]
if "font_color" in normalized and "color" not in normalized:
normalized["color"] = normalized["font_color"]
merged["title"] = normalized
# 清掉旧 key,避免双字段并存
merged.pop("title_config", None)
plan_model.config = merged
db.commit()
+2
View File
@@ -16,6 +16,7 @@ export interface TTSSynthesizeRequest {
voice_id?: string
output_name?: string
language?: string
emotion?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
@@ -103,6 +104,7 @@ export interface TTSPreviewRequest {
voice_id: string
speed?: number
pitch?: number
language?: string
emotion?: string // 情绪参数:natural/excited/calm/friendly
}
+3
View File
@@ -96,9 +96,12 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
export const getVoiceClonePreview = async (
cloneId: string,
text?: string,
options?: { speed?: number; emotion?: string },
): Promise<VoiceClonePreviewResponse> => {
const searchParams = new URLSearchParams()
if (text) searchParams.set("text", text)
if (options?.speed !== undefined) searchParams.set("speed", String(options.speed))
if (options?.emotion) searchParams.set("emotion", options.emotion)
const qs = searchParams.toString()
const response = await apiClient.get<VoiceClonePreviewResponse>(
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
-9
View File
@@ -25,15 +25,6 @@ body {
background-color: var(--bg-secondary);
}
/* ── 自定义字体 ── */
@font-face {
font-family: "华康俪金黑";
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 滚动条 - V21 样式 */
::-webkit-scrollbar {
width: 8px;
@@ -22,8 +22,8 @@ export interface ScriptSentence {
endTime: number
}
/** 句子分隔符:中英文句号/问号/感叹号/分号/逗号/换行(覆盖中文短视频常用断句 */
const SENTENCE_SPLIT_RE = /[。!?!??!;,\n\r]+/
/** 句子分隔符:中英文句号/问号/感叹号/分号/换行(按句断,不在逗号处切;保持与后端一致 */
const SENTENCE_SPLIT_RE = /[。!?!??!;\n\r]+/
/**
* 分句并计算每句的起止时间。
@@ -5,6 +5,7 @@
import React from "react"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/template-editor"
import { getFontFamily } from "@/pages/generate/constants"
interface SubtitleSettings {
enabled: boolean
@@ -80,7 +81,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
className="ep-preview-title"
style={{
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
fontFamily: titleConfig.font_preset,
fontFamily: getFontFamily(titleConfig.font_preset),
fontWeight: "bold",
fontStyle: "normal",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
@@ -106,7 +107,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
className="ep-preview-subtitle"
style={{
fontSize: `${Math.min(subtitleSettings.size, 14)}px`,
fontFamily: subtitleSettings.font,
fontFamily: getFontFamily(subtitleSettings.font),
top:
subtitleSettings.position === "top"
? "8px"
@@ -9,15 +9,8 @@ export const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
// FONT_OPTIONS 统一从 generate/constants 导入,避免多处维护遗漏
export { FONT_OPTIONS } from "@/pages/generate/constants"
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -7,15 +7,8 @@ export const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
// FONT_OPTIONS 统一从 generate/constants 导入
export { FONT_OPTIONS } from "@/pages/generate/constants"
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -17,6 +17,8 @@ import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { usePreviewAudio } from "../hooks/usePreviewAudio"
import { PreviewControls } from "./PreviewControls"
import { getFontFamily } from "../constants"
interface FrontendPreviewPlayerProps {
assets: AssetItem[]
videoRatio: string
@@ -555,7 +557,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<span
style={{
fontSize: `${titleFontSizePx}px`,
fontFamily: titleSettings.font || "思源黑体",
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
color: titleSettings.color || "#ffffff",
fontWeight: titleSettings.bold ? 700 : 400,
fontStyle: titleSettings.italic ? "italic" : "normal",
+1 -10
View File
@@ -50,15 +50,7 @@ export const POSITION_OPTIONS = [
]
/* ── 标题字体选项 ── */
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
export const FONT_FAMILY_MAP: Record<string, string> = {
@@ -68,7 +60,6 @@ export const FONT_FAMILY_MAP: Record<string, string> = {
PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
: '"Microsoft YaHei", "PingFang SC", sans-serif',
: '"KaiTi", "STKaiti", "DFKai-SB", serif',
: '"华康俪金黑", "DFLiJinHei-W8", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
}
export function getFontFamily(font: string): string {
@@ -11,6 +11,7 @@ import CloneModal from "@/components/voice/CloneModal"
import { VoiceCloneCard } from "./components/VoiceCloneCard"
import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States"
import { EditNameDialog } from "./components/EditNameDialog"
import VoiceClonePreviewPanel from "./components/VoiceClonePreviewPanel"
import { useVoiceCloneList } from "./hooks/useVoiceCloneList"
import "./voice-clone.css"
@@ -47,6 +48,9 @@ const VoiceClone: React.FC = () => {
}
/>
{/* 音色试听面板(自定义文本 + 语速/情绪) */}
{!isLoading && voices.length > 0 && <VoiceClonePreviewPanel voices={voices} />}
{/* 加载状态 — 骨架屏 */}
{isLoading && <VoiceCloneSkeleton />}
@@ -0,0 +1,133 @@
/**
* 克隆音色试听面板
* - 选择就绪音色、输入试听文本、调节语速/情绪,点试听
* - 复用配音库 TTS 弹窗的 SpeedControl / EmotionControl 组件
*/
import React, { useState, useRef, useCallback } from "react"
import { message } from "antd"
import { SoundOutlined, LoadingOutlined } from "@ant-design/icons"
import { getVoiceClonePreview, type VoiceClone } from "@/api/voice-clone"
import SpeedControl from "@/pages/voices/components/tts-modal/SpeedControl"
import EmotionControl from "@/pages/voices/components/tts-modal/EmotionControl"
import { DEFAULT_TTS_EMOTION, type TtsEmotion } from "@/pages/voices/components/tts-modal/constants"
import { TTS_CONFIG } from "@/pages/voices/components/tts-modal/types"
interface VoiceClonePreviewPanelProps {
voices: VoiceClone[]
}
const DEFAULT_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的声音效果,希望你喜欢。"
const VoiceClonePreviewPanel: React.FC<VoiceClonePreviewPanelProps> = ({ voices }) => {
const readyVoices = voices.filter((v) => v.status === "ready")
const [selectedId, setSelectedId] = useState<string>(readyVoices[0]?.id ?? "")
const [previewText, setPreviewText] = useState(DEFAULT_PREVIEW_TEXT)
const [speed, setSpeed] = useState<number>(TTS_CONFIG.DEFAULT_SPEED)
const [emotion, setEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [previewing, setPreviewing] = useState(false)
const [audioUrl, setAudioUrl] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const handlePreview = useCallback(async () => {
if (!selectedId) {
message.warning("请先选择要试听的音色")
return
}
const text = previewText.trim()
if (!text) {
message.warning("请输入试听文本")
return
}
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setPreviewing(true)
setAudioUrl(null)
try {
const res = await getVoiceClonePreview(selectedId, text, { speed, emotion })
setAudioUrl(res.audio_url)
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio.play().catch(() => {
message.error("播放失败,请重试")
})
} catch (err) {
const msg = err instanceof Error ? err.message : "试听失败"
message.error(msg)
} finally {
setPreviewing(false)
}
}, [selectedId, previewText, speed, emotion])
if (readyVoices.length === 0) return null
return (
<div className="vc-preview-panel">
<div className="vc-preview-header">
<SoundOutlined style={{ color: "var(--primary-color)" }} />
<span className="vc-preview-title"></span>
</div>
<div className="vc-preview-body">
<div className="vc-preview-field">
<label className="vc-preview-label"></label>
<select
className="vc-preview-select"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{readyVoices.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div className="vc-preview-field">
<label className="vc-preview-label"></label>
<textarea
className="vc-preview-textarea"
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
rows={2}
maxLength={200}
placeholder="输入试听文本(最多200字)"
/>
</div>
<div className="vc-preview-grid">
<div className="vc-preview-field">
<SpeedControl speed={speed} onChange={setSpeed} />
</div>
<div className="vc-preview-field">
<EmotionControl emotion={emotion} onChange={setEmotion} />
</div>
</div>
<button
type="button"
className="vc-preview-btn"
onClick={handlePreview}
disabled={previewing || !selectedId || !previewText.trim()}
>
{previewing ? (
<>
<LoadingOutlined /> ...
</>
) : (
<> </>
)}
</button>
{audioUrl && !previewing && (
<audio controls src={audioUrl} style={{ width: "100%", marginTop: 4 }} />
)}
</div>
</div>
)
}
export default VoiceClonePreviewPanel
@@ -464,3 +464,93 @@
padding: var(--space-sm);
}
}
/* ── 试听面板 ─────────────────────────────────────────── */
.vc-preview-panel {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.vc-preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.vc-preview-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.vc-preview-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.vc-preview-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.vc-preview-label {
font-size: 13px;
color: var(--text-secondary);
}
.vc-preview-select,
.vc-preview-textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.vc-preview-select:focus,
.vc-preview-textarea:focus {
border-color: var(--primary-color);
}
.vc-preview-textarea {
resize: vertical;
min-height: 52px;
}
.vc-preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vc-preview-btn {
width: 100%;
padding: 10px 0;
border-radius: 8px;
border: none;
background: var(--primary-color);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.15s;
}
.vc-preview-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -132,12 +132,16 @@ const VoiceLibrary: React.FC = () => {
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
ttsError,
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsEmotion,
setTtsLanguage,
setTtsOpen,
handleTtsSynthesize,
handleTtsSave,
@@ -363,6 +367,8 @@ const VoiceLibrary: React.FC = () => {
ttsText={ttsText}
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
@@ -374,6 +380,8 @@ const VoiceLibrary: React.FC = () => {
onTtsTextChange={setTtsText}
onTtsVoiceChange={setTtsVoiceId}
onTtsSpeedChange={setTtsSpeed}
onTtsEmotionChange={setTtsEmotion}
onTtsLanguageChange={setTtsLanguage}
onTtsSynthesize={handleTtsSynthesize}
onTtsSave={handleTtsSave}
/>
@@ -4,6 +4,8 @@ import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
import TextInputSection from "./tts-modal/TextInputSection"
import VoiceSelector from "./tts-modal/VoiceSelector"
import SpeedControl from "./tts-modal/SpeedControl"
import EmotionControl from "./tts-modal/EmotionControl"
import LanguageControl from "./tts-modal/LanguageControl"
import SynthesizeButton from "./tts-modal/SynthesizeButton"
import ErrorAlert from "./tts-modal/ErrorAlert"
import ResultPanel from "./tts-modal/ResultPanel"
@@ -14,6 +16,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
ttsError,
@@ -23,6 +27,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
onTextChange,
onVoiceChange,
onSpeedChange,
onEmotionChange,
onLanguageChange,
onSynthesize,
onSave,
}) => {
@@ -43,6 +49,16 @@ const TtsModal: React.FC<TtsModalProps> = ({
presetVoices={presetVoices}
clonedVoices={clonedVoices}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
}}
>
<EmotionControl emotion={ttsEmotion} onChange={onEmotionChange} />
<LanguageControl language={ttsLanguage} onChange={onLanguageChange} />
</div>
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
{ttsError && <ErrorAlert error={ttsError} />}
@@ -6,6 +6,7 @@ import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import type { TtsStatus } from "./TtsModal"
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./tts-modal/constants"
import CloneModal from "@/components/voice/CloneModal"
import CloneDetailModal from "./CloneDetailModal"
import UploadVoiceModal from "./UploadVoiceModal"
@@ -42,6 +43,8 @@ export interface VoiceModalsProps {
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
@@ -52,6 +55,8 @@ export interface VoiceModalsProps {
onTtsTextChange: (text: string) => void
onTtsVoiceChange: (id: string) => void
onTtsSpeedChange: (speed: number) => void
onTtsEmotionChange: (emotion: TtsEmotion) => void
onTtsLanguageChange: (language: TtsLanguage) => void
onTtsSynthesize: () => void
onTtsSave: () => void
}
@@ -80,6 +85,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsStatus,
ttsAudioUrl,
ttsError,
@@ -89,6 +96,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onTtsTextChange,
onTtsVoiceChange,
onTtsSpeedChange,
onTtsEmotionChange,
onTtsLanguageChange,
onTtsSynthesize,
onTtsSave,
}) => {
@@ -129,6 +138,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
ttsText={ttsText}
ttsVoiceId={ttsVoiceId}
ttsSpeed={ttsSpeed}
ttsEmotion={ttsEmotion}
ttsLanguage={ttsLanguage}
ttsStatus={ttsStatus}
ttsAudioUrl={ttsAudioUrl}
ttsError={ttsError}
@@ -138,6 +149,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
onTextChange={onTtsTextChange}
onVoiceChange={onTtsVoiceChange}
onSpeedChange={onTtsSpeedChange}
onEmotionChange={onTtsEmotionChange}
onLanguageChange={onTtsLanguageChange}
onSynthesize={onTtsSynthesize}
onSave={onTtsSave}
/>
@@ -0,0 +1,47 @@
import React from "react"
import { TTS_EMOTION_OPTIONS, type TtsEmotion } from "./constants"
interface EmotionControlProps {
emotion: TtsEmotion
onChange: (emotion: TtsEmotion) => void
}
/** 情绪选择下拉 */
const EmotionControl: React.FC<EmotionControlProps> = ({ emotion, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={emotion}
onChange={(e) => onChange(e.target.value as TtsEmotion)}
style={{
width: "100%",
padding: "7px 10px",
borderRadius: 6,
border: "1px solid var(--border-color, #e5e7eb)",
background: "var(--bg-primary, #fff)",
color: "var(--text-primary)",
fontSize: 13,
outline: "none",
cursor: "pointer",
}}
>
{TTS_EMOTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}
export default EmotionControl
@@ -0,0 +1,47 @@
import React from "react"
import { TTS_LANGUAGE_OPTIONS, type TtsLanguage } from "./constants"
interface LanguageControlProps {
language: TtsLanguage
onChange: (language: TtsLanguage) => void
}
/** 语言选择下拉 */
const LanguageControl: React.FC<LanguageControlProps> = ({ language, onChange }) => {
return (
<div>
<div
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginBottom: 6,
}}
>
</div>
<select
value={language}
onChange={(e) => onChange(e.target.value as TtsLanguage)}
style={{
width: "100%",
padding: "7px 10px",
borderRadius: 6,
border: "1px solid var(--border-color, #e5e7eb)",
background: "var(--bg-primary, #fff)",
color: "var(--text-primary)",
fontSize: 13,
outline: "none",
cursor: "pointer",
}}
>
{TTS_LANGUAGE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}
export default LanguageControl
@@ -0,0 +1,22 @@
/** TTS 情绪选项(对齐后端 CosyVoice 支持:natural/excited/calm/friendly */
export const TTS_EMOTION_OPTIONS = [
{ value: "natural", label: "自然" },
{ value: "excited", label: "兴奋" },
{ value: "calm", label: "沉稳" },
{ value: "friendly", label: "亲切" },
] as const
export type TtsEmotion = (typeof TTS_EMOTION_OPTIONS)[number]["value"]
/** TTS 语言选项 */
export const TTS_LANGUAGE_OPTIONS = [
{ value: "zh-CN", label: "中文" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
] as const
export type TtsLanguage = (typeof TTS_LANGUAGE_OPTIONS)[number]["value"]
export const DEFAULT_TTS_EMOTION: TtsEmotion = "natural"
export const DEFAULT_TTS_LANGUAGE: TtsLanguage = "zh-CN"
@@ -1,5 +1,6 @@
import { type PresetVoiceDisplay } from "@/pages/voices/types"
import type { TtsClonedVoiceOption } from "./VoiceSelector"
import type { TtsEmotion, TtsLanguage } from "./constants"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -8,6 +9,8 @@ export interface TtsModalProps {
ttsText: string
ttsVoiceId: string
ttsSpeed: number
ttsEmotion: TtsEmotion
ttsLanguage: TtsLanguage
ttsStatus: TtsStatus
ttsAudioUrl: string | null
ttsError: string | null
@@ -18,6 +21,8 @@ export interface TtsModalProps {
onTextChange: (text: string) => void
onVoiceChange: (voiceId: string) => void
onSpeedChange: (speed: number) => void
onEmotionChange: (emotion: TtsEmotion) => void
onLanguageChange: (language: TtsLanguage) => void
onSynthesize: () => void
onSave: () => void
}
@@ -4,6 +4,12 @@ import { message } from "antd"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import { type PresetVoiceDisplay } from "../types"
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
import {
DEFAULT_TTS_EMOTION,
DEFAULT_TTS_LANGUAGE,
type TtsEmotion,
type TtsLanguage,
} from "../components/tts-modal/constants"
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
@@ -29,6 +35,8 @@ export function useTtsSynthesize({
const [ttsText, setTtsText] = useState("")
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsSpeed, setTtsSpeed] = useState(1.0)
const [ttsEmotion, setTtsEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
const [ttsLanguage, setTtsLanguage] = useState<TtsLanguage>(DEFAULT_TTS_LANGUAGE)
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
@@ -51,6 +59,8 @@ export function useTtsSynthesize({
text: ttsText.trim(),
voice_id: ttsVoiceId || undefined,
speed: ttsSpeed,
emotion: ttsEmotion,
language: ttsLanguage,
})
setTtsJobId(resp.job_id)
@@ -81,7 +91,7 @@ export function useTtsSynthesize({
setTtsStatus("error")
setTtsError(msg)
}
}, [ttsText, ttsVoiceId, ttsSpeed])
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsLanguage])
/** 保存 TTS 结果到素材库 */
const handleTtsSave = useCallback(async () => {
@@ -103,6 +113,8 @@ export function useTtsSynthesize({
setTtsText("")
setTtsVoiceId("")
setTtsSpeed(1.0)
setTtsEmotion(DEFAULT_TTS_EMOTION)
setTtsLanguage(DEFAULT_TTS_LANGUAGE)
setTtsStatus("idle")
setTtsAudioUrl(null)
setTtsError(null)
@@ -132,6 +144,8 @@ export function useTtsSynthesize({
ttsText,
ttsVoiceId,
ttsSpeed,
ttsEmotion,
ttsLanguage,
ttsJobId,
ttsStatus,
ttsAudioUrl,
@@ -143,6 +157,8 @@ export function useTtsSynthesize({
setTtsText,
setTtsVoiceId,
setTtsSpeed,
setTtsEmotion,
setTtsLanguage,
setTtsOpen,
// Actions
handleTtsSynthesize,
@@ -321,8 +321,14 @@ def build_subtitles_from_plan(
has_any = False
# 1. 标题
title_cfg = plan_config.get("title_config") or {}
# 1. 标题#1901 统一读 "title",兼容老数据 "title_config"
title_cfg = plan_config.get("title") or {}
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
_alt = plan_config.get("title_config") or {}
if isinstance(_alt, dict):
title_cfg = _alt
if not isinstance(title_cfg, dict):
title_cfg = {}
if isinstance(title_cfg, dict):
title_text = str(title_cfg.get("text", ""))
title_enabled = title_cfg.get("enabled", True)
@@ -572,7 +572,12 @@ class UnifiedRenderService:
ASS 文件路径,没有字幕时返回 None
"""
config = self.plan.config or {}
# #1901 统一读 "title",兼容老数据 "title_config"
title_cfg = config.get("title", {}) or {}
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
_alt = config.get("title_config") or {}
if isinstance(_alt, dict):
title_cfg = _alt
if not isinstance(title_cfg, dict):
title_cfg = {}
subtitle_cfg = config.get("subtitle", {}) or {}
+907
View File
@@ -0,0 +1,907 @@
# 会员制 + 积分方案设计文档
> **Issue**: [#1895](https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/issues/1895)
> **版本**: v1.0
> **状态**: 方案设计(待确认)
> **创建时间**: 2026-09-14
---
## 一、设计背景与目标
### 1.1 背景
当前系统采用**纯订阅制**计费模式(4 档:free/standard/pro/enterprise),存在以下问题:
1. **灵活性不足**:用户付费后只能按套餐配额使用,无法按需消费高频 AI 能力
2. **前后端不一致**:前端 Plans.tsx 写死 3 档,后端硬编码 4 档
3. **积分能力缺失**`quota.py` 已预留 `AI_VOICE_CREDITS` 枚举,`module_registry.py` 已注册消耗规则,但无实际账户、流水、扣费和充值链路
4. **收入天花板**:纯订阅制难以覆盖 AI 调用成本波动,高频用户和低频用户无法差异化变现
### 1.2 目标
- **会员制**保留基础权益(存储、项目数、并发数等),提供稳定收入
- **积分制**覆盖 AI 消耗型功能(配音、数字人、视频生成等),按需付费、用多少扣多少
- 两者结合,既降低轻度用户入门门槛,又提升重度用户 ARPU
- 在现有 `QuotaChecker` + `ModuleRegistry` 架构上扩展,不推倒重来
---
## 二、会员等级设计
### 2.1 等级定义
保留 4 档,与现有后端 `PLAN_QUOTAS` 对齐,重新定义权益:
| 等级 | 月付价格 | 年付价格 | 定位 |
|------|----------|----------|------|
| **免费版(free** | ¥0 | ¥0 | 体验用户,验证产品价值 |
| **标准版(standard** | ¥99/月 | ¥999/年(约 ¥83/月) | 个人创作者 |
| **专业版(pro** | ¥299/月 | ¥2,999/年(约 ¥250/月) | 专业团队 |
| **企业版(enterprise** | ¥999/月 | ¥9,999/年(约 ¥833/月) | 企业 / 工作室 |
### 2.2 会员权益对照表
| 权益维度 | 免费版 | 标准版 | 专业版 | 企业版 |
|----------|--------|--------|--------|--------|
| 项目数量 | 3 个 | 10 个 | 不限 | 不限 |
| 存储空间 | 10 GB | 50 GB | 100 GB | 1 TB |
| 并发任务数 | 3 | 10 | 20 | 50 |
| 导出分辨率 | 720p | 1080p | 4K | 4K |
| 模板数量 | 3 个 | 15 个 | 不限 | 不限 |
| 标题库 | 50 条 | 500 条 | 500 条 | 不限 |
| 配音库 | 10 条 | 100 条 | 100 条 | 不限 |
| AI 配音 | ✗ | ✓ | ✓ | ✓ |
| 批量导出 | ✗ | ✓ | ✓ | ✓ |
| 多平台发布 | ✗ | ✗ | ✓ | ✓ |
| 去重检测报告 | ✗ | ✗ | ✓ | ✓ |
| **每月赠送积分** | 0 | 200 | 800 | 3,000 |
| 技术支持 | 社区 | 邮件 | 优先响应 | 专属客服 |
> 注:`AI_VOICE_ENABLED`、`BATCH_EXPORT_ENABLED`、`MULTI_PLATFORM_ENABLED`、`DEDUP_REPORT_ENABLED` 等开关类权益由现有 `QuotaRegistry` 直接控制,无需积分参与。
### 2.3 与现有代码的映射
- 后端 `PLAN_QUOTAS` 字典扩展,新增 `monthly_credits` 字段
- 前端 `Plans.tsx` 对齐后端 4 档,与后端保持一致
- 现有 `QuotaTier.limits` 中扩展 `monthly_credits` 维度,由 `QuotaRegistry` 统一管理
---
## 三、积分体系设计
### 3.1 积分获取方式
| 获取方式 | 说明 | 频率 |
|----------|------|------|
| **会员每月赠送** | 各等级每月自动到账(见上表) | 每月 1 日 00:00 自动发放 |
| **单独充值** | 用户按需购买积分包 | 随时可买 |
| **任务奖励** | 完成指定任务赠送(新手引导、邀请好友、反馈 Bug 等) | 一次性 |
### 3.2 积分包定价
| 积分包 | 积分数量 | 价格 | 单价 | 备注 |
|--------|----------|------|------|------|
| 体验包 | 50 积分 | ¥9.9 | ¥0.198/积分 | 首次购买限购 1 次 |
| 基础包 | 200 积分 | ¥36 | ¥0.18/积分 | |
| 标准包 | 500 积分 | ¥80 | ¥0.16/积分 | |
| 专业包 | 1,500 积分 | ¥210 | ¥0.14/积分 | 热门 |
| 企业包 | 5,000 积分 | ¥600 | ¥0.12/积分 | |
> 积分永久有效,不随会员过期清零。会员过期后停止每月赠送,但已有积分不受影响。
### 3.3 任务奖励规则
| 任务 | 奖励积分 | 次数限制 |
|------|----------|----------|
| 新用户注册 | 50 | 1 次 |
| 完善个人信息 | 20 | 1 次 |
| 邀请新用户注册 | 30/人 | 每月限 10 人 |
| 首次完成视频生成 | 20 | 1 次 |
| 提交有效 Bug 反馈 | 50 | 不限(审核通过后发放) |
---
## 四、积分消耗场景
### 4.1 消耗清单
| 功能模块 | 消耗场景 | 每次消耗积分 | 说明 |
|----------|----------|-------------|------|
| **AI 配音** | 生成一条配音 | 1 | 已存在于 `module_registry.py` |
| **AI 数字人** | 生成一段数字人视频片段 | 5 | 新场景 |
| **视频生成** | 生成一段 AI 视频(≤10s) | 10 | 新场景 |
| **视频生成(长时长)** | 每增加 10s | +5 | 累进计费 |
| **抖音文案提取** | 提取一次 | 1 | 新场景 |
| **AI 改写** | 改写一段文案 | 1 | 新场景 |
| **AI 标题生成** | 批量生成一次(≤10 条) | 1 | 新场景 |
| **AI 封面生成** | 生成一张封面 | 2 | 新场景 |
| **AI 字幕翻译** | 翻译一条字幕(≤50 字) | 1 | 新场景 |
### 4.2 消耗规则扩展机制
复用现有 `ModuleRegistry` + `QuotaRule` 模式:
```python
# 新增模块注册示例
module_registry.register(Module(
name="ai_digital_human",
version="1.0.0",
description="AI 数字人生成模块",
capabilities=[
ModuleCapability(
name="generate_digital_human",
description="生成数字人视频片段",
quota_rules=[QuotaRule("points", 5.0, "每段数字人视频消耗 5 积分")],
),
],
))
```
新增 `QuotaDimension.POINTS = "points"` 作为通用积分维度,所有消耗型功能统一通过积分维度扣费。
### 4.3 消费折扣(预留)
未来可扩展按会员等级设置折扣:
| 等级 | 积分消耗折扣 |
|------|-------------|
| 免费版 | 无折扣 |
| 标准版 | 9.5 折 |
| 专业版 | 9 折 |
| 企业版 | 8 折 |
> 折扣仅在会员有效期内生效,过期后恢复原价。实现时通过 `discount_rate` 配置字段支持。
---
## 五、数据库表设计
### 5.1 新增表结构
#### 5.1.1 积分账户表 `points_accounts`
每个用户一个积分账户,记录余额和累计值。
```sql
CREATE TABLE IF NOT EXISTS points_accounts (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
balance INTEGER NOT NULL DEFAULT 0, -- 当前可用积分
total_earned INTEGER NOT NULL DEFAULT 0, -- 累计获得积分
total_spent INTEGER NOT NULL DEFAULT 0, -- 累计消耗积分
total_recharged INTEGER NOT NULL DEFAULT 0, -- 累计充值积分
total_gifted INTEGER NOT NULL DEFAULT 0, -- 累计赠送积分(会员赠送 + 任务奖励)
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_points_accounts_user ON points_accounts(user_id);
```
**设计说明**
- `user_id` 设为 UNIQUE,每个用户只有一个积分账户
- 余额通过 `total_earned - total_spent` 可交叉校验 `balance`,保证数据一致性
- 不使用悲观锁,而是通过事务 + 乐观锁(`updated_at`)保证并发安全
#### 5.1.2 积分流水表 `points_transactions`
每笔积分变动都记录一条流水,支持对账和审计。
```sql
CREATE TABLE IF NOT EXISTS points_transactions (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(36) NOT NULL REFERENCES points_accounts(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL, -- earn(获得) / spend(消耗) / refund(退还) / expire(过期)
source VARCHAR(50) NOT NULL, -- recharge(充值) / membership_gift(会员赠送) / task_reward(任务奖励) / ai_voice / ai_digital_human / ai_video / ...
amount INTEGER NOT NULL, -- 变动数量(正数)
balance_after INTEGER NOT NULL, -- 变动后余额
description VARCHAR(255) DEFAULT '', -- 描述
ref_id VARCHAR(100) DEFAULT '', -- 关联业务 ID(订单号、任务 ID 等)
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_points_tx_user ON points_transactions(user_id);
CREATE INDEX idx_points_tx_type ON points_transactions(type);
CREATE INDEX idx_points_tx_source ON points_transactions(source);
CREATE INDEX idx_points_tx_created ON points_transactions(created_at);
```
**设计说明**
- `source` 字段标识具体来源/场景,新增消耗场景时只需新增 source 值,不需要改表结构
- `ref_id` 关联具体业务,方便追溯(如充值关联订单号,消费关联生成任务 ID)
- 流水只追加不修改,保证审计完整性
#### 5.1.3 积分订单表 `points_orders`
记录用户充值积分的支付订单。
```sql
CREATE TABLE IF NOT EXISTS points_orders (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
package_name VARCHAR(50) NOT NULL, -- 积分包名称
points_amount INTEGER NOT NULL, -- 积分数量
price_cents INTEGER NOT NULL, -- 支付金额(分),避免浮点精度问题
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / paid / failed / refunded
payment_method VARCHAR(50), -- alipay / wechat_pay / ...
payment_id VARCHAR(100), -- 第三方支付流水号
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expire_at TIMESTAMP -- 订单过期时间(未支付自动关闭)
);
CREATE INDEX idx_points_orders_user ON points_orders(user_id);
CREATE INDEX idx_points_orders_status ON points_orders(status);
```
#### 5.1.4 积分消耗配置表 `points_consumption_config`
集中管理各场景的积分消耗规则,支持动态调整。
```sql
CREATE TABLE IF NOT EXISTS points_consumption_config (
id VARCHAR(36) PRIMARY KEY,
scene_key VARCHAR(50) NOT NULL UNIQUE, -- 场景标识,如 ai_voice, ai_video
scene_name VARCHAR(100) NOT NULL, -- 场景显示名称
points_per_use INTEGER NOT NULL DEFAULT 1, -- 每次消耗积分数
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用
description VARCHAR(255) DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```
### 5.2 现有表变更
#### 5.2.1 users 表新增字段
```sql
-- 会员等级字段保留现有 subscription_plan,无需改动
-- 无需在 users 表加 credits 字段,积分独立在 points_accounts 表管理
```
### 5.3 表关系
```
users (1) ──── (1) points_accounts
└── (1:N) points_transactions
└── ref_id ──> generation_tasks / points_orders / ...
users (1) ──── (1:N) points_orders
```
### 5.4 与现有模型的映射
| 现有模型 | 改动 |
|----------|------|
| `UserModel` | 不新增积分字段,积分由独立表管理 |
| `BillingRecordModel` | 保留,用于订阅支付记录;积分充值走新的 `points_orders` |
| `QuotaDimension` | 新增 `POINTS = "points"` 维度 |
| `QuotaTier` | 各等级新增 `monthly_credits` 配额 |
| `QuotaRule` | `dimension` 支持 `"points"` 值 |
---
## 六、API 设计
### 6.1 积分账户 API
#### `GET /api/v1/points/balance`
获取当前积分余额。
```json
// Response
{
"balance": 580,
"total_earned": 1200,
"total_spent": 620,
"membership_monthly_gift": 200,
"membership_expires_at": "2027-01-15T00:00:00Z"
}
```
#### `GET /api/v1/points/transactions`
查询积分流水,支持分页和筛选。
```
?page=1&page_size=20&type=spend&source=ai_voice&start_date=2026-09-01&end_date=2026-09-30
```
```json
// Response
{
"total": 156,
"page": 1,
"page_size": 20,
"items": [
{
"id": "tx_xxx",
"type": "spend",
"source": "ai_voice",
"amount": 1,
"balance_after": 579,
"description": "AI 配音 - 温柔女声",
"ref_id": "task_xxx",
"created_at": "2026-09-14T10:30:00Z"
}
]
}
```
### 6.2 积分充值 API
#### `POST /api/v1/points/recharge`
创建积分充值订单。
```json
// Request
{
"package_id": "standard_pack" // 或自定义 amount
}
// Response
{
"order_id": "po_xxx",
"package_name": "标准包",
"points_amount": 500,
"price_cents": 8000,
"payment_url": "https://pay.alipay.com/...",
"expire_at": "2026-09-14T16:30:00Z"
}
```
#### `POST /api/v1/points/payment-callback`
支付回调(内部接口 + 第三方支付通知)。
```json
// Request (来自支付平台)
{
"order_id": "po_xxx",
"payment_method": "alipay",
"payment_id": "2026xxx",
"status": "paid"
}
```
回调处理流程:
1. 验证支付签名
2. 更新 `points_orders.status = "paid"`
3. 增加 `points_accounts.balance += points_amount`
4. 写入 `points_transactions` 流水(type=earn, source=recharge
### 6.3 积分消费 API
#### 内部扣费接口(供各功能模块调用)
```python
# packages/domain/points_service.py
class PointsService:
def deduct(self, user_id: str, scene_key: str, amount: int, ref_id: str = "") -> DeductResult:
"""
扣减积分
1. 检查积分余额是否充足
2. 在事务中扣减余额、写入流水
3. 返回扣减结果
"""
pass
def check_balance(self, user_id: str, scene_key: str) -> CheckResult:
"""
检查余额是否充足某场景消耗
"""
pass
```
#### `GET /api/v1/points/consumption-rules`
查询当前所有积分消耗规则(前端展示用)。
```json
// Response
{
"rules": [
{ "scene_key": "ai_voice", "scene_name": "AI 配音", "points_per_use": 1 },
{ "scene_key": "ai_digital_human", "scene_name": "AI 数字人", "points_per_use": 5 },
{ "scene_key": "ai_video", "scene_name": "视频生成", "points_per_use": 10 },
...
]
}
```
### 6.4 会员订阅 API(改造)
保留现有 `subscription.py` 路由结构,新增以下逻辑:
- `POST /subscription/change-plan`:变更套餐时,自动创建/更新积分账户,发放当月赠送积分
- `GET /subscription/current`:返回中增加 `monthly_credits``points_balance` 字段
- 新增 `POST /subscription/claim-monthly-credits`:手动领取每月赠送积分(兜底入口)
### 6.5 API 路由汇总
| 方法 | 路径 | 说明 | 类型 |
|------|------|------|------|
| GET | `/api/v1/points/balance` | 查询积分余额 | 用户 |
| GET | `/api/v1/points/transactions` | 积分流水查询 | 用户 |
| POST | `/api/v1/points/recharge` | 创建充值订单 | 用户 |
| POST | `/api/v1/points/payment-callback` | 支付回调 | 内部 |
| GET | `/api/v1/points/consumption-rules` | 消耗规则查询 | 用户 |
| POST | `/api/v1/points/check` | 消费前余额检查 | 内部 |
| POST | `/api/v1/points/deduct` | 消费扣减 | 内部 |
| POST | `/api/v1/points/refund` | 消费退还 | 内部 |
---
## 七、前端页面设计
### 7.1 会员购买页(改造 Plans.tsx
**路由**: `/app/subscription`(保持不变)
**改动要点**
1. 将 3 档对齐为 4 档,与后端一致
2. 每档卡片增加"每月赠送 XXX 积分"标识
3. 按钮文案按当前状态动态显示("当前方案"/"升级"/"降级"/"联系我们"
4. 底部增加积分包购买入口
```
┌─────────────────────────────────────────────────────────────┐
│ 选择适合您的方案 │
├──────────┬──────────┬──────────┬──────────┐ │
│ 免费版 │ 标准版 │ 专业版 ★ │ 企业版 │ │
│ ¥0/月 │ ¥99/月 │ ¥299/月 │ ¥999/月 │ │
│ │ │ │ │ │
│ 3 项目 │ 10 项目 │ 无限项目 │ 无限项目 │ │
│ 10GB │ 50GB │ 100GB │ 1TB │ │
│ 0 积分 │ 200积分 │ 800积分 │ 3000积分 │ ← 新增 │
│ │ │ │ │ │
│ [当前] │ [升级] │ [升级] │ [联系] │ │
├──────────┴──────────┴──────────┴──────────┘ │
│ │
│ 💰 积分充值 [查看全部积分包] │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │50积分 │ │200积分│ │500积分│ │1500积分│ │5000积分│ │
│ │ ¥9.9 │ │ ¥36 │ │ ¥80 │ │ ¥210 │ │ ¥600 │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 7.2 积分余额展示位置
#### 顶部导航栏(Header 组件)
在用户头像旁边增加积分余额徽章:
```
┌─────────────────────────────────────────────────┐
│ Logo 工作台 模板 素材 🔔 💎 580 👤 │
└─────────────────────────────────────────────────┘
```
- 点击积分徽章展开快捷面板,显示余额、本月已用、充值入口
- 积分不足时徽章变为警告色(橙色)
#### 功能入口处的消耗提示
在每个 AI 功能的操作按钮旁,显示本次操作将消耗的积分:
```
[生成配音] 💎 -1 积分
[生成视频] 💎 -10 积分
```
### 7.3 积分中心页面
**路由**: `/app/points`(新增)
**页面结构**
```
┌─────────────────────────────────────────────────┐
│ 积分中心 │
├─────────────────────────────────────────────────┤
│ │
│ 当前余额 本月获得 本月消耗 │
│ 💎 580 +200 -120 │
│ │
├─────────────────────────────────────────────────┤
│ │
│ [积分明细] [充值记录] [消耗规则] │
│ ───────── │
│ │
│ 时间 类型 场景 数量 余额 │
│ 09-14 10:30 消耗 AI配音 -1 579 │
│ 09-14 09:15 消耗 AI视频 -10 580 │
│ 09-01 00:00 获得 会员赠送 +200 590 │
│ 08-28 14:20 获得 充值+500 +500 390 │
│ ... │
│ │
└─────────────────────────────────────────────────┘
```
### 7.4 前端组件清单
| 组件 | 路径 | 说明 |
|------|------|------|
| `PointsBadge` | `components/common/PointsBadge/` | 顶部积分余额徽章 |
| `PointsPanel` | `components/common/PointsPanel/` | 点击徽章展开的快捷面板 |
| `PointsCost` | `components/common/PointsCost/` | 功能入口的消耗提示标签 |
| `PointsCenter` | `pages/points/Center.tsx` | 积分中心主页面 |
| `PointsTransactions` | `pages/points/Transactions.tsx` | 积分明细子页 |
| `PointsPackages` | `pages/points/Packages.tsx` | 充值积分包子页 |
| `Plans` (改造) | `pages/subscription/Plans.tsx` | 对齐 4 档 + 积分展示 |
---
## 八、与现有订阅制的迁移方案
### 8.1 迁移原则
1. **向前兼容**:迁移期间老用户权益不降低
2. **平滑过渡**:不需要用户手动操作,自动完成
3. **灰度发布**:按用户批次逐步迁移,降低风险
### 8.2 迁移步骤
#### Step 1:数据准备(后端 + DBA
```sql
-- 1. 创建新表(points_accounts, points_transactions, points_orders, points_consumption_config
-- 2. 为所有现有用户创建积分账户
INSERT INTO points_accounts (id, user_id, balance, total_earned, total_gifted)
SELECT
uuid(), id,
CASE subscription_plan
WHEN 'standard' THEN 200
WHEN 'pro' THEN 800
WHEN 'enterprise' THEN 3000
ELSE 0
END,
CASE subscription_plan
WHEN 'standard' THEN 200
WHEN 'pro' THEN 800
WHEN 'enterprise' THEN 3000
ELSE 0
END,
CASE subscription_plan
WHEN 'standard' THEN 200
WHEN 'pro' THEN 800
WHEN 'enterprise' THEN 3000
ELSE 0
END
FROM users WHERE subscription_status = 'active';
```
#### Step 2:代码兼容层
```python
# packages/domain/quota.py 扩展
class QuotaDimension(str, Enum):
# ... 现有维度保持不变
POINTS = "points" # 新增:通用积分维度
# QUOTA_TIERS 扩展
"standard": QuotaTier(
name="standard",
limits={
# ... 现有配额保持不变
QuotaDimension.MONTHLY_CREDITS: 200, # 新增
},
),
```
#### Step 3:双轨运行期(1 个月)
- 订阅制功能不变,老用户正常续费
- 积分系统上线后,所有 AI 消耗型功能改为积分扣费
- 会员权益中的"基础配额"(存储、项目数等)继续由订阅制控制
- 在用户首次登录后,弹出迁移通知弹窗,说明变更内容
#### Step 4:完全切换
- 停止订阅制的 AI 配额逻辑(`AI_VOICE_CREDITS` 等旧维度废弃)
- 所有 AI 功能统一使用积分扣费
- 订阅制仅控制基础权益(存储、项目数、并发数、导出分辨率等)
### 8.3 老用户过渡策略
| 用户类型 | 过渡方案 |
|----------|----------|
| 当前 free 用户 | 不变,积分余额为 0,可充值 |
| 当前 standard 用户 | 赠送 200 积分作为过渡礼包,当前周期内权益不变 |
| 当前 pro 用户 | 赠送 800 积分 + 延长 1 个月有效期 |
| 当前 enterprise 用户 | 赠送 3000 积分 + 延长 1 个月有效期 + 专属客户经理通知 |
| 年付用户 | 按剩余月数比例折算赠送积分 |
### 8.4 前端迁移
- `Plans.tsx` 从 3 档改为 4 档,增加积分信息展示
- 新增 `/app/points` 积分中心页面
- 顶部 Header 增加积分余额徽章
- 各 AI 功能页增加积分消耗提示
---
## 九、配额检查中间件设计
### 9.1 整体架构
```
用户请求 → API Route → 积分检查中间件 → 业务逻辑 → 返回结果
├─ 检查会员权益(QuotaChecker
├─ 检查积分余额(PointsChecker
└─ 扣减积分(PointsService.deduct
```
### 9.2 中间件设计
#### `apps/api/app/middleware/points_check.py`
```python
"""积分扣费中间件 - 用于 AI 功能入口的统一检查与扣费"""
from functools import wraps
from fastapi import HTTPException
def require_points(scene_key: str, amount: int = None):
"""
装饰器:在 AI 功能入口检查积分余额并扣费
Args:
scene_key: 消耗场景标识,如 "ai_voice", "ai_video"
amount: 指定消耗数量,为 None 时从 points_consumption_config 读取
使用方式:
@router.post("/generate-voice")
@require_points(scene_key="ai_voice")
async def generate_voice(request: VoiceRequest, current_user = Depends(get_current_user)):
# 到这里积分已扣减成功
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 1. 从 kwargs 或 args 中提取 current_user
current_user = kwargs.get("current_user") or next(
(a for a in args if isinstance(a, AuthenticatedUser)), None
)
if not current_user:
raise HTTPException(status_code=401, detail="未登录")
# 2. 获取消耗数量
consume_amount = amount or get_consumption_config(scene_key)
# 3. 检查会员权益(原有 QuotaChecker 逻辑)
user = current_user.user
tier = quota_registry.get_tier(user.subscription_plan or "free")
# ... 检查存储、并发等基础权益
# 4. 检查并扣减积分
points_service = get_points_service()
result = points_service.check_and_deduct(
user_id=user.id,
scene_key=scene_key,
amount=consume_amount,
)
if not result.success:
raise HTTPException(
status_code=402, # Payment Required
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {consume_amount} 积分,当前余额 {result.balance}",
"recharge_url": "/app/points/recharge"
}
)
# 5. 将扣减信息注入请求上下文,供业务层使用
kwargs["points_deduct_id"] = result.transaction_id
try:
# 6. 执行业务逻辑
return await func(*args, **kwargs)
except Exception as e:
# 7. 业务失败时退还积分
points_service.refund(
user_id=user.id,
transaction_id=result.transaction_id,
reason=f"业务执行失败: {scene_key}"
)
raise
return wrapper
return decorator
```
### 9.3 积分服务层
```python
# packages/domain/points_service.py
class PointsService:
"""积分服务 - 核心扣费逻辑"""
def __init__(self, account_repo, transaction_repo, config_repo):
self.account_repo = account_repo
self.transaction_repo = transaction_repo
self.config_repo = config_repo
def check_and_deduct(self, user_id: str, scene_key: str, amount: int, ref_id: str = "") -> DeductResult:
"""
检查余额并扣减积分(事务操作)
流程:
1. 查询积分账户
2. 检查余额是否 >= amount
3. 在事务中:扣减余额 + 写入流水
4. 返回扣减结果
"""
pass
def refund(self, user_id: str, transaction_id: str, reason: str = "") -> bool:
"""退还积分(业务失败时调用)"""
pass
def gift(self, user_id: str, amount: int, source: str, ref_id: str = ""):
"""赠送积分(会员赠送 / 任务奖励)"""
pass
def get_balance(self, user_id: str) -> int:
"""查询余额"""
pass
def get_transactions(self, user_id: str, page: int = 1, page_size: int = 20,
type: str = None, source: str = None) -> list:
"""查询流水"""
pass
```
### 9.4 与现有 QuotaChecker 的集成
```python
# 改造后的检查流程
async def check_all_quotas(user, scene_key: str, consume_amount: int):
"""统一配额检查入口"""
# 1. 基础配额检查(存储空间、项目数、并发数等)
# 复用现有 QuotaChecker
plan = user.subscription_plan or "free"
quota_results = quota_checker.check_multiple(plan, {
QuotaDimension.STORAGE_GB.value: get_used_storage(user.id),
QuotaDimension.VIDEOS_PER_MONTH.value: get_monthly_video_count(user.id),
QuotaDimension.MAX_CONCURRENT.value: get_concurrent_count(user.id),
})
for result in quota_results:
if not result.allowed:
raise QuotaExceededError(result)
# 2. 积分检查
# 新增 PointsChecker
balance = points_service.get_balance(user.id)
if balance < consume_amount:
raise InsufficientPointsError(
required=consume_amount,
balance=balance
)
return True
```
### 9.5 各功能模块接入方式
```python
# apps/api/app/api/routes/ai_voice.py(示例)
@router.post("/generate")
@require_points(scene_key="ai_voice")
async def generate_voice(
request: VoiceGenerateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
points_deduct_id: str = None, # 由中间件注入
):
# 积分已扣减,直接执行业务逻辑
result = await voice_service.generate(request, current_user.user.id)
return VoiceGenerateResponse(
voice_id=result.id,
points_consumed=1,
)
# apps/api/app/api/routes/ai_video.py(示例)
@router.post("/generate")
@require_points(scene_key="ai_video")
async def generate_video(
request: VideoGenerateRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
points_deduct_id: str = None,
):
# 视频按秒计费,需要在中间件外动态计算
...
```
> **注意**:对于视频生成等按量计费场景(时长不确定),中间件支持动态计算消耗量:
> ```python
> @require_points(scene_key="ai_video", dynamic=True)
> # dynamic=True 时,中间件只检查余额 > 0,实际扣费由业务层调用 points_service.deduct()
> ```
---
## 十、实施计划
### 10.1 开发阶段拆分
| 阶段 | 内容 | 预估工时 |
|------|------|----------|
| **P1 - 基础框架** | 数据库迁移脚本、积分账户/流水/订单 Model、Repository | 3 天 |
| **P2 - 核心服务** | PointsService 核心逻辑(扣费/退还/赠送/查询) | 3 天 |
| **P3 - API 层** | 积分 API 路由、中间件、与现有订阅 API 集成 | 3 天 |
| **P4 - 前端页面** | Plans.tsx 改造、积分中心页面、Header 积分徽章、消耗提示 | 5 天 |
| **P5 - 功能接入** | 各 AI 功能模块接入 `@require_points` 中间件 | 3 天 |
| **P6 - 迁移与测试** | 数据迁移脚本、灰度方案、集成测试、端到端测试 | 3 天 |
### 10.2 文件改动清单(预览)
| 类型 | 文件路径 | 改动说明 |
|------|----------|----------|
| 新增 | `migrations/007_membership_points.sql` | 新建积分相关 4 张表 |
| 新增 | `packages/domain/points_service.py` | 积分核心服务 |
| 新增 | `packages/domain/points_models.py` 或追加到 `models.py` | 积分相关 ORM Model |
| 新增 | `packages/adapters/sqlalchemy_impl/points_repository.py` | 积分 Repository |
| 新增 | `apps/api/app/middleware/points_check.py` | 积分扣费中间件 |
| 新增 | `apps/api/app/api/routes/points.py` | 积分 API 路由 |
| 新增 | `apps/api/app/schemas/points.py` | 积分 Schema |
| 新增 | `apps/web/src/pages/points/Center.tsx` | 积分中心页面 |
| 新增 | `apps/web/src/pages/points/Transactions.tsx` | 积分明细页面 |
| 新增 | `apps/web/src/pages/points/Packages.tsx` | 积分包充值页面 |
| 新增 | `apps/web/src/components/common/PointsBadge/` | 积分徽章组件 |
| 新增 | `apps/web/src/components/common/PointsCost/` | 消耗提示组件 |
| 修改 | `packages/domain/quota.py` | 新增 POINTS 维度和 monthly_credits 配额 |
| 修改 | `packages/adapters/sqlalchemy_impl/models.py` | 新增积分相关 Model |
| 修改 | `apps/api/app/api/routes/subscription.py` | 集成积分逻辑 |
| 修改 | `apps/api/app/schemas/subscription.py` | 返回中增加积分信息 |
| 修改 | `apps/web/src/pages/subscription/Plans.tsx` | 对齐 4 档 + 积分展示 |
| 修改 | 各 AI 功能路由文件 | 添加 `@require_points` 装饰器 |
### 10.3 测试计划
| 测试类型 | 覆盖范围 |
|----------|----------|
| 单元测试 | PointsService 扣费/退还/赠送逻辑、PointsChecker 余额检查 |
| 集成测试 | API 端到端:充值→到账→消费→扣减→流水查询 |
| 并发测试 | 同一用户多请求并发扣费的余额一致性 |
| 前端测试 | Plans 页面渲染、积分中心交互、Header 徽章实时更新 |
| 迁移测试 | 老用户数据迁移正确性验证 |
---
## 十一、风险与注意事项
| 风险 | 应对方案 |
|------|----------|
| 并发扣费导致余额不一致 | 数据库事务 + 行锁,`points_accounts` 使用 `SELECT ... FOR UPDATE` |
| 支付回调延迟导致积分未到账 | 订单创建后 30 分钟未支付自动关闭;回调支持幂等重试 |
| 积分消耗规则变更影响用户 | 变更前 7 天公告通知;已购买的服务按旧价格执行 |
| 前后端积分展示不一致 | 统一从 `GET /api/v1/points/balance` 获取,前端不本地缓存余额 |
| 老用户迁移产生不满 | 过渡期权益不降低 + 额外赠送积分礼包 |
---
## 十二、开放问题(待确认)
1. **积分有效期**:当前设计为永久有效。是否需要设置有效期(如 1 年)?
2. **退款策略**:积分充值后是否支持退款?已消费的积分如何计算?
3. **企业版定制**:企业版是否需要支持自定义积分消耗规则?
4. **支付渠道**:第一期接入支付宝 + 微信支付,是否需要支持其他渠道?
5. **发票需求**:积分充值是否需要单独开发票?与订阅发票合并还是分开?
---
*本文档为方案设计阶段产物,待确认后将按「实施计划」分阶段开发。*
+9 -5
View File
@@ -12,8 +12,9 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 预装系统依赖(gcc 编译 psycopg/pg 扩展,libpq-dev 编译期,libpq5 运行期,ffmpeg 封面取帧)
# 字体修复:fonts-noto-cjk 包的 .ttc 文件混入了 Mono 变体,导致 Bold 匹配到等宽字体
# 解决方案:删除有问题的 .ttc使用仓库内预下载的 Noto Sans SC Variable Font(不含 Mono
# 字体修复:fonts-noto-cjk 包的 Sans .ttc 文件混入了 Mono 变体,导致 Bold 匹配到等宽字体
# 解决方案:删除 Sans .ttc保留 Serif .ttc;仓库内预下载 VF 可变字体(不含 Mono
# #1896 补充开源字体:NotoSerifCJKsc-VF.otf(思源宋体)、LXGWWenKai-Regular.ttf(霞鹜文楷开源楷体)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
@@ -21,12 +22,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/* \
# 删除有问题的 .ttc 文件(包含 Mono 变体)
# 删除有问题的 Sans .ttc(含 Mono 变体),保留 Serif .ttc 作为宋体 fallback
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc \
&& mkdir -p /usr/share/fonts/truetype/lxgw
# 复制预下载的 Noto Sans SC Variable Font(包含所有字重,不含 Mono 变体)
# 复制开源中文字体:思源黑体 VF + 思源宋体 VF(#1896)+ 霞鹜文楷(#1896,开源楷体)
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
RUN fc-cache -fv
# 创建虚拟环境
+11 -5
View File
@@ -12,8 +12,11 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 预装系统依赖(编译工具 + 运行时 + CJK 字体用于 ASS 字幕渲染)
# 字体修复:fonts-noto-cjk 包的 .ttc 文件混入了 Mono 变体,导致 Bold 匹配到等宽字体
# 解决方案:删除有问题的 .ttc使用仓库内预下载的 Noto Sans SC Variable Font(不含 Mono
# 字体修复:fonts-noto-cjk 包的 Sans .ttc 文件混入了 Mono 变体,导致 Bold 匹配到等宽字体
# 解决方案:删除 Sans .ttc保留 Serif .ttc(宋体 fallback);仓库内预下载 VF 可变字体(不含 Mono
# #1896 补充开源字体:NotoSerifCJKsc-VF.otf(思源宋体衬线)、LXGWWenKai-Regular.ttf(霞鹜文楷开源楷体,SIL OFL)
# - 苹方/微软雅黑为 macOS/Windows 系统字体,服务器无对应文件,映射到 Noto Sans SC fallback
# - 华康俪金黑为商业字体有版权风险,前端已移除,后端映射到 Noto Sans SC 兼容老数据
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
@@ -23,12 +26,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/* \
# 删除有问题的 .ttc 文件(包含 Mono 变体
# 删除有问题的 Sans .ttc 文件(包含 Mono 变体会导致粗体匹配错误),保留 Serif .ttc 作为宋体 fallback
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc
&& rm -f /usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc \
&& mkdir -p /usr/share/fonts/truetype/lxgw
# 复制预下载的 Noto Sans SC Variable Font(包含所有字重,不含 Mono 变体)
# 复制开源中文字体:思源黑体 VF + 思源宋体 VF(#1896)+ 霞鹜文楷(#1896,开源楷体)
COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf
COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf
COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf
RUN fc-cache -fv
# 创建虚拟环境
Binary file not shown.
Binary file not shown.
+8 -1
View File
@@ -25,13 +25,20 @@ TITLE_MARGIN_BOTTOM = 100
TITLE_MARGIN_SIDE = 40
# 字体名称映射:前端中文字体名 → 服务器实际注册名(ffmpeg/ASS 通过注册名匹配字体)
# #1896 字体映射修复:每个字体映射到独立的注册名,而非全部回退到 Noto Sans SC
# - 思源宋体 → Noto Serif CJK SCfonts-noto-cjk 包预装 + VF.otf
# - 楷体 → LXGW WenKai(霞鹜文楷,#1896 新增 SIL OFL 开源楷体)
# - 苹方/PingFang/微软雅黑:服务器 Linux 无对应字体,fallback 思源黑体
# - 华康俪金黑:商业字体有版权风险,前端已移除,后端保留映射 fallback 思源黑体(兼容老数据)
FONT_NAME_MAP: dict[str, str] = {
"思源黑体": "Noto Sans SC",
"思源宋体": "Noto Serif CJK SC",
"苹方": "Noto Sans SC",
"PingFang": "Noto Sans SC",
"微软雅黑": "Noto Sans SC",
"楷体": "Noto Serif CJK SC",
"Microsoft YaHei": "Noto Sans SC",
"楷体": "LXGW WenKai",
"霞鹜文楷": "LXGW WenKai",
"华康俪金黑": "Noto Sans SC",
}
+6 -5
View File
@@ -1,6 +1,6 @@
"""共享的句子时间戳计算工具 — 供 Celery TTS 任务和 /lipsync/tts-preview 同步接口复用.
- `_split_script_into_sentences`: 按标点分句(中英文逗号/句号/问号/感叹号/分号/换行)
- `split_script_into_sentences`: 按标点分句(中英文句号/问号/感叹号/分号/换行,不含逗号,与前端 sentences.ts 保持一致
- `_estimate_sentence_timings_by_chars`: 按字数比例估算(静音检测失败时降级)
- `_probe_audio_duration`: ffprobe 读取音频时长
- `compute_sentence_timings`: 基于 ffmpeg silencedetect 精确计算每句起止时间
@@ -19,15 +19,16 @@ logger = logging.getLogger(__name__)
def split_script_into_sentences(script_text: str) -> list[str]:
"""按句号/问号/感叹号/分号/逗号/换行分句(与前端 SENTENCE_SPLIT_RE 一致).
"""按句号/问号/感叹号/分号/换行分句(与前端 sentences.ts 的 SENTENCE_SPLIT_RE 一致).
中文短视频文案习惯用「,」断小句(如"卖花的叫花无缺,卖姜的叫姜子牙"),
必须把逗号也纳入分隔符,否则多句文案会被识别成一整句,导致 B-roll 时间戳错位。
仅在句末标点(。!?!?;;)和换行处分句,**不再用逗号(,,)切分**。
按逗号切分会把连贯句子拆得过碎,导致 B-roll/口播画面按句插入时句数过多、
时长过短,效果不符合预期(Issue #1892)。
"""
text = (script_text or "").strip()
if not text:
return []
parts = re.split(r"[。!?!??!;,\n\r]+", text)
parts = re.split(r"[。!?!??!;\n\r]+", text)
return [p.strip() for p in parts if p.strip()]
+15 -8
View File
@@ -378,12 +378,16 @@ def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -
# ── 标题 drawtext 滤镜构建(#1789)─────────────────────────────────────────────
# drawtext 字体搜索路径:按优先级从高到低排列
# 服务器使用 Noto Sans SC(思源黑体)作为默认字体。
# - NotoSansSC-VF.ttfworker-base.Dockerfile COPY 的 VF 字体(含所有字重,无 Mono 变体),优先级最高
# - .ttc 系列为 fonts-noto-cjk 包预装字体(Dockerfile 已删除含 Mono 变体的旧 .ttc,存在时作为 fallback
# #1896 字体映射修复:
# - NotoSansSC-VF.ttf:思源黑体(VF 可变字体,含所有字重),worker-base.Dockerfile COPY
# - NotoSerifCJKsc-VF.otf:思源宋体(VF 可变字体),#1896 新增,衬线字体
# - LXGWWenKai-Regular.ttf:霞鹜文楷(开源楷体,SIL OFL),#1896 新增
# - fonts-noto-cjk 预装 .ttc 作为 fallbackDockerfile 已删除含 Mono 变体的文件)
# - DejaVuSans 仅含拉丁字符不支持中文,已移除
DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
"/usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf",
"/usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
@@ -393,13 +397,18 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [
]
# 前端字体名 → drawtext 字体搜索关键字(匹配 DRAWTEXT_FONT_SEARCH_PATHS 中的文件名关键字)
# #1896 字体映射修复:每个字体映射到独立的关键字,而非全部回退到 NotoSansSC
# - 苹方(macOS/ 微软雅黑(Windows/ PingFang:服务器 Linux 无对应文件,fallback 思源黑体
# - 华康俪金黑:商业字体有版权风险,前端已按 #1896 要求移除,后端保留映射但 fallback 思源黑体(兼容老数据)
DRAWTEXT_FONT_MAP: dict[str, str] = {
"思源黑体": "NotoSansSC",
"思源宋体": "NotoSerifCJK",
"思源宋体": "NotoSerifCJKsc",
"苹方": "NotoSansSC",
"PingFang": "NotoSansSC",
"微软雅黑": "NotoSansSC",
"楷体": "NotoSerifCJK",
"Microsoft YaHei": "NotoSansSC",
"楷体": "LXGWWenKai",
"霞鹜文楷": "LXGWWenKai",
"华康俪金黑": "NotoSansSC",
}
@@ -479,9 +488,7 @@ def build_title_drawtext_filter(
return None
# 字段名归一化:兼容 content/text/title 三套命名
text = (
title_config.get("text") or title_config.get("content") or title_config.get("title") or ""
).strip()
text = (title_config.get("text") or title_config.get("content") or title_config.get("title") or "").strip()
if not text:
return None
+49 -17
View File
@@ -36,14 +36,20 @@ CACHE_TAG_PRIMARY="${CACHE_TAG:-develop}"
API_IMAGE="xiaoxia-saas-api:$VERSION"
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
WEB_IMAGE="xiaoxia-saas-web:$VERSION"
API_LATEST="xiaoxia-saas-api:dev"
WORKER_LATEST="xiaoxia-saas-worker:dev"
API_DEV="xiaoxia-saas-api:dev"
WORKER_DEV="xiaoxia-saas-worker:dev"
WEB_DEV="xiaoxia-saas-web:dev"
# Registry 上的完整镜像名
# Registry 上的完整镜像名SHA/版本 tag
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$VERSION"
# Registry 上的 dev floating tag(仅 staging/develop 构建时推送,供 Watchtower 监听自动更新)
REGISTRY_API_DEV="${REGISTRY}/xiaoxia-saas-api:dev"
REGISTRY_WORKER_DEV="${REGISTRY}/xiaoxia-saas-worker:dev"
REGISTRY_WEB_DEV="${REGISTRY}/xiaoxia-saas-web:dev"
USE_CACHE=0
USE_PUSH=0
CACHE_WRITE=0
@@ -56,23 +62,34 @@ if docker buildx version >/dev/null 2>&1; then
docker buildx use default 2>/dev/null || true
fi
# ---- 是否需要推送 dev tag ----
# staging 构建 或 develop 分支构建时才推送 :dev tag,供 Watchtower 自动更新;
# production / release tag 构建不推送 dev tag,避免污染生产 tag 指向。
PUSH_DEV_TAG=0
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
if [ "$BUILD_ENV" = "staging" ] || [ "$BRANCH_NAME" = "develop" ]; then
PUSH_DEV_TAG=1
echo "Dev tag push: ENABLED (BUILD_ENV=$BUILD_ENV, BRANCH=$BRANCH_NAME) — Watchtower will pick up new :dev"
else
echo "Dev tag push: disabled (BUILD_ENV=$BUILD_ENV, BRANCH=$BRANCH_NAME)"
fi
# ---- 缓存读写策略(按分支隔离)----
# 默认只读不写,防止 feature 分支污染主缓存
# 只有 develop/main 分支才写回缓存
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
# 清理本地旧镜像
docker rmi -f "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
docker rmi -f "$API_IMAGE" "$API_DEV" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/api.Dockerfile \
-t "$API_IMAGE" -t "$API_LATEST" \
-t "$API_IMAGE" -t "$API_DEV" \
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_DEV" .
fi
build_with_cache() {
@@ -122,22 +139,22 @@ build_with_cache() {
echo "=== Building API image ==="
build_with_cache "api" "infra/docker/api.Dockerfile" \
"--build-arg APP_VERSION=$VERSION"
docker tag "$API_IMAGE" "$API_LATEST"
docker tag "$API_IMAGE" "$API_DEV"
echo "=== Building Worker image ==="
# 清理本地旧镜像
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
docker rmi -f "$WORKER_IMAGE" "$WORKER_DEV" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/worker.Dockerfile \
-t "$WORKER_IMAGE" -t "$WORKER_LATEST" \
-t "$WORKER_IMAGE" -t "$WORKER_DEV" \
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_DEV" .
fi
echo "=== Building Web image (with buildx cache) ==="
@@ -158,37 +175,52 @@ docker run --rm \
test -f apps/web/dist/index.html
# 清理本地旧镜像
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
docker rmi -f "$WEB_IMAGE" "$WEB_DEV" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
-t "$WEB_IMAGE" -t "$WEB_DEV" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
-t "$WEB_IMAGE" -t "$WEB_DEV" \
.
fi
# Push 到 Registry
if [ "$USE_PUSH" -eq 1 ]; then
echo "=== Pushing images to Registry ==="
echo "=== Pushing SHA/version-tagged images to Registry ==="
docker tag "$API_IMAGE" "$REGISTRY_API"
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER"
docker tag "$WEB_IMAGE" "$REGISTRY_WEB"
docker push "$REGISTRY_API"
docker push "$REGISTRY_WORKER"
docker push "$REGISTRY_WEB"
echo "All images pushed to $REGISTRY"
echo "SHA-tagged images pushed to $REGISTRY"
# ---- 额外推送 :dev floating tag(仅 staging/develop----
# :dev tag 指向最新一次 develop/staging 构建,供 Watchtower 通过 WATCHTOWER_ROLLING_RESTART 等方式
# 监听镜像更新并自动 pull + restart;部署脚本 (ci_staging_deploy.sh) 仍使用 SHA tag 做确定性部署,
# dev tag 仅作为"最新 develop 成功构建"的可滚动标识,避免并发构建互相覆盖导致的不可重现问题。
if [ "$PUSH_DEV_TAG" -eq 1 ]; then
echo "=== Pushing :dev floating tags to Registry (for Watchtower auto-update) ==="
docker tag "$API_IMAGE" "$REGISTRY_API_DEV"
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER_DEV"
docker tag "$WEB_IMAGE" "$REGISTRY_WEB_DEV"
docker push "$REGISTRY_API_DEV"
docker push "$REGISTRY_WORKER_DEV"
docker push "$REGISTRY_WEB_DEV"
echo ":dev tags pushed to $REGISTRY"
fi
else
echo "Registry push skipped (no auth token available)"
fi
echo "=== Build complete ==="
docker images | grep "xiaoxia-saas" | grep "$VERSION" || true
docker images | grep "xiaoxia-saas" | grep -E "($VERSION|dev)" || true
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# ============================================
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
# 用法:
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
# ============================================
set -euo pipefail
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
ACR_USERNAME="${ACR_USERNAME:-}"
ACR_PASSWORD="${ACR_PASSWORD:-}"
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
IMAGES=(
"python:3.12-slim-bookworm"
"python:3.12-slim"
"node:20"
"nginx:alpine"
)
echo "============================================"
echo " 基础镜像同步到 ACR"
echo " ACR: $ACR_REGISTRY"
echo " 源: $SOURCE_PREFIX"
echo "============================================"
echo ""
# 登录 ACR
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
echo "登录 ACR..."
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
echo "ACR 登录成功"
echo ""
fi
success=0
failed=0
for image in "${IMAGES[@]}"; do
source_image="${SOURCE_PREFIX}/${image}"
target_image="${ACR_REGISTRY}/base/${image}"
echo "--- 同步: $image ---"
echo " 源: $source_image"
echo " 目标: $target_image"
# Pull 源镜像(带重试)
pulled=0
for attempt in 1 2 3; do
echo " Pull 尝试 $attempt/3..."
if docker pull "$source_image"; then
pulled=1
break
fi
echo " Pull 失败,5s 后重试..."
sleep 5
done
if [ "$pulled" -eq 0 ]; then
echo " ❌ Pull 失败: $image"
failed=$((failed + 1))
continue
fi
# Tag
docker tag "$source_image" "$target_image"
echo " Tag 完成"
# Push 到 ACR
pushed=0
for attempt in 1 2 3; do
echo " Push 尝试 $attempt/3..."
if docker push "$target_image"; then
pushed=1
break
fi
echo " Push 失败,5s 后重试..."
sleep 5
done
if [ "$pushed" -eq 1 ]; then
echo " ✅ 同步成功: $image"
success=$((success + 1))
else
echo " ❌ Push 失败: $image"
failed=$((failed + 1))
fi
echo ""
done
echo "============================================"
echo " 同步完成"
echo " 成功: $success"
echo " 失败: $failed"
echo "============================================"
if [ "$failed" -gt 0 ]; then
exit 1
fi
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
set -eu
echo "=== CI Validate: 代码质量与安全扫描 ==="
# --- 密钥检测 ---
echo ""
echo "=== [1/6] Secret detection (detect-secrets) ==="
python3 -m pip install -q detect-secrets
detect-secrets --version
detect-secrets scan \
--all-files \
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
--disable-plugin Base64HighEntropyString \
--disable-plugin HexHighEntropyString \
--disable-plugin BasicAuthDetector \
--disable-plugin KeywordDetector \
--disable-plugin IPPublicDetector \
> /tmp/secrets-scan.json 2>&1
FOUND=$(python3 -c "
import json
try:
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
results = data.get('results', {})
total = sum(len(v) for v in results.values())
print(total)
except Exception:
print('error')
")
echo "Secrets detected: $FOUND"
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
echo ""
echo "=== Secret details ==="
python3 -c "
import json
with open('/tmp/secrets-scan.json') as f:
data = json.load(f)
for fpath, items in data.get('results', {}).items():
for item in items:
line = item.get('line_number', '?')
stype = item.get('type', '?')
hashed = item.get('hashed_secret', '')[:16]
print(f' {fpath}:{line} [{stype}] {hashed}...')
"
echo ""
echo "ERROR: Potential secrets detected in code!"
exit 1
fi
echo "✅ Secret scan passed"
# --- 代码质量检查(全量,PR 和 push 统一标准)---
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
echo ""
echo "=== [2/6] Code quality checks (full scan) ==="
SCAN_MODE="full"
echo "Full scan mode"
python3 -m compileall -q alembic apps packages tests scripts
python3 -m black --check --fast alembic apps packages tests scripts
python3 -m isort --check-only alembic apps packages tests scripts
python3 -m ruff check apps packages tests --statistics
echo "✅ Code quality checks passed"
# --- Bandit 安全扫描(仅告警) ---
echo ""
echo "=== [3/6] Security scan (bandit, advisory only) ==="
set +e
bandit -r apps packages -q -ll
BANDIT_EXIT=$?
set -e
if [ "$BANDIT_EXIT" -ne 0 ]; then
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
else
echo "✅ Bandit security scan passed"
fi
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
echo ""
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
python3 -m pip install -q pip-audit
pip-audit --version
EXIT_CODE=0
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
if [ -f "$req_file" ]; then
echo "--- Scanning $req_file ---"
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
echo ""
fi
done
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
# --- Vulture 死代码检测(仅告警) ---
echo ""
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
set +e
python3 -m pip install -q vulture
vulture --version
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
echo ""
vulture apps packages scripts \
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
--min-confidence 70 \
2>&1 | sort -t'(' -k2 -rn | head -80
echo ""
echo "=== vulture scan summary ==="
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
echo "建议:定期人工审查高置信度(>=90%)条目"
set -e
# --- CI脚本语法校验 ---
echo ""
echo "=== [6/6] CI & shell scripts syntax validation ==="
SYNTAX_ERROR=0
# 检查所有 CI shell 脚本
for script in scripts/ci/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查所有 CI Python 脚本语法
for script in scripts/ci/*.py; do
if [ -f "$script" ]; then
if ! python3 -m py_compile "$script" 2>&1; then
echo "❌ Python语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
# 检查 .gitea/workflows 下的脚本(如果有)
for script in .gitea/workflows/*.sh; do
if [ -f "$script" ]; then
if ! bash -n "$script" 2>&1; then
echo "❌ 语法错误: $script"
SYNTAX_ERROR=1
fi
fi
done
if [ "$SYNTAX_ERROR" -ne 0 ]; then
echo "❌ CI脚本语法校验失败,见上方错误"
exit 1
fi
echo "✅ All CI scripts syntax OK"
echo ""
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
+52 -38
View File
@@ -170,16 +170,17 @@ rollback() {
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-api \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-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://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
@@ -196,26 +197,29 @@ rollback() {
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-worker \
--network-alias xiaoxia-api \
--env-file "$ENV_FILE" \
-e APP_ENV=production \
-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://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PYTHONPATH=/app:/app/apps/api:/app/packages \
-v "$GENERATED_DIR:/app/generated" \
-v "$LEGACY_ASSETS_DIR:/app/legacy-assets" \
-w /app/apps/worker \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--cpus 2 \
--memory 3g \
--health-cmd "sh -c 'PYTHONPATH=/app:/app/apps/api:/app/packages celery -A worker_app.celery_app inspect ping -t 5 2>&1 | grep -q pong'" \
--health-interval 30s \
--health-timeout 10s \
--health-timeout 15s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$PREV_WORKER_IMAGE"
--health-start-period 60s \
--log-driver json-file --log-opt max-size=200m --log-opt max-file=5 \
"$PREV_WORKER_IMAGE" \
/usr/local/bin/entrypoint-worker.sh
else
echo "No previous Worker image to roll back to"
fi
@@ -230,12 +234,15 @@ rollback() {
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
--network-alias xiaoxia-web \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
-e APP_ENV=production \
-e API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
$LEGACY_VOLUME \
--restart unless-stopped \
--cpus 1 \
--memory 512m \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -373,12 +380,13 @@ docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-api \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
@@ -395,26 +403,29 @@ docker run -d \
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
--network-alias xiaoxia-worker \
--network-alias xiaoxia-api \
--env-file "$ENV_FILE" \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-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://production-api.xiaoxiajianji.com \
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
-e PYTHONPATH=/app:/app/apps/api:/app/packages \
-v "$GENERATED_DIR:/app/generated" \
-v "$LEGACY_ASSETS_DIR:/app/legacy-assets" \
-w /app/apps/worker \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
--memory 3g \
--health-cmd "sh -c 'PYTHONPATH=/app:/app/apps/api:/app/packages celery -A worker_app.celery_app inspect ping -t 5 2>&1 | grep -q pong'" \
--health-interval 30s \
--health-timeout 10s \
--health-timeout 15s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$REGISTRY_WORKER" || rollback
--health-start-period 60s \
--log-driver json-file --log-opt max-size=200m --log-opt max-file=5 \
"$REGISTRY_WORKER" \
/usr/local/bin/entrypoint-worker.sh || rollback
# ---- 启动 Web ----
LEGACY_VOLUME=""
@@ -429,12 +440,15 @@ echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
--network-alias xiaoxia-web \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
-e APP_ENV=production \
-e API_BASE_URL=https://saas-api.xiaoxiajianji.com \
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
$LEGACY_VOLUME \
--restart unless-stopped \
--cpus 1 \
--memory 512m \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
@@ -487,7 +501,7 @@ docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Production deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "API: http://127.0.0.1:8001"
echo "Web: http://127.0.0.1:3002"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
+3
View File
@@ -264,6 +264,9 @@ if [ -n "$REGISTRY_TOKEN" ]; then
fi
# ---- 并行 Pull 三个镜像 ----
# 注意:这里必须使用 IMAGE_TAG(commit SHA)做确定性部署,不要改成 :dev。
# :dev 是 floating tag,可能被并发构建覆盖,导致部署版本不可重现、回滚混乱。
# Watchtower 可监听 :dev 做非关键路径的自动同步;正式部署/回滚一律锚定 SHA。
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
+4 -4
View File
@@ -17,9 +17,9 @@
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
#
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 127.0.0.1CI runner 在 staging 本机)
# STAGING_SSH_USER - SSH 用户名 (默认 root)
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
# STAGING_SSH_PORT - SSH 端口 (默认 22)
# STAGING_SSH_KEY - SSH 私钥内容
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
#
@@ -40,9 +40,9 @@ HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-127.0.0.1}"
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
+47 -7
View File
@@ -107,29 +107,68 @@ class TestWritebackEditPlanConfig:
writeback_edit_plan_config("p1", "task-xyz", None, db)
assert plan.config["generation_task_id"] == "task-xyz"
assert plan.config["other"] == "keep-me"
assert "title_config" not in plan.config
assert "title" not in plan.config or not plan.config.get("title")
db.commit.assert_called_once()
def test_merges_title_config_without_title_change(self):
def test_merges_title_without_title_change(self):
"""#1901: 写 'title' 字段,未变标题保留 cover。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
assert plan.config["title"] == {"text": "old"}
# 旧 key 不应残留
assert "title_config" not in plan.config
# 标题未变 → cover 保留
assert plan.config.get("cover") == "x"
def test_merges_title_fallback_to_old_title_config_key(self):
"""#1901: 老数据存在 title_config(无 title)时,也能正确识别旧标题文字。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
assert plan.config["title_config"] == {"text": "old"}
# 标题未变 → cover 保留
# 写入新 key "title",旧 key 被清除
assert plan.config["title"] == {"text": "old"}
assert "title_config" not in plan.config
assert plan.config.get("cover") == "x"
def test_title_change_clears_cover(self):
"""#1901: 标题变化时清 cover,新配置写到 'title'"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "new-title"}, db)
assert "cover" not in plan.config
assert plan.config["title_config"] == {"text": "new-title"}
assert plan.config["title"] == {"text": "new-title"}
assert "title_config" not in plan.config
def test_title_config_normalizes_legacy_keys(self):
"""#1901: 写入时归一化 font_size/font_preset/font_color → size/font/color,与 worker 对齐。"""
from app.services.generation_common import writeback_edit_plan_config
plan = _make_plan_model({})
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config(
"p1",
"t1",
{"text": "hi", "font_size": 32, "font_preset": "楷体", "font_color": "#ff0000", "bold": True},
db,
)
title = plan.config["title"]
assert title["text"] == "hi"
assert title["size"] == 32
assert title["font"] == "楷体"
assert title["color"] == "#ff0000"
# 原始 key 保留(方便调用方排查,但归一化后的 key 必须存在)
assert title["font_size"] == 32
def test_config_not_dict_treated_as_empty(self):
from app.services.generation_common import writeback_edit_plan_config
@@ -139,7 +178,8 @@ class TestWritebackEditPlanConfig:
db.query.return_value.filter.return_value.first.return_value = plan
writeback_edit_plan_config("p1", "t1", {"text": "hi"}, db)
assert plan.config["generation_task_id"] == "t1"
assert plan.config["title_config"] == {"text": "hi"}
assert plan.config["title"] == {"text": "hi"}
assert "title_config" not in plan.config
def test_exception_triggers_rollback_and_logs(self, caplog):
from app.services.generation_common import writeback_edit_plan_config
+15
View File
@@ -46,6 +46,21 @@ class TestSplitScriptIntoSentences(unittest.TestCase):
result = _split_script_into_sentences("没有标点的句子")
self.assertEqual(result, ["没有标点的句子"])
def test_chinese_comma_not_split(self):
"""Issue #1892:中文逗号「,」不应分句,保持完整小句。"""
result = _split_script_into_sentences("卖花的叫花无缺,卖姜的叫姜子牙。")
self.assertEqual(result, ["卖花的叫花无缺,卖姜的叫姜子牙"])
def test_english_comma_not_split(self):
"""Issue #1892:英文逗号「,」不应分句。"""
result = _split_script_into_sentences("Hello, how are you?")
self.assertEqual(result, ["Hello, how are you"])
def test_comma_between_sentences_kept_in_sentence(self):
"""两个句号间带逗号的长句:逗号不切,只按句号切。"""
result = _split_script_into_sentences("第一句,带逗号。第二句,也带逗号!")
self.assertEqual(result, ["第一句,带逗号", "第二句,也带逗号"])
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
"""Tests for _estimate_sentence_timings_by_chars."""
+75 -1
View File
@@ -50,7 +50,7 @@ def _make_auth_user(user_id: str = "user_001") -> MagicMock:
class TestVoiceClonePreview:
"""克隆音色试听接口测试。"""
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001"):
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001", speed=1.0, emotion=""):
"""调用路由函数,模拟 FastAPI 注入依赖。"""
from app.api.routes.voice_clones import get_voice_clone_preview
@@ -93,6 +93,8 @@ class TestVoiceClonePreview:
result = get_voice_clone_preview(
clone_id=profile.id if profile else "nonexistent",
text=text,
speed=speed,
emotion=emotion,
authenticated_user=_make_auth_user(user_id),
repository=repo,
cosyvoice=cosyvoice_mock,
@@ -129,6 +131,8 @@ class TestVoiceClonePreview:
call_kwargs = cosyvoice.synthesize_speech.call_args
assert call_kwargs.kwargs["voice_id"] == "clone_voice_001"
assert call_kwargs.kwargs["format"] == "mp3"
assert call_kwargs.kwargs["speed"] == 1.0
assert call_kwargs.kwargs.get("emotion", "") == ""
def test_preview_custom_text(self) -> None:
"""自定义试听文本。"""
@@ -161,6 +165,76 @@ class TestVoiceClonePreview:
assert exc_info.value.status_code == 404
cosyvoice.synthesize_speech.assert_not_called()
def test_preview_custom_speed_emotion_passed_through(self) -> None:
"""Issue #1897: speed/emotion 参数透传到 cosyvoice.synthesize_speech。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
cosyvoice.synthesize_speech.return_value = SynthesizeResult(
audio_url="https://oss.example.com/preview/fast.mp3",
duration=2.0,
file_size=32000,
)
result = self._call_preview(profile, cosyvoice, speed=1.3, emotion="excited")
assert result.audio_url == "https://oss.example.com/preview/fast.mp3"
call_kwargs = cosyvoice.synthesize_speech.call_args.kwargs
assert call_kwargs["speed"] == 1.3
assert call_kwargs["emotion"] == "excited"
def test_preview_invalid_emotion_rejected(self) -> None:
"""Issue #1897: 非法 emotion 值返回 400。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
with pytest.raises(HTTPException) as exc_info:
self._call_preview(profile, cosyvoice, emotion="angry")
assert exc_info.value.status_code == 400
cosyvoice.synthesize_speech.assert_not_called()
def test_preview_non_default_speed_no_cache(self) -> None:
"""Issue #1897: 自定义 speed/emotion 不走缓存。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
cosyvoice.synthesize_speech.side_effect = [
SynthesizeResult(audio_url="https://example.com/fast.mp3", duration=2.0, file_size=32000),
SynthesizeResult(audio_url="https://example.com/slow.mp3", duration=4.0, file_size=60000),
]
# 非默认speed — 不应缓存
result1 = self._call_preview(profile, cosyvoice, speed=1.5)
result2 = self._call_preview(profile, cosyvoice, speed=0.7, emotion="calm")
assert cosyvoice.synthesize_speech.call_count == 2
assert result1.audio_url != result2.audio_url
def test_preview_valueerror_from_cosyvoice_returns_400(self) -> None:
"""Issue #1897: cosyvoice 因 speed 非法等抛 ValueError 时返回 400(与 TTS preview 一致)。"""
from app.api.routes.voice_clones import _clone_preview_cache
_clone_preview_cache.clear()
profile = _make_profile()
cosyvoice = MagicMock()
cosyvoice.synthesize_speech.side_effect = ValueError("speed out of range")
with pytest.raises(HTTPException) as exc_info:
self._call_preview(profile, cosyvoice)
assert exc_info.value.status_code == 400
def test_preview_not_ready_pending(self) -> None:
"""pending 状态的克隆音色不能试听。"""
from app.api.routes.voice_clones import _clone_preview_cache
@@ -57,7 +57,7 @@ class TestWritebackEditPlanConfig:
mock_db.query.assert_called_once()
mock_db.commit.assert_not_called()
# ---- 行 170-182: 正常写入 + title_config ----
# ---- 行 170-182: 正常写入 + title#1901 统一字段名) ----
def test_success_with_title_config(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
@@ -69,7 +69,11 @@ class TestWritebackEditPlanConfig:
)
assert mock_plan.config["generation_task_id"] == "task_456"
assert mock_plan.config["title_config"] == {"text": "标题", "font_size": 36}
# #1901: 统一写到 "title" 字段,且 font_size 已归一化为 size
assert mock_plan.config["title"]["text"] == "标题"
assert mock_plan.config["title"]["size"] == 36
assert mock_plan.config["title"]["font_size"] == 36
assert "title_config" not in mock_plan.config
assert mock_plan.config["existing_key"] == "existing_value"
mock_db.commit.assert_called_once()
@@ -80,7 +84,7 @@ class TestWritebackEditPlanConfig:
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_789", title_config=None, db=mock_db)
assert mock_plan.config["generation_task_id"] == "task_789"
assert "title_config" not in mock_plan.config
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
mock_db.commit.assert_called_once()
# ---- 行 170: config 不是 dict → 兜底空 dict ----
@@ -115,12 +119,12 @@ class TestWritebackEditPlanConfig:
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
mock_db.rollback.assert_called_once()
# ---- 行 173: title_config 为空 dict → 不写入 title_config ----
# ---- 行 173: title_config 为空 dict → 不写入 title ----
def test_empty_title_config_not_written(self, mock_db, mock_plan):
mock_db.first.return_value = mock_plan
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config={}, db=mock_db)
# 空 dict 为 falsy,不写入
assert "title_config" not in mock_plan.config
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
assert mock_plan.config["generation_task_id"] == "task_1"