Compare commits

...

8 Commits

Author SHA1 Message Date
xiaoxia 0fdb1363fb perf(ci): parallel image builds + pip cache
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m36s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m34s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m16s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 24s
2026-07-13 15:14:09 +08:00
xiaoxia 9030b0587e perf(build): use buildx --push directly instead of --load + push 2026-07-13 15:14:02 +08:00
xiaoxia cc47c9f90f chore(frontend): Phase 1 技术债务清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 28s
CI/CD Pipeline / Unit Tests (push) Failing after 32s
CI/CD Pipeline / Integration Tests (push) Failing after 24s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m53s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #251: chore(frontend): Phase 1 技术债务清理 - 删除死代码和重复样式 into develop
2026-07-13 15:06:45 +08:00
xiaoxia c1e466f9c1 fix(backend): Phase 1 后端代码清理与修复
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 25s
CI/CD Pipeline / Unit Tests (push) Failing after 26s
CI/CD Pipeline / Integration Tests (push) Failing after 25s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m44s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #249: fix(backend): Phase 1 后端代码清理与修复 into develop
2026-07-13 15:02:13 +08:00
CI Bot 8598638e8f fix: 恢复 tts_job/workflow.py 中误删的 TTSJobStatus import
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 49s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 43s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m56s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 第 207 行仍在使用 TTSJobStatus.COMPLETED.value
- 删除 import 会导致运行时 NameError
- pyflakes 验证零 undefined name 错误
2026-07-13 14:30:31 +08:00
xiaoxia c48ddeef7d fix: black/isort 格式化修复 - generation.py 和单测文件 (#250)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m27s
CI/CD Pipeline / Unit Tests (push) Successful in 2m12s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Frontend Lint (push) Successful in 3m27s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 5s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-13 14:13:45 +08:00
CI Test b87d7b763e chore(frontend): Phase 1 技术债务清理 - 删除死代码和重复样式
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m7s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 5m5s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 删除 src/config/navigation.tsx(废弃文件,211行)
- 删除 src/components/business/business.css(死CSS,365行)
- 删除 src/api/dashboard.ts(废弃API模块,42行)
- 移除 router/index.tsx 重复 my-voices 路由(7行)
- 清理 generate.css 重复按钮样式(48行,改用 ui.css 统一样式)
- 清理 Admin.css 重复 .xx-card/.xx-select 定义(40行)

共减少约 713 行代码,vite build 验证通过
2026-07-13 14:12:00 +08:00
CI Bot 9c6c477f55 fix(backend): Phase 1 后端代码清理与修复
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m3s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
P0 关键修复:
- P0-1: 注册接口添加 RateLimitMiddleware 限流保护
- P0-3: /metrics 端点添加 JWT 认证(移除匿名访问)
- P0-4: 修复 Celery 任务名冲突(generation_task vs generate_video)
- P1-5: JWT logout token 黑名单机制

P1 修复:
- P1-1: forgot_password 硬编码 localhost → 使用 settings.APP_BASE_URL
- P1-2: generation.py 直接创建 DB 连接 → 使用依赖注入
- P1-6: Image.open() 未关闭 → 统一使用 with 语句
- P1-7: 订阅续费事务修复

P2 代码质量:
- P2-1: 修复 EditingMode 枚举重复定义 → 统一引用 shared 包
- P2-2: 修复 SMTP_FRON_NAME → SMTP_FROM_NAME 拼写
- P2-3: UserModel subscription_quota 类型统一为 float
- P2-4: .env.production DATABASE_MAX_OVERFLOW 30 → 10
- 清理 15 处 except:pass(保留 2 处有注释说明的)
- 禁用 SVG 上传(XSS 风险)
- 删除 decode_token_unsafe() 不安全函数
- 简化 /ready 端点
- 删除 8 处死代码、10 个空文件/模块
- 合并 3 对 100% 重复函数
- 对齐 6 个废弃环境变量

v2 修复(代码审查后):
- 修复密码重置路由路径: /password/forgot → /forgot-password,
  /password/reset → /reset-password(与前端 API 对齐)
- 合并 _check_project_access: asset_libraries.py 和 edit_plans.py
  中的重复函数统一到 _helpers.py(含空字符串守卫 + 中文错误信息)
- 顺手修复: HTTPException 统一从 fastapi 导入(替换 starlette 导入)
- OSS_ENDPOINT 拼写修复拆分为单独 PR,本 PR 不包含
2026-07-13 13:50:52 +08:00
63 changed files with 727 additions and 1066 deletions
+4 -1
View File
@@ -3,6 +3,7 @@
# ==================== 应用配置 ====================
APP_NAME=小虾 SaaS
APP_BASE_URL=http://localhost:3000
APP_ENV=development
# ==================== 数据库配置 ====================
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
@@ -35,7 +36,8 @@ ENVIRONMENT=development
DEBUG=true
# ==================== CORS 配置 ====================
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
# ==================== 阿里云 OSS 配置 ====================
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
@@ -49,6 +51,7 @@ OSS_BUCKET_NAME=xiaoxia-autocut
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
+401 -29
View File
@@ -84,6 +84,13 @@ jobs:
python3 -m pip --version
echo "CI environment is ready"
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements-base.txt', 'requirements.txt', 'requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Install dependencies
shell: sh
run: |
@@ -213,6 +220,13 @@ jobs:
tar.extract(member, '.')
PY
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements-base.txt', 'requirements.txt', 'requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Install dependencies
shell: sh
run: |
@@ -312,6 +326,13 @@ jobs:
python3 -m pip --version
echo "CI environment is ready"
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements-base.txt', 'requirements.txt', 'requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Install dependencies
shell: sh
run: |
@@ -587,14 +608,17 @@ jobs:
echo "=== CI 失败通知 ==="
FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py
deploy-staging:
name: Build & Push Staging (Watchtower auto-deploy)
build-staging-api:
name: Build Staging API Image
runs-on: saas
timeout-minutes: 30
timeout-minutes: 25
needs: [validate, frontend-lint]
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
env:
BUILD_ONLY: api
steps:
- name: Checkout code
shell: sh
@@ -641,16 +665,195 @@ jobs:
tar.extract(member, '.')
INNERPY
- name: Build and push all images to Gitea Registry
- name: Build and push api image
shell: sh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN }
ALLOW_SHARED_PRODUCTION_BUILD_HOST: "true"
run: |
set -eu
chmod +x scripts/build_release_images.sh
ALLOW_SHARED_PRODUCTION_BUILD_HOST=true REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
BUILD_ONLY="${BUILD_ONLY}" \
ALLOW_SHARED_PRODUCTION_BUILD_HOST=true \
REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
scripts/build_release_images.sh "${GITHUB_SHA}" staging
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build Staging API Image" python3 scripts/ci_notify_failure.py
build-staging-worker:
name: Build Staging Worker Image
runs-on: saas
timeout-minutes: 25
needs: [validate, frontend-lint]
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
env:
BUILD_ONLY: worker
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'INNERPY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
INNERPY
- name: Build and push worker image
shell: sh
env:
REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN }
ALLOW_SHARED_PRODUCTION_BUILD_HOST: "true"
run: |
set -eu
chmod +x scripts/build_release_images.sh
BUILD_ONLY="${BUILD_ONLY}" \
ALLOW_SHARED_PRODUCTION_BUILD_HOST=true \
REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
scripts/build_release_images.sh "${GITHUB_SHA}" staging
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build Staging Worker Image" python3 scripts/ci_notify_failure.py
build-staging-web:
name: Build Staging Web Image
runs-on: saas
timeout-minutes: 25
needs: [validate, frontend-lint]
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
env:
BUILD_ONLY: web
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'INNERPY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
INNERPY
- name: Build and push web image
shell: sh
env:
REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN }
ALLOW_SHARED_PRODUCTION_BUILD_HOST: "true"
run: |
set -eu
chmod +x scripts/build_release_images.sh
BUILD_ONLY="${BUILD_ONLY}" \
ALLOW_SHARED_PRODUCTION_BUILD_HOST=true \
REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
scripts/build_release_images.sh "${GITHUB_SHA}" staging
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build Staging Web Image" python3 scripts/ci_notify_failure.py
deploy-staging:
name: Deploy Staging (Watchtower auto-deploy)
runs-on: saas
timeout-minutes: 20
needs: [build-staging-api, build-staging-worker, build-staging-web]
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
steps:
- name: Tag and push :staging images (Watchtower auto-update)
shell: sh
env:
@@ -662,6 +865,7 @@ jobs:
printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null
fi
for svc in api worker web; do
docker pull "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}"
docker tag "${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}" "${REGISTRY}/xiaoxia-saas-${svc}:staging"
docker push "${REGISTRY}/xiaoxia-saas-${svc}:staging"
done
@@ -732,9 +936,7 @@ jobs:
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py
FAILED_JOB="Deploy Staging" python3 scripts/ci_notify_failure.py
staging-e2e:
name: Staging E2E Tests
@@ -887,14 +1089,17 @@ jobs:
build-production-runtime-images:
name: Build Production Runtime Images
build-prod-api:
name: Build Production API Image
runs-on: saas
timeout-minutes: 30
timeout-minutes: 25
needs: [validate, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v')
env:
BUILD_ONLY: api
steps:
- name: Checkout code
shell: sh
@@ -902,7 +1107,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
python3 - <<'INNERPY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
@@ -927,7 +1132,6 @@ jobs:
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
@@ -940,47 +1144,215 @@ jobs:
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
INNERPY
- name: Build and push all images (api + worker + web, with buildx cache)
- name: Build and push production api image
shell: sh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN }
run: |
set -eu
chmod +x scripts/build_release_images.sh
REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}"
BUILD_ONLY="${BUILD_ONLY}" \
REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
- name: Cleanup old Docker images
if: always()
shell: sh
run: |
set -eu
if [ -f scripts/cleanup_old_images.sh ]; then
chmod +x scripts/cleanup_old_images.sh
scripts/cleanup_old_images.sh
else
echo "Cleanup script not found, doing basic prune..."
docker image prune -f 2>/dev/null || true
fi
set +e
docker image prune -f 2>/dev/null || true
echo "Disk usage after cleanup:"
df -h / | tail -1
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build Production Runtime Images" python3 scripts/ci_notify_failure.py
FAILED_JOB="Build Production API Image" python3 scripts/ci_notify_failure.py
build-prod-worker:
name: Build Production Worker Image
runs-on: saas
timeout-minutes: 25
needs: [validate, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v')
env:
BUILD_ONLY: worker
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'INNERPY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
INNERPY
- name: Build and push production worker image
shell: sh
env:
REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN }
run: |
set -eu
chmod +x scripts/build_release_images.sh
BUILD_ONLY="${BUILD_ONLY}" \
REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
- name: Cleanup old Docker images
if: always()
shell: sh
run: |
set +e
docker image prune -f 2>/dev/null || true
echo "Disk usage after cleanup:"
df -h / | tail -1
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build Production Worker Image" python3 scripts/ci_notify_failure.py
build-prod-web:
name: Build Production Web Image
runs-on: saas
timeout-minutes: 25
needs: [validate, frontend-lint]
if: startsWith(github.ref, 'refs/tags/v')
env:
BUILD_ONLY: web
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'INNERPY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
INNERPY
- name: Build and push production web image
shell: sh
env:
REGISTRY_TOKEN: ${ secrets.REGISTRY_TOKEN }
run: |
set -eu
chmod +x scripts/build_release_images.sh
BUILD_ONLY="${BUILD_ONLY}" \
REGISTRY_TOKEN="${REGISTRY_TOKEN}" \
scripts/build_release_images.sh "${GITHUB_REF_NAME}"
- name: Cleanup old Docker images
if: always()
shell: sh
run: |
set +e
docker image prune -f 2>/dev/null || true
echo "Disk usage after cleanup:"
df -h / | tail -1
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build Production Web Image" python3 scripts/ci_notify_failure.py
deploy-production:
name: Deploy Production
runs-on: saas
timeout-minutes: 20
if: startsWith(github.ref, 'refs/tags/v')
needs: build-production-runtime-images
needs: [build-prod-api, build-prod-worker, build-prod-web]
steps:
- name: Install SSH client
-1
View File
@@ -1 +0,0 @@
"""API application package."""
-1
View File
@@ -1 +0,0 @@
"""API package."""
+48
View File
@@ -0,0 +1,48 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
from typing import Any
from fastapi import HTTPException, status
from packages.application import GetProjectUseCase
from packages.ports.user_repository import UserRepository
def check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限。
合并自 asset_libraries.py / edit_plans.py 的同名函数。
- 空 project_id 直接放行(兼容 edit_plans 中 project_id 可选的场景)
- 错误信息使用中文,与项目其他路由保持一致
"""
if not project_id or not project_id.strip():
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
"""获取用户的订阅计划名称。"""
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
def require_project_and_library(
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
"""Verify project and asset library exist."""
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
+3 -10
View File
@@ -22,18 +22,11 @@ from packages.application import (
)
from packages.domain import AssetLibrary, AssetLibraryKind
from ._helpers import check_project_access
router = APIRouter()
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
def _to_asset_library_response(item) -> AssetLibraryResponse:
return AssetLibraryResponse(
id=item.id,
@@ -168,7 +161,7 @@ def delete_asset_library(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
# 权限校验:检查用户是否有项目访问权限
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
# 删除库内所有素材(无 FK 级联,需手动清理)
assets_in_library = asset_repository.find_by_library(library_id)
+13 -19
View File
@@ -27,6 +27,8 @@ from packages.application import (
)
from packages.domain import AssetStatus, ClassificationStatus
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -72,14 +74,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
)
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
@router.get("", response_model=ListAssetsResponse)
def list_assets(
@@ -136,7 +130,7 @@ def list_assets(
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
check_project_access(library.project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
@@ -152,7 +146,7 @@ def list_assets(
# 模式2:指定 project_id
if project_id:
_check_project_access(project_id, user_id, project_repository)
check_project_access(project_id, user_id, project_repository)
if ft:
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
all_items = asset_repository.find_by_project(project_id)
@@ -210,13 +204,13 @@ def list_assets(
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
check_project_access(library.project_id, user_id, project_repository)
if kind:
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
else:
all_items = asset_repository.find_by_library(library_id)
elif project_id:
_check_project_access(project_id, user_id, project_repository)
check_project_access(project_id, user_id, project_repository)
all_items = asset_repository.find_by_project(project_id)
else:
try:
@@ -262,7 +256,7 @@ def update_asset_review_status(
item = asset_repository.get(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
_apply_asset_review_status(item, request.review_status)
updated = asset_repository.update(item)
return _to_asset_response(updated)
@@ -286,7 +280,7 @@ def batch_delete_assets(
failed_ids.append(asset_id)
continue
try:
_check_project_access(item.project_id, user_id, project_repository)
check_project_access(item.project_id, user_id, project_repository)
deleted_ids.append(asset_id)
except HTTPException:
failed_ids.append(asset_id)
@@ -307,7 +301,7 @@ def get_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
return _to_asset_response(item)
@@ -322,7 +316,7 @@ def update_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
# 合并可修改字段
if request.name is not None:
@@ -346,7 +340,7 @@ def delete_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
asset_repository.delete(asset_id)
@@ -363,7 +357,7 @@ def tag_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
for tag_id in request.tag_ids:
tag = tag_repository.get(tag_id)
if tag is None:
@@ -387,7 +381,7 @@ def untag_asset(
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
item.remove_tag(tag_id)
asset_repository.update(item)
+2 -3
View File
@@ -206,7 +206,7 @@ async def verify_email_post(
return _verify_email_token(request.token, user_repository)
@router.post("/password/forgot", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
async def forgot_password(
request: PasswordResetRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
@@ -223,7 +223,7 @@ async def forgot_password(
return MessageResponse(message="如果账户存在,密码重置邮件已发送")
@router.post("/password/reset", response_model=MessageResponse)
@router.post("/reset-password", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordModel,
user_repository: UserRepository = Depends(get_user_repository),
@@ -243,7 +243,6 @@ async def logout(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""登出 - 将当前 token 加入黑名单"""
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
if credentials:
try:
+3 -19
View File
@@ -14,7 +14,6 @@ from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.config import get_settings
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
@@ -35,6 +34,8 @@ from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -113,22 +114,6 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def _require_project_and_library(
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
"""Verify project and asset library exist"""
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
"""Load upload metadata"""
meta_path = _get_upload_meta_path(upload_id)
@@ -206,7 +191,6 @@ async def init_chunked_upload(
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> ChunkedUploadInitResponse:
"""Initialize chunked upload"""
settings = get_settings()
# Validate file size
if request.file_size > MAX_FILE_SIZE:
@@ -221,7 +205,7 @@ async def init_chunked_upload(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
# Verify asset library
_require_project_and_library(
require_project_and_library(
request.project_id,
request.library_id,
project_repository,
+16 -31
View File
@@ -32,12 +32,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
@@ -51,6 +45,8 @@ from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from ._helpers import check_project_access
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
@@ -247,17 +243,6 @@ class GenerateFromTemplateResponse(BaseModel):
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
if not project_id or not project_id.strip():
return
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="无权访问该项目")
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
@@ -311,7 +296,7 @@ def list_plans(
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
_check_project_access(project_id, current_user.user.id, project_repository)
check_project_access(project_id, current_user.user.id, project_repository)
skip = (page - 1) * page_size
plans = svc.list_plans(
@@ -353,7 +338,7 @@ def get_plan(
)
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
return _to_response(plan)
@@ -369,7 +354,7 @@ def create_plan(
project_id = (body.project_id or "").strip()
# 项目鉴权
if project_id:
_check_project_access(project_id, current_user.user.id, project_repository)
check_project_access(project_id, current_user.user.id, project_repository)
svc = EditPlanService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_plan_config(body.config)
@@ -411,7 +396,7 @@ def update_plan(
if existing is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if existing.project_id:
_check_project_access(existing.project_id, current_user.user.id, project_repository)
check_project_access(existing.project_id, current_user.user.id, project_repository)
# 基础字段更新
try:
@@ -465,7 +450,7 @@ def delete_plan(
# 项目鉴权
existing = svc.get_plan(plan_id)
if existing and existing.project_id:
_check_project_access(existing.project_id, current_user.user.id, project_repository)
check_project_access(existing.project_id, current_user.user.id, project_repository)
deleted = svc.delete_plan(plan_id)
if not deleted:
raise HTTPException(
@@ -507,7 +492,7 @@ def generate_plan(
if plan_check is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan_check.project_id:
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
if plan_check.status == EditPlanStatus.DRAFT:
@@ -710,7 +695,7 @@ def generate_plan(
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception as exc:
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
@@ -749,7 +734,7 @@ def get_generation_status(
plan = gen_status["plan"]
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = gen_status["clips"]
clip_items = [
@@ -791,7 +776,7 @@ def list_plan_generations(
# 验证计划存在 + 项目鉴权
plan = svc.get_plan_or_raise(plan_id)
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
@@ -860,7 +845,7 @@ def ai_recommend_clips(
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 验证状态:只允许 draft 或 editing
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
@@ -909,7 +894,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception as exc:
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
# 尝试回滚未提交的变更
try:
@@ -990,7 +975,7 @@ def generate_cover(
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
# 调用 AI 封面生成服务
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
@@ -1110,7 +1095,7 @@ def get_plan_timeline(
plan = svc.get_plan_or_raise(plan_id)
# 项目鉴权
if plan.project_id:
_check_project_access(plan.project_id, current_user.user.id, project_repository)
check_project_access(plan.project_id, current_user.user.id, project_repository)
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
# 按 order 排序
@@ -1171,7 +1156,7 @@ def generate_from_template(
# 项目鉴权
if body.project_id:
_check_project_access(body.project_id, current_user.user.id, project_repository)
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
+4 -11
View File
@@ -32,6 +32,8 @@ from app.schemas.generation_task import (
)
from fastapi import APIRouter, Depends, HTTPException
from app.api.routes._helpers import check_project_access
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -43,15 +45,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -339,7 +332,7 @@ def get_generation_task(
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
return _to_generation_task_response(task)
@@ -356,7 +349,7 @@ def list_generation_results(
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
responses = []
+6 -13
View File
@@ -20,7 +20,7 @@ from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session, get_job_repository, get_project_repository
from app.dependencies import get_job_repository, get_project_repository
from app.schemas.job import (
CompleteJobRequest,
CreateJobRequest,
@@ -51,6 +51,8 @@ from packages.application.jobs import (
)
from packages.domain.job import JobType
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -66,15 +68,6 @@ _JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
}
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
"""检查用户是否有项目访问权限。"""
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(user_id):
raise HTTPException(status_code=403, detail="Access denied to project")
# ── 创建任务 ──────────────────────────────────────────────────────────────────
@@ -89,7 +82,7 @@ def create_job(
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
"""
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
check_project_access(request.project_id, authenticated_user.user.id, project_repository)
# 校验 job_type
try:
@@ -182,7 +175,7 @@ def list_project_jobs(
offset: int = Query(default=0, ge=0),
) -> ListJobsResponse:
"""获取项目下的任务列表。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = ListJobsUseCase(job_repo)
jobs = use_case.execute(
@@ -204,7 +197,7 @@ def get_job_statistics(
project_repository: Any = Depends(get_project_repository),
) -> JobStatisticsResponse:
"""获取项目任务统计摘要。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = GetJobStatisticsUseCase(job_repo)
stats = use_case.execute(project_id)
+3 -8
View File
@@ -33,6 +33,8 @@ from packages.application.recipe.use_cases import (
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -40,13 +42,6 @@ def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlc
return SQLAlchemyRecipeRepository(session)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
def _item_to_response(item) -> RecipeItemResponse:
return RecipeItemResponse(
id=item.id,
@@ -194,7 +189,7 @@ def use_recipe(
user_repository: UserRepository = Depends(get_user_repository),
) -> UseRecipeResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
plan_name = get_user_plan(user_id, user_repository)
use_case = UseRecipeUseCase(recipe_repository)
try:
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
+1 -1
View File
@@ -232,7 +232,7 @@ async def payment_callback(
# 创建账单记录
record_id = uuid.uuid4().hex
record = repo.create(
repo.create(
{
"id": record_id,
"user_id": user_id,
+3 -8
View File
@@ -28,6 +28,8 @@ from packages.application.title_library.use_cases import (
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -51,13 +53,6 @@ def _to_response(item) -> TitleLibraryItemResponse:
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
@router.get("", response_model=ListTitleLibraryResponse)
def list_titles(
category: Optional[str] = Query(None),
@@ -98,7 +93,7 @@ def create_title(
user_repository: UserRepository = Depends(get_user_repository),
) -> TitleLibraryItemResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
plan_name = get_user_plan(user_id, user_repository)
command = CreateTitleLibraryCommand(
user_id=user_id,
name=request.name,
+7 -21
View File
@@ -1,5 +1,5 @@
import logging
from typing import Annotated, Any
from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
@@ -17,12 +17,13 @@ from app.schemas.upload import (
DirectUploadCompleteResponse,
DirectUploadPrepareRequest,
DirectUploadPrepareResponse,
UploadAssetRequest,
UploadAssetResponse,
)
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
logger = logging.getLogger(__name__)
@@ -80,21 +81,6 @@ def _validate_mime_type(content_type: str | None) -> str:
return base_type
def _require_project_and_library(
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.find_by_project(project_id)
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def _submit_ingest_job(
project_id: str,
library_id: str,
@@ -135,7 +121,7 @@ async def prepare_direct_upload(
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(request.content_type)
_require_project_and_library(
require_project_and_library(
request.project_id,
request.library_id,
project_repository,
@@ -183,7 +169,7 @@ async def complete_direct_upload(
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
_require_project_and_library(
require_project_and_library(
request.project_id,
request.library_id,
project_repository,
@@ -252,7 +238,7 @@ async def upload_asset(
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
if file_hash:
-1
View File
@@ -28,7 +28,6 @@ from packages.application.voice_clone.use_cases import (
VoiceCloneNotRetryableError,
)
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowError,
VoiceCloneWorkflowService,
)
+3 -8
View File
@@ -39,6 +39,8 @@ from packages.application.voice_library.use_cases import (
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -125,13 +127,6 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
# ==================== 统一配音列表(预置 + 克隆)====================
@@ -271,7 +266,7 @@ def create_voice(
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
plan_name = get_user_plan(user_id, user_repository)
command = CreateVoiceLibraryCommand(
user_id=user_id,
name=request.name,
+6 -1
View File
@@ -25,7 +25,7 @@ class Settings(BaseSettings):
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
DATABASE_POOL_TIMEOUT: int = 30
DATABASE_POOL_RECYLE: int = 3600
DATABASE_POOL_RECYCLE: int = 3600
USE_IN_MEMORY_DB: bool = False
AUTO_CREATE_SCHEMA: bool = False
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
# 密钥轮换天数(到达此天数后建议更换密钥)
SECRET_ROTATION_DAYS: int = 90
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
@field_validator("JWT_SECRET_KEY", mode="before")
@classmethod
def validate_jwt_secret_key(cls, v):
-1
View File
@@ -1 +0,0 @@
"""Core configuration package."""
-12
View File
@@ -50,20 +50,8 @@ from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
SQLAlchemyVoiceLibraryRepository,
)
from packages.ports.asset_library_repository import AssetLibraryRepository
from packages.ports.asset_repository import AssetRepository
from packages.ports.classification_job_repository import ClassificationJobRepository
from packages.ports.duplication_repository import DuplicationRecordRepository
from packages.ports.generated_video_repository import GeneratedVideoRepository
from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.ingest_job_repository import IngestJobRepository
from packages.ports.job_repository import JobRepository
from packages.ports.project_repository import ProjectRepository
from packages.ports.tag_repository import TagRepository
from packages.ports.title_library_repository import TitleLibraryRepository
from packages.ports.user_repository import UserRepository
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
from packages.ports.voice_library_repository import VoiceLibraryRepository
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from app.auth import AuthenticatedUser
from app.auth import get_current_user as get_authenticated_user
from app.dependencies import get_user_repository
from fastapi import Depends
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.domain.entities import User
+1 -1
View File
@@ -6,7 +6,7 @@ import logging
import time
from typing import Callable
from fastapi import Request, Response
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
+1 -1
View File
@@ -24,7 +24,7 @@ from packages.adapters.sqlalchemy_impl import (
)
from packages.domain.asset import AssetType
from packages.domain.classification import AssetClassification
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.edit_plan_clip import EditPlanClip
logger = logging.getLogger(__name__)
@@ -18,7 +18,6 @@ from packages.adapters.sqlalchemy_impl import (
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.generation_task import GenerationTaskStatus
logger = logging.getLogger(__name__)
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session
from packages.application.jobs import (
CancelJobUseCase,
@@ -13,7 +13,7 @@
from __future__ import annotations
import logging
from typing import Any, List, Optional
from typing import Any, List
from sqlalchemy.orm import Session
@@ -16,7 +16,6 @@ FFmpeg 视频合成编排服务:
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from typing import Any
@@ -28,7 +27,7 @@ from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect
-42
View File
@@ -1,42 +0,0 @@
/**
* 仪表盘 API
* Phase 1 新增:用户仪表盘概览
*/
import apiClient from "./client";
/** 仪表盘概览数据 */
export interface DashboardOverview {
/** 素材总数 */
total_assets: number;
/** 已用存储(字节) */
used_storage_bytes: number;
/** 总标题数 */
total_titles: number;
/** 总配音数 */
total_voices: number;
/** 生成任务总数 */
total_tasks: number;
/** 成品总数 */
total_products: number;
/** 最近生成任务 */
recent_tasks: Array<{
id: string;
task_type: string;
status: string;
progress: number;
user_message: string;
created_at: string;
}>;
/** 订阅信息 */
subscription: {
plan: "free" | "pro" | "enterprise";
status: "active" | "inactive" | "expired";
expires_at?: string;
};
}
/** 获取仪表盘概览数据 */
export const getDashboardOverview = async (): Promise<DashboardOverview> => {
const response = await apiClient.get("/dashboard/overview");
return response.data;
};
@@ -1,365 +0,0 @@
/* V21 业务组件统一样式 */
/* ==================== 按钮 ==================== */
.xx-primary-btn {
background: var(--gradient-primary) !important;
color: var(--text-inverse) !important;
border: none !important;
border-radius: var(--radius-md) !important;
padding: 10px 20px !important;
font-weight: var(--font-weight-bold) !important;
box-shadow: var(--shadow-primary) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-primary-btn:hover {
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: transparent !important;
color: var(--primary-color) !important;
border: 2px solid var(--primary-color) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-sm) 18px !important;
font-weight: var(--font-weight-bold) !important;
transition: var(--transition-all) !important;
cursor: pointer;
height: auto !important;
}
.xx-ghost-btn:hover {
background: var(--primary-soft) !important;
}
/* ==================== 卡片 ==================== */
.xx-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
margin-bottom: 20px;
transition: all var(--transition-slow);
}
.xx-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* ==================== 页面结构 ==================== */
.xx-page {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-lg);
}
.xx-page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
margin-bottom: 28px;
}
.xx-page-head h2 {
font-size: 26px;
font-weight: var(--font-weight-extrabold);
color: var(--text-primary);
margin: 0 0 var(--space-sm);
}
.xx-page-head p {
font-size: var(--font-size-base);
color: var(--text-secondary);
margin: 0;
}
/* ==================== 表格样式 ==================== */
.xx-table-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
padding: 20px;
overflow: hidden;
}
/* 表格包装器 */
.xx-table-wrapper {
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ==================== 标签/Tag ==================== */
.xx-tag {
padding: var(--space-xs) 12px;
border-radius: var(--radius-xs);
font-size: 13px;
font-weight: var(--font-weight-medium);
}
.xx-tag-indigo {
background: var(--primary-soft);
color: var(--primary-color);
border: 1px solid var(--color-primary-200);
}
.xx-tag-success {
background: var(--success-soft);
color: var(--color-secondary-500);
border: 1px solid var(--success-border);
}
.xx-tag-warning {
background: var(--warning-soft);
color: var(--accent-dark);
border: 1px solid var(--color-accent-200);
}
.xx-tag-error {
background: var(--error-soft);
color: var(--error-color);
border: 1px solid var(--error-border);
}
/* ==================== 搜索栏 ==================== */
.xx-search-bar {
margin-bottom: 20px;
}
.xx-search-input {
width: 100%;
padding: 12px 18px;
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--bg-primary);
transition: var(--transition-all);
outline: none;
}
.xx-search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== Modal ==================== */
.xx-modal .ant-modal-content {
border-radius: var(--radius-xl);
padding: var(--space-lg);
}
.xx-modal .ant-modal-header {
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
padding: 20px var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.xx-modal .ant-modal-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-modal .ant-modal-footer {
border-top: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
}
/* ==================== 空状态 ==================== */
.xx-empty-state {
text-align: center;
padding: var(--space-3xl) var(--space-lg);
color: var(--text-secondary);
}
.xx-empty-state-icon {
font-size: 48px;
margin-bottom: var(--space-md);
}
/* ==================== 网格布局 ==================== */
.xx-grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.xx-grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.xx-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
@media (max-width: 768px) {
.xx-grid-2,
.xx-grid-3,
.xx-grid-4 {
grid-template-columns: 1fr;
}
}
/* ==================== 配额展示 ==================== */
.xx-quota-item {
padding: 20px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
transition: var(--transition-all);
}
.xx-quota-item:hover {
border-color: var(--primary-color);
box-shadow: 0 8px 24px
color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ==================== 进度条 ==================== */
.xx-progress {
margin-top: 12px;
}
/* ==================== Ant Design 覆盖样式 ==================== */
/* Table overrides */
.ant-table-wrapper .ant-table-thead > tr > th {
background: var(--bg-secondary) !important;
font-weight: var(--font-weight-bold) !important;
color: var(--text-primary) !important;
border-bottom: 2px solid var(--border-color) !important;
padding: 14px var(--space-md) !important;
}
.ant-table-wrapper .ant-table-tbody > tr > td {
padding: 14px var(--space-md) !important;
border-bottom: 1px solid var(--color-gray-100) !important;
}
.ant-table-wrapper .ant-table-tbody > tr:hover > td {
background: var(--color-gray-50) !important;
}
/* Card overrides */
.ant-card {
border-radius: var(--radius-xl) !important;
border: 1px solid var(--border-color) !important;
}
.ant-card-head {
border-bottom: 1px solid var(--border-color) !important;
min-height: 52px !important;
padding: 0 var(--space-lg) !important;
}
.ant-card-head-title {
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-md) !important;
color: var(--text-primary) !important;
}
.ant-card-body {
padding: 20px var(--space-lg) !important;
}
/* Modal overrides */
.ant-modal-content {
border-radius: var(--radius-xl) !important;
overflow: hidden;
}
.ant-modal-header {
padding: 20px var(--space-lg) !important;
background: var(--bg-primary) !important;
}
.ant-modal-title {
font-weight: var(--font-weight-bold) !important;
font-size: var(--font-size-lg) !important;
color: var(--text-primary) !important;
}
.ant-modal-body {
padding: var(--space-lg) !important;
}
.ant-modal-footer {
padding: var(--space-md) var(--space-lg) !important;
}
/* Button overrides */
.ant-btn-primary {
background: var(--gradient-primary) !important;
border: none !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow-primary) !important;
height: auto !important;
padding: 10px 20px !important;
font-weight: var(--font-weight-bold) !important;
}
.ant-btn-primary:hover {
background: var(--gradient-primary) !important;
box-shadow: var(--shadow-hover) !important;
transform: translateY(-1px);
}
/* Tag overrides */
.ant-tag {
border-radius: var(--radius-xs) !important;
padding: var(--space-xs) 12px !important;
font-weight: var(--font-weight-medium) !important;
}
/* Select overrides */
.ant-select-selector {
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
}
.ant-select:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary-color) !important;
}
.ant-select-focused .ant-select-selector {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Input overrides */
.ant-input {
border-radius: var(--radius-md) !important;
border-color: var(--border-color) !important;
padding: 10px 14px !important;
}
.ant-input:hover {
border-color: var(--primary-color) !important;
}
.ant-input:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
}
/* Progress overrides */
.ant-progress-inner {
background: var(--color-gray-100) !important;
border-radius: var(--radius-xs) !important;
}
.ant-progress-bg {
border-radius: var(--radius-xs) !important;
}
-211
View File
@@ -1,211 +0,0 @@
/**
* 统一导航配置
* Header 和 Sidebar 共用此数据源
*/
import React from "react";
import {
DashboardOutlined,
VideoCameraOutlined,
FileOutlined,
AudioOutlined,
FileTextOutlined,
TrophyOutlined,
AppstoreOutlined,
HistoryOutlined,
ControlOutlined,
CrownOutlined,
ScanOutlined,
EditOutlined,
FolderOutlined,
} from "@ant-design/icons";
/** 导航项定义 */
export interface NavItem {
key: string;
label: string;
path: string;
icon: React.ReactNode;
}
/** 导航分组定义 */
export interface NavGroup {
title: string;
items: NavItem[];
}
/**
* 扁平导航列表(Header 使用)
*/
export const NAV_ITEMS: NavItem[] = [
{
key: "dashboard",
label: "概览",
path: "/app/dashboard",
icon: <DashboardOutlined />,
},
{
key: "assets",
label: "素材库",
path: "/app/assets",
icon: <FileOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/app/titles",
icon: <FileTextOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/app/voices",
icon: <AudioOutlined />,
},
{
key: "voice-clone",
label: "我的音色",
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/app/templates",
icon: <AppstoreOutlined />,
},
{
key: "editing-planner",
label: "剪辑编辑器",
path: "/app/editing-planner",
icon: <EditOutlined />,
},
{
key: "my-templates",
label: "我的模板",
path: "/app/my-templates",
icon: <FolderOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/app/generate",
icon: <VideoCameraOutlined />,
},
{
key: "history",
label: "任务历史",
path: "/app/history",
icon: <HistoryOutlined />,
},
{
key: "products",
label: "成品库",
path: "/app/products",
icon: <TrophyOutlined />,
},
{
key: "duplication",
label: "查重",
path: "/app/duplication",
icon: <ScanOutlined />,
},
];
/**
* 分组导航列表(Sidebar 使用)
*/
export const NAV_GROUPS: NavGroup[] = [
{
title: "创作工具",
items: [
{
key: "dashboard",
label: "首页",
path: "/app/dashboard",
icon: <DashboardOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/app/generate",
icon: <VideoCameraOutlined />,
},
],
},
{
title: "资源管理",
items: [
{
key: "assets",
label: "素材库",
path: "/app/assets",
icon: <FileOutlined />,
},
{
key: "voices",
label: "配音库",
path: "/app/voices",
icon: <AudioOutlined />,
},
{
key: "voice-clone",
label: "我的音色",
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
path: "/app/titles",
icon: <FileTextOutlined />,
},
{
key: "products",
label: "成片库",
path: "/app/products",
icon: <TrophyOutlined />,
},
{
key: "templates",
label: "模板库",
path: "/app/templates",
icon: <AppstoreOutlined />,
},
],
},
{
title: "系统",
items: [
{
key: "history",
label: "任务历史",
path: "/app/history",
icon: <HistoryOutlined />,
},
{
key: "admin",
label: "控制台",
path: "/app/admin",
icon: <ControlOutlined />,
},
{
key: "subscription",
label: "订阅管理",
path: "/app/subscription",
icon: <CrownOutlined />,
},
],
},
];
-40
View File
@@ -75,35 +75,6 @@
margin: 0;
}
/* V21 卡片 */
.xx-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
transition: all 0.3s;
}
.xx-card:hover {
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.xx-card .ant-card-head {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-card .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--slate, #0f172a);
}
.xx-card .ant-card-body {
padding: 24px;
}
/* 统计卡片网格 - 4列 */
.xx-grid-4 {
display: grid;
@@ -302,17 +273,6 @@
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
}
/* V21 Select */
.xx-select {
border-radius: var(--radius-md) !important;
}
.xx-select:hover,
.xx-select:focus {
border-color: var(--indigo, #4f46e5) !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
}
/* V21 Tag */
.xx-tag {
border-radius: var(--radius-xs) !important;
-48
View File
@@ -612,54 +612,6 @@
flex: 1;
}
/* ============================================================
按钮(匹配原型 .btn .ghost / .btn .primary
============================================================ */
.xx-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 42px;
padding: 0 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
border: none;
outline: none;
white-space: nowrap;
}
.xx-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.xx-btn-primary {
background: var(--gradient-primary);
color: var(--text-inverse);
box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22);
}
.xx-btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28);
}
.xx-btn-ghost {
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.xx-btn-ghost:hover:not(:disabled) {
border-color: var(--info-border);
color: var(--primary-dark);
background: var(--primary-soft);
}
/* ============================================================
右侧预览区 generate-preview
============================================================ */
-7
View File
@@ -170,13 +170,6 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
},
{
path: "accounts",
lazy: () =>
+31 -15
View File
@@ -113,8 +113,8 @@ from video_processing.oss_helpers import (
get_signed_download_url,
upload_to_oss,
)
from video_processing.unified_render_service import UnifiedRenderService
from video_processing.render_engine_resolver import ENGINE_LEGACY, ENGINE_UNIFIED
from video_processing.unified_render_service import UnifiedRenderService
# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ────────────────────────────────
@@ -619,7 +619,8 @@ def _render_with_legacy_engine(
import subprocess
main_clips = [
c for c in virtual_clips
c
for c in virtual_clips
if c.clip_type in ("main", "b_roll", "background")
or (c.clip_type == "main" and c.config.get("role") == "b_roll")
]
@@ -674,17 +675,27 @@ def _render_with_legacy_engine(
filter_complex = ";".join(fc_parts)
command = [
FFMPEG_BIN, "-y",
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex", filter_complex,
"-map", video_label,
"-map", audio_label,
"-c:v", "libx264",
"-crf", "23",
"-preset", "medium",
"-c:a", "aac",
"-b:a", "192k",
"-movflags", "+faststart",
"-filter_complex",
filter_complex,
"-map",
video_label,
"-map",
audio_label,
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
str(output_path),
]
@@ -694,7 +705,9 @@ def _render_with_legacy_engine(
except subprocess.CalledProcessError as e:
logger.error(
"[task_id=%s] [渲染] legacy 引擎 FFmpeg 失败: %s\nfilter_complex: %s",
task_id, e, filter_complex[:500],
task_id,
e,
filter_complex[:500],
)
raise
@@ -880,7 +893,9 @@ def generate_video(self, task_id: str) -> dict:
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] legacy 引擎完成: 耗时=%.1fs, 时长=%.2fs",
task_id, render_elapsed, render_duration,
task_id,
render_elapsed,
render_duration,
)
else:
# 新引擎:UnifiedRenderService 图层架构
@@ -901,7 +916,8 @@ def generate_video(self, task_id: str) -> dict:
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
task_id, render_elapsed,
task_id,
render_elapsed,
)
if gen_task:
-1
View File
@@ -1 +0,0 @@
"""Packages root."""
-1
View File
@@ -1 +0,0 @@
"""Adapters package for external implementations."""
+1 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint, create_engine
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import declarative_base
Base = declarative_base()
-1
View File
@@ -27,7 +27,6 @@ from .generated_videos import (
from .generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
GetGenerationTaskUseCase,
)
from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase
from .jobs import (
+1 -2
View File
@@ -12,10 +12,9 @@ JWT 处理器委托层
payload = jwt_handler.verify_access_token(token)
"""
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
from packages.application.auth.jwt_service import JWTConfig, JWTService
class JWTHandler:
+4 -4
View File
@@ -208,10 +208,10 @@ def _get_jwt_service():
kw = dict(secret_key=settings.JWT_SECRET_KEY)
if hasattr(settings, "JWT_ALGORITHM"):
kw["algorithm"] = settings.JWT_ALGORITHM
if hasattr(settings, "ACCESS_TOKEN_EXPIRE_MINUTES"):
kw["access_token_expire_minutes"] = settings.ACCESS_TOKEN_EXPIRE_MINUTES
if hasattr(settings, "REFRESH_TOKEN_EXPIRE_DAYS"):
kw["refresh_token_expire_days"] = settings.REFRESH_TOKEN_EXPIRE_DAYS
if hasattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_MINUTES"):
kw["access_token_expire_minutes"] = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
if hasattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_DAYS"):
kw["refresh_token_expire_days"] = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
_jwt_service_instance = JWTService(JWTConfig(**kw))
return _jwt_service_instance
+1 -1
View File
@@ -293,7 +293,7 @@ class LogoutUseCase:
try:
if request.logout_all_devices:
# 删除所有设备的 session
count = self.session_store.delete_all_user_sessions(request.user_id)
self.session_store.delete_all_user_sessions(request.user_id)
return True, None
else:
# 删除当前 session
@@ -85,8 +85,6 @@ class PasswordHasher:
True 如果需要重新哈希
"""
try:
hashed_bytes = hashed_password.encode("utf-8")
current_rounds = bcrypt.getsalt(hashed_bytes)
# 提取当前的 cost factor
# bcrypt hash 格式: $2b$rounds$salt+hash
@@ -3,7 +3,7 @@
"""
import secrets
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
+1 -1
View File
@@ -3,7 +3,7 @@
"""
from math import ceil
from typing import Generic, List, Optional, TypeVar
from typing import Generic, List, TypeVar
from pydantic import BaseModel, Field
-2
View File
@@ -7,9 +7,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from packages.domain.job import Job, JobStatus, JobType
from packages.ports.job_repository import JobRepository
-1
View File
@@ -9,7 +9,6 @@ from typing import List, Optional
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.domain.recipe import Recipe, RecipeItem
-1
View File
@@ -1 +0,0 @@
"""TTS Job application layer."""
@@ -148,7 +148,6 @@ class TTSStreamingService:
# 并发合成所有分段,按顺序流式推送
queue: asyncio.Queue[tuple[int, Optional[bytes], Optional[str]]] = asyncio.Queue()
completed_count = 0
async def _synthesize_one(idx: int, seg_text: str) -> None:
"""合成单个分段并放入队列。"""
+1 -1
View File
@@ -24,7 +24,7 @@ from packages.application.cosyvoice_service import (
CosyVoiceError,
CosyVoiceService,
)
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
from packages.application.tts_job.audio_merger import AudioMerger
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.ports.tts_job_repository import TTSJobRepository
@@ -2,7 +2,6 @@
from __future__ import annotations
import uuid
from typing import List, Optional
from packages.domain.voice_clone_profile import VoiceCloneProfile
+2 -3
View File
@@ -10,7 +10,7 @@
from __future__ import annotations
import logging
from typing import Any, Optional
from typing import Optional
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
@@ -21,9 +21,8 @@ from packages.application.voice_clone.use_cases import (
CreateVoiceCloneUseCase,
RetryVoiceCloneUseCase,
VoiceCloneNotFoundError,
VoiceCloneNotRetryableError,
)
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
from packages.domain.voice_clone_profile import VoiceCloneProfile
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
logger = logging.getLogger(__name__)
-1
View File
@@ -13,7 +13,6 @@ else:
pass
from typing import Any
from uuid import uuid4
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import copy
from enum import Enum
from typing import List, Optional
from typing import Optional
from pydantic import BaseModel, Field
+1 -1
View File
@@ -24,7 +24,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Set
from typing import Dict, Optional
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.domain import AssetLibrary
class AssetLibraryRepository(ABC):
+83 -77
View File
@@ -17,6 +17,12 @@ case "$BUILD_ENV" in
esac
echo "Build environment: $BUILD_ENV → nginx config: $NGINX_CONF_FILE"
# 可选:只构建指定镜像(api|worker|web),空则全部构建
BUILD_ONLY="${BUILD_ONLY:-}"
if [ -n "$BUILD_ONLY" ]; then
echo "Build mode: only $BUILD_ONLY"
fi
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT_DIR"
@@ -60,26 +66,22 @@ fi
# 默认只读不写,防止 feature 分支污染主缓存
# 只有 develop/main 分支才写回缓存
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
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},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
-f infra/docker/api.Dockerfile \
-t "$API_IMAGE" -t "$API_LATEST" \
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
fi
case "$BRANCH_NAME" in
develop|main) CACHE_WRITE=1 ; echo "Cache mode: read+write (branch: $BRANCH_NAME)" ;;
*) CACHE_WRITE=0 ; echo "Cache mode: read-only (branch: $BRANCH_NAME)" ;;
esac
# ---- 构建函数:使用 buildx 直接 push 到 registry ----
# 不再使用 --load → docker tag → docker push 的串行方式
# buildx --push 直接从 buildkit 推送,省去序列化导入 daemon 的开销
build_with_cache() {
# usage: build_with_cache <image_name> <dockerfile> <extra_args...>
# usage: build_with_cache <image_name> <dockerfile> [extra_args...]
IMG_NAME="$1"
DOCKERFILE="$2"
shift 2
EXTRA_ARGS="$*"
REGISTRY_IMG="${REGISTRY}/xiaoxia-saas-${IMG_NAME}:$VERSION"
CACHE_FROM="type=registry,ref=${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY},ignore-error=true"
if [ "$CACHE_WRITE" -eq 1 ]; then
@@ -90,96 +92,100 @@ build_with_cache() {
echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
fi
if [ "$USE_CACHE" -eq 1 ]; then
if [ "$USE_CACHE" -eq 1 ] && [ "$USE_PUSH" -eq 1 ]; then
if [ -n "$CACHE_TO" ]; then
docker buildx build \
$EXTRA_ARGS \
--cache-from "$CACHE_FROM" \
--cache-to "$CACHE_TO" \
-f "$DOCKERFILE" \
-t "$IMG_NAME:$VERSION" \
--load \
-t "$REGISTRY_IMG" \
--push \
.
else
docker buildx build \
$EXTRA_ARGS \
--cache-from "$CACHE_FROM" \
-f "$DOCKERFILE" \
-t "$IMG_NAME:$VERSION" \
--load \
-t "$REGISTRY_IMG" \
--push \
.
fi
echo " ✅ Pushed: $REGISTRY_IMG"
else
docker build --pull=false $EXTRA_ARGS -f "$DOCKERFILE" -t "$IMG_NAME:$VERSION" .
# 无push权限时,本地构建用于测试
docker build --pull=false $EXTRA_ARGS -f "$DOCKERFILE" -t "xiaoxia-saas-${IMG_NAME}:$VERSION" .
echo " ✅ Built locally: xiaoxia-saas-${IMG_NAME}:$VERSION"
fi
}
echo "=== Building API image ==="
build_with_cache "api" "infra/docker/api.Dockerfile" \
"--build-arg APP_VERSION=$VERSION"
docker tag "$API_IMAGE" "$API_LATEST"
should_build() {
# usage: should_build <image_name>
# 返回0表示需要构建,1表示跳过
local img="$1"
if [ -z "$BUILD_ONLY" ]; then
return 0 # 全部构建
fi
if [ "$BUILD_ONLY" = "$img" ]; then
return 0
fi
return 1
}
echo "=== Building Worker image ==="
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},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
-f infra/docker/worker.Dockerfile \
-t "$WORKER_IMAGE" -t "$WORKER_LATEST" \
--load \
.
else
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
if should_build "api"; then
echo ""
echo "=== Building API image ==="
build_with_cache "api" "infra/docker/api.Dockerfile" \
"--build-arg APP_VERSION=$VERSION"
# 本地也打一个 :dev 标签方便本地引用(如果有本地镜像的话)
if [ "$USE_PUSH" -eq 0 ]; then
docker tag "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
fi
fi
echo "=== Building Web image (with buildx cache) ==="
# 先构建前端产物(使用持久化 npm 缓存卷)
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo " Created npm cache volume: $NPM_CACHE_VOLUME"
if should_build "worker"; then
echo ""
echo "=== Building Worker image ==="
build_with_cache "worker" "infra/docker/worker.Dockerfile" \
"--build-arg APP_VERSION=$VERSION"
if [ "$USE_PUSH" -eq 0 ]; then
docker tag "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
fi
fi
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm ci && npm run build"
if should_build "web"; then
echo ""
echo "=== Building Web image ==="
# 先构建前端产物(使用持久化 npm 缓存卷)
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo " Created npm cache volume: $NPM_CACHE_VOLUME"
fi
test -f apps/web/dist/index.html
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm ci && npm run build"
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
.
fi
test -f apps/web/dist/index.html
# Push 到 Registry
if [ "$USE_PUSH" -eq 1 ]; then
echo "=== Pushing 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"
else
echo "Registry push skipped (no auth token available)"
build_with_cache "web" "infra/docker/web-artifact.Dockerfile" \
"--build-arg NGINX_CONF=$NGINX_CONF_FILE"
fi
echo ""
echo "=== Build complete ==="
docker images | grep "xiaoxia-saas.*:$VERSION"
if [ "$USE_PUSH" -eq 1 ]; then
echo "Images pushed to $REGISTRY:"
should_build "api" && echo " - $REGISTRY_API"
should_build "worker" && echo " - $REGISTRY_WORKER"
should_build "web" && echo " - $REGISTRY_WEB"
else
echo "Images built locally (registry push skipped)"
docker images | grep "xiaoxia-saas.*:$VERSION" || true
fi
+1
View File
@@ -247,3 +247,4 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
+67 -23
View File
@@ -19,9 +19,10 @@ from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from pathlib import Path
import pytest
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
@@ -68,11 +69,12 @@ class _TestClip:
def test_resolver_unified_when_enabled_100_percent():
"""flag 全局开启(percentage=100)时,返回 unified。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=100))
@@ -83,11 +85,12 @@ def test_resolver_unified_when_enabled_100_percent():
def test_resolver_legacy_when_flag_disabled():
"""flag 全局关闭时,返回默认引擎 legacy。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=False, percentage=100))
@@ -98,11 +101,12 @@ def test_resolver_legacy_when_flag_disabled():
def test_resolver_whitelist_overrides_percentage_0():
"""白名单用户即使 percentage=0 也走 unified。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(
@@ -121,11 +125,12 @@ def test_resolver_whitelist_overrides_percentage_0():
def test_resolver_percentage_0_all_legacy():
"""percentage=0 且无白名单时,全部走 legacy。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=True, percentage=0))
@@ -137,11 +142,12 @@ def test_resolver_percentage_0_all_legacy():
def test_resolver_default_unified_when_flag_off():
"""默认引擎设为 unified 且 flag 关闭时,返回 unified。"""
from video_processing.render_engine_resolver import RenderEngineResolver
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
)
from video_processing.render_engine_resolver import RenderEngineResolver
store = InMemoryFeatureFlagStore()
store.set(FeatureFlagConfig(name="render_engine", enabled=False, percentage=0))
@@ -170,11 +176,23 @@ def test_legacy_engine_single_clip_keeps_original_fps():
# 生成 1 秒 30fps 测试视频(带音频)
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=red:s=640x360:d=1:r=30",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo:d=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=640x360:d=1:r=30",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-shortest",
str(input_path),
],
check=True,
@@ -222,11 +240,23 @@ def test_legacy_engine_two_clips_concat_duration():
color = "red" if idx == 0 else "blue"
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c={color}:s=640x360:d=1:r=30",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo:d=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
f"color=c={color}:s=640x360:d=1:r=30",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-shortest",
str(inp),
],
check=True,
@@ -264,11 +294,23 @@ def test_legacy_engine_broll_mode_supported():
subprocess.run(
[
"ffmpeg", "-y",
"-f", "lavfi", "-i", "color=c=green:s=640x360:d=1:r=30",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo:d=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=c=green:s=640x360:d=1:r=30",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-shortest",
str(input_path),
],
check=True,
@@ -276,8 +318,10 @@ def test_legacy_engine_broll_mode_supported():
)
clip = _TestClip(
asset_id="asset-1", duration=1.0,
clip_type="main", config={"role": "b_roll"},
asset_id="asset-1",
duration=1.0,
clip_type="main",
config={"role": "b_roll"},
)
asset_path_map = {"asset-1": input_path}