Compare commits

..

3 Commits

Author SHA1 Message Date
用户CI Test 717f239f1b feat(ci): 封装checkout逻辑为公共脚本scripts/ci_checkout.sh
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
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
2026-07-09 15:29:47 +08:00
用户CI Test 014949e6b1 feat(ci): 添加pip和npm依赖缓存加速CI执行 2026-07-09 15:29:47 +08:00
用户CI Test 531a2024b0 feat(ci): 添加workflow_dispatch手动触发支持 2026-07-09 15:29:47 +08:00
231 changed files with 11036 additions and 18726 deletions
+4 -12
View File
@@ -3,7 +3,6 @@
# ==================== 应用配置 ====================
APP_NAME=小虾 SaaS
APP_BASE_URL=http://localhost:3000
APP_ENV=development
# ==================== 数据库配置 ====================
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
@@ -36,8 +35,7 @@ ENVIRONMENT=development
DEBUG=true
# ==================== CORS 配置 ====================
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# ==================== 阿里云 OSS 配置 ====================
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
@@ -46,15 +44,9 @@ OSS_ACCESS_KEY_SECRET=your-access-key-secret
OSS_BUCKET_NAME=xiaoxia-autocut
# ==================== CosyVoice 语音合成配置 ====================
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
COSYVOICE_API_KEY=your-cosyvoice-api-key
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
COSYVOICE_VOICE=longxiaochun_v3
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
Executable → Regular
+4 -8
View File
@@ -5,6 +5,7 @@ APP_ENV=production
ENVIRONMENT=production
DEBUG=false
USE_IN_MEMORY_DB=false
LOG_LEVEL=WARNING
# ==================== 数据库(必须修改)====================
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
@@ -41,15 +42,10 @@ OSS_DIRECT_UPLOAD_MAX_MB=2000
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
# ==================== CosyVoice 语音合成(必须配置)====================
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
# cosyvoice-v3-plus (高质量,系统音色少)
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
COSYVOICE_MODEL=cosyvoice-v3-flash
COSYVOICE_VOICE=longxiaochun_v3
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
COSYVOICE_MODEL=cosyvoice-v1
COSYVOICE_VOICE=longxiaochun
COSYVOICE_SAMPLE_RATE=22050
COSYVOICE_FORMAT=mp3
-1
View File
@@ -2,7 +2,6 @@
max-line-length = 120
exclude =
.git,
.cache,
__pycache__,
.venv,
venv,
+26
View File
@@ -0,0 +1,26 @@
name: Auto Merge PRs
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
auto-merge:
runs-on: saas
timeout-minutes: 10
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
bash scripts/ci_checkout.sh
- name: Auto merge develop PRs
run: |
bash scripts/auto_merge_prs.sh develop
- name: Auto merge main PRs (release only)
run: |
bash scripts/auto_merge_prs.sh main
+69 -586
View File
@@ -15,18 +15,15 @@ on:
branches:
- main
- develop
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
name: Validate Code Quality And Tests
runs-on: host
runs-on: ubuntu-22.04
timeout-minutes: 10
env:
@@ -40,61 +37,30 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Verify CI environment
shell: sh
run: |
set -eu
python3 --version
python --version
python3 -m pip --version
echo "CI environment is ready"
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
shell: sh
run: |
set -eu
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m black --version
python3 -m isort --version-number
python3 -m flake8 --version
@@ -162,195 +128,13 @@ jobs:
python3 scripts/check_migration_safety.py --allow-medium-risk
fi
unit-tests:
name: Unit Tests
runs-on: host
timeout-minutes: 8
env:
USE_IN_MEMORY_DB: "true"
steps:
- name: Checkout code
- name: Run unit tests
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
USE_IN_MEMORY_DB: "true"
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Install dependencies
shell: sh
run: |
set -eu
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
pytest --version
- name: Run unit tests with coverage
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest tests/unit -q
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=60 > /dev/null
- name: CI failure notification
if: failure()
shell: sh
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
run: |
set +e
FAILED_JOB="Unit Tests" python3 scripts/ci_notify_failure.py
integration-tests:
name: Integration Tests
runs-on: host
timeout-minutes: 20
if: always()
needs: validate
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: "false"
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Verify CI environment
shell: sh
run: |
set -eu
python3 --version
python3 -m pip --version
echo "CI environment is ready"
- name: Install dependencies
shell: sh
run: |
set -eu
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
pytest --version
- name: Start Redis
shell: sh
run: |
set -eu
REDIS_CONTAINER="ci-redis-${GITHUB_RUN_ID:-$$}"
echo "REDIS_CONTAINER=$REDIS_CONTAINER" >> "$GITHUB_ENV"
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" \
-P \
--health-cmd "redis-cli ping" \
--health-interval 2s \
--health-timeout 2s \
--health-retries 10 \
redis:7-alpine
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | cut -d: -f2)
echo "Redis port: $REDIS_PORT"
echo "REDIS_URL=redis://127.0.0.1:$REDIS_PORT/0" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
if docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" 2>/dev/null | grep -q healthy; then
echo "Redis is ready on port $REDIS_PORT"
break
fi
echo "Waiting for Redis... ($i/15)"
sleep 2
done
docker inspect --format='{{.State.Health.Status}}' "$REDIS_CONTAINER" | grep -q healthy
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q
- name: Start PostgreSQL for integration tests
shell: sh
@@ -394,15 +178,8 @@ jobs:
shell: sh
run: |
set -eu
pip install --break-system-packages -q pytest-rerunfailures
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
--branch \
-m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
python3 -m coverage report --show-missing
python3 -m coverage xml -o coverage.xml
python3 -m coverage report --fail-under=40 > /dev/null # 集成测试覆盖率门槛较低,核心目标是功能验证
pip install -q pytest-rerunfailures
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
- name: Run API performance baseline tests
shell: sh
@@ -446,44 +223,25 @@ jobs:
exit 0
- name: Cleanup PostgreSQL & Redis
- name: Cleanup PostgreSQL
if: always()
shell: sh
run: |
docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
docker rm -f "${REDIS_CONTAINER:-ci-redis-int}" 2>/dev/null || true
echo "PostgreSQL container cleaned up"
echo "Redis container cleaned up"
- name: Coverage summary
if: always()
shell: sh
env:
COVERAGE_THRESHOLD: "40"
run: |
set +e
echo "=== 覆盖率汇总 ==="
python3 scripts/ci_coverage_summary.py
- name: Notify CI failure
if: failure()
- name: Build summary
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py
- name: Notify CI failure - Integration Tests
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Integration Tests" python3 scripts/ci_notify_failure.py
set -eu
echo "Build completed successfully!"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
frontend-lint:
name: Frontend Lint
runs-on: host
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
@@ -493,45 +251,14 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
shell: sh
@@ -539,6 +266,7 @@ jobs:
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-v "$HOME/.npm:/root/.npm" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npm ci'
@@ -582,22 +310,13 @@ jobs:
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npx vitest run src/test'
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Frontend Lint" python3 scripts/ci_notify_failure.py
deploy-staging:
name: Build & Push Staging (Watchtower auto-deploy)
runs-on: saas
timeout-minutes: 30
needs: [validate, frontend-lint]
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
if: github.ref_name == 'main' || github.ref_name == 'develop' || startsWith(github.ref_name, 'feature/')
steps:
- name: Checkout code
@@ -606,45 +325,7 @@ jobs:
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
bash scripts/ci_checkout.sh
- name: Build and push all images to Gitea Registry
shell: sh
env:
@@ -722,23 +403,6 @@ jobs:
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
- name: Notify CI success
if: success()
shell: sh
run: |
set +e
echo "=== CI 成功通知 ==="
SUCCESS_JOB="Staging部署成功" python3 scripts/ci_notify_success.py
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Build & Push Staging (Watchtower auto-deploy)" python3 scripts/ci_notify_failure.py
staging-e2e:
name: Staging E2E Tests
@@ -754,45 +418,15 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Run Playwright E2E on staging
shell: sh
run: |
@@ -803,19 +437,11 @@ jobs:
-e E2E_BROWSER_CHANNEL=chromium \
-e PLAYWRIGHT_HEADLESS=1 \
-v "$PWD:/workspace" \
-v "$HOME/.npm:/root/.npm" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Staging E2E Tests" python3 scripts/ci_notify_failure.py
staging-api-tests:
name: Staging API Integration Tests
runs-on: saas
@@ -830,45 +456,15 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Run API integration tests on staging
shell: sh
run: |
@@ -877,19 +473,11 @@ jobs:
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
-v "$PWD:/workspace" \
-v "$HOME/.npm:/root/.npm" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Staging API Integration Tests" python3 scripts/ci_notify_failure.py
build-production-runtime-images:
name: Build Production Runtime Images
@@ -906,46 +494,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Build and push all images (api + worker + web, with buildx cache)
shell: sh
env:
@@ -970,15 +519,6 @@ jobs:
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
deploy-production:
name: Deploy Production
runs-on: saas
@@ -1044,23 +584,6 @@ jobs:
echo "$DEPLOY_B64" | base64 -d | ssh -p 22222 -i "$key_path" "$production_user@$production_host" "IMAGE_TAG='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh"
- name: Notify CI success
if: success()
shell: sh
run: |
set +e
echo "=== CI 成功通知 ==="
SUCCESS_JOB="生产部署成功" python3 scripts/ci_notify_success.py
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Deploy Production" python3 scripts/ci_notify_failure.py
production-e2e:
name: Production Browser E2E
runs-on: saas
@@ -1075,46 +598,14 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Run production browser E2E
shell: sh
@@ -1126,15 +617,7 @@ jobs:
-e E2E_BROWSER_CHANNEL=chromium \
-e PLAYWRIGHT_HEADLESS=1 \
-v "$PWD:/workspace" \
-v "$HOME/.npm:/root/.npm" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
- name: Notify CI failure
if: failure()
shell: sh
run: |
set +e
echo "=== CI 失败通知 ==="
FAILED_JOB="Production Browser E2E" python3 scripts/ci_notify_failure.py
+11 -119
View File
@@ -24,46 +24,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Production health check & smoke test
id: smoke
shell: sh
@@ -121,46 +82,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Run API smoke test on staging
id: smoke
shell: sh
@@ -258,45 +180,14 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
bash scripts/ci_checkout.sh
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Run Playwright E2E on staging
id: e2e
@@ -310,6 +201,7 @@ jobs:
-e E2E_BROWSER_CHANNEL=chromium \
-e PLAYWRIGHT_HEADLESS=1 \
-v "$PWD:/workspace" \
-v "$HOME/.npm:/root/.npm" \
-w /workspace/apps/web \
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
+69
View File
@@ -0,0 +1,69 @@
name: Test SSH Secret
on:
push:
branches: [develop]
paths:
- '.gitea/workflows/test-ssh-secret.yml'
jobs:
test-ssh:
runs-on: ubuntu-22.04
steps:
- name: Install SSH client
run: |
which ssh || (apt-get update && apt-get install -y openssh-client)
ssh -V
- name: Debug environment
run: |
echo "=== Environment ==="
echo "Runner hostname: $(hostname)"
echo "Runner IP: $(hostname -i || echo 'unknown')"
echo "Current user: $(whoami)"
echo "=== Secrets check ==="
if [ -n "$STAGING_SSH_HOST" ]; then
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
else
echo "STAGING_SSH_HOST: [EMPTY]"
fi
if [ -n "$STAGING_SSH_USER" ]; then
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
else
echo "STAGING_SSH_USER: [EMPTY]"
fi
if [ -n "$STAGING_SSH_KEY" ]; then
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
else
echo "STAGING_SSH_KEY: [EMPTY]"
fi
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
echo "=== SSH Key fingerprint ==="
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
env:
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
- name: Test SSH connection
run: |
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o BatchMode=yes \
-v \
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
echo "=== SSH Test Complete ==="
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
+163
View File
@@ -0,0 +1,163 @@
name: Tests
on:
pull_request:
branches: [ main ]
jobs:
test:
runs-on: runtime-builder
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import time
import urllib.error
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Show Python version
shell: sh
run: |
set -eu
python --version
python -m pip --version
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run unit tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/unit -q
- name: Run integration tests
shell: sh
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python -m pytest tests/integration -q --timeout=60 -x
lint:
runs-on: runtime-builder
steps:
- name: Checkout code
shell: sh
run: |
set -eu
python - <<'PY'
import io
import os
import tarfile
import time
import urllib.error
import urllib.request
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Install dependencies
shell: sh
run: |
set -eu
python -m pip install --upgrade pip -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
python -m pip install -r requirements.txt -r requirements-dev.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
- name: Run Black (check only)
shell: sh
run: |
set -eu
python -m black --check alembic apps packages tests scripts
- name: Run Flake8
shell: sh
run: |
set -eu
python -m flake8 apps packages tests --count --statistics
-1
View File
@@ -6,7 +6,6 @@ dist/
coverage/
# Python / backend
.cache/
.venv/
venv/
.venv-ci-root/
@@ -1,26 +0,0 @@
"""Add editing_mode to edit_templates
Revision ID: 035_editing_mode
Revises: 034_cms_enhance
Create Date: 2026-07-09
"""
import sqlalchemy as sa
from alembic import op
revision = "035_editing_mode"
down_revision = "034_cms_enhance"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_templates",
sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take"),
)
def downgrade() -> None:
op.drop_column("edit_templates", "editing_mode")
@@ -1,68 +0,0 @@
"""Expand UUID fields from varchar(32) to varchar(36)
All UUID fields across all tables were varchar(32), but standard UUIDs with
hyphens are 36 characters (e.g. 550e8400-e29b-41d4-a716-446655440000).
This caused StringDataRightTruncation errors on insert.
Revision ID: 036_expand_uuid_36
Revises: 035_editing_mode
Create Date: 2026-07-10
"""
import sqlalchemy as sa
from alembic import op
revision = "036_expand_uuid_36"
down_revision = "035_editing_mode"
branch_labels = None
depends_on = None
# ── 表 → 需要扩容的列 ─────────────────────────────────────────────────────────
_TABLES: dict[str, list[str]] = {
"projects": ["id", "owner_user_id"],
"edit_templates": ["id"],
"edit_plans": ["id", "template_id", "source_edit_plan_id", "project_id", "created_by_user_id"],
"template_clip_configs": ["id", "template_id"],
"edit_plan_clips": ["id", "plan_id", "template_clip_config_id", "asset_id"],
"ingest_jobs": ["id", "project_id", "library_id", "result_asset_id"],
"classification_jobs": ["id", "project_id", "asset_id"],
"generation_tasks": [
"id",
"project_id",
"strategy_id",
"asset_library_id",
"voice_library_id",
"created_by_user_id",
"source_edit_plan_id",
"batch_id",
],
"generated_videos": ["id", "project_id", "generation_task_id", "duplicate_of"],
"jobs": ["id", "project_id", "source_id", "created_by_user_id"],
}
def upgrade() -> None:
for table, columns in _TABLES.items():
for col in columns:
op.alter_column(
table,
col,
existing_type=sa.String(32),
type_=sa.String(36),
existing_nullable=None,
)
def downgrade() -> None:
for table, columns in reversed(list(_TABLES.items())):
for col in columns:
op.alter_column(
table,
col,
existing_type=sa.String(36),
type_=sa.String(32),
existing_nullable=None,
)
@@ -1,26 +0,0 @@
"""Add logs field to generation_tasks
Revision ID: 037_generation_logs
Revises: 036_expand_uuid_36
Create Date: 2026-07-10
"""
import sqlalchemy as sa
from alembic import op
revision = "037_generation_logs"
down_revision = "036_expand_uuid_36"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("logs", sa.Text(), nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "logs")
+1
View File
@@ -0,0 +1 @@
"""API application package."""
+1
View File
@@ -0,0 +1 @@
"""API package."""
+29 -10
View File
@@ -4,14 +4,17 @@ from app.api.routes.assets import router as assets_router
from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.edit_plans import router as edit_plans_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.edit_templates import router as edit_templates_router
from app.api.routes.generated_videos import router as generated_videos_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router
from app.api.routes.internal_render import router as internal_render_router
from app.api.routes.jobs import router as jobs_router
from app.api.routes.projects import router as projects_router
from app.api.routes.recipes import router as recipes_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
@@ -84,6 +87,15 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
jobs_router,
tags=["Job"],
)
api_router.include_router(
generated_videos_router,
prefix="/generated-videos",
tags=["GeneratedVideo"],
)
api_router.include_router(
titles_router,
prefix="/titles",
@@ -109,11 +121,26 @@ api_router.include_router(
prefix="/subscription",
tags=["Subscription"],
)
api_router.include_router(
recipes_router,
prefix="/recipes",
tags=["Recipe"],
)
api_router.include_router(
templates_router,
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
dashboard_router,
prefix="/dashboard",
tags=["Dashboard"],
)
api_router.include_router(
edit_templates_router,
prefix="/edit-templates",
tags=["EditTemplate"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
@@ -124,11 +151,3 @@ api_router.include_router(
prefix="/tts",
tags=["TTS"],
)
api_router.include_router(
feature_flags_router,
tags=["Internal"],
)
api_router.include_router(
internal_render_router,
tags=["Internal"],
)
-48
View File
@@ -1,48 +0,0 @@
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
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")
+10 -3
View File
@@ -22,11 +22,18 @@ 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,
@@ -161,7 +168,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)
+19 -13
View File
@@ -27,8 +27,6 @@ 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()
@@ -74,6 +72,14 @@ 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(
@@ -130,7 +136,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)
@@ -146,7 +152,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)
@@ -204,13 +210,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:
@@ -256,7 +262,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)
@@ -280,7 +286,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)
@@ -301,7 +307,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)
@@ -316,7 +322,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:
@@ -340,7 +346,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)
@@ -357,7 +363,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:
@@ -381,7 +387,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)
+3 -2
View File
@@ -206,7 +206,7 @@ async def verify_email_post(
return _verify_email_token(request.token, user_repository)
@router.post("/forgot-password", response_model=MessageResponse, status_code=status.HTTP_202_ACCEPTED)
@router.post("/password/forgot", 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("/reset-password", response_model=MessageResponse)
@router.post("/password/reset", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordModel,
user_repository: UserRepository = Depends(get_user_repository),
@@ -243,6 +243,7 @@ async def logout(
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""登出 - 将当前 token 加入黑名单"""
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
if credentials:
try:
+19 -3
View File
@@ -14,6 +14,7 @@ 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 (
@@ -34,8 +35,6 @@ 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__)
@@ -114,6 +113,22 @@ 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)
@@ -191,6 +206,7 @@ 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:
@@ -205,7 +221,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,
+92
View File
@@ -0,0 +1,92 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_generation_task_repository,
get_project_repository,
get_title_library_repository,
get_voice_library_repository,
)
from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo
from fastapi import APIRouter, Depends
router = APIRouter()
def _status_value(status) -> str:
return status.value if hasattr(status, "value") else str(status)
def _generation_step(status: str) -> str:
if status == "pending":
return "等待 Worker 执行"
if status == "running":
return "正在生成成片"
if status == "completed":
return "生成完成"
if status == "failed":
return "生成失败"
return status
@router.get("/overview", response_model=DashboardOverviewResponse)
def get_dashboard_overview(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
title_library_repository: Any = Depends(get_title_library_repository),
voice_library_repository: Any = Depends(get_voice_library_repository),
) -> DashboardOverviewResponse:
"""Dashboard 概览:用户级汇总数据。"""
user_id = authenticated_user.user.id
# 获取用户可访问的所有 project
projects = project_repository.find_accessible_projects(user_id)
project_ids = [p.id for p in projects]
# 素材统计
total_assets = asset_repository.count_by_project_ids(project_ids)
used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids)
# 标题库 / 配音库统计
total_titles = title_library_repository.count_by_user(user_id)
total_voices = voice_library_repository.count_by_user(user_id)
# 生成任务统计
total_tasks = generation_task_repository.count_by_user(user_id)
# 最近任务(SQL 层 LIMIT 5
recent = generation_task_repository.list_recent_by_user(user_id, limit=5)
recent_tasks = []
for task in recent:
s = _status_value(task.status)
recent_tasks.append(
RecentTaskItem(
id=task.id,
task_type="generation",
status=s,
current_step=_generation_step(s),
error_message=task.error_message or "",
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
# 订阅信息
user = authenticated_user.user
subscription = SubscriptionInfo(
plan=getattr(user, "subscription_plan", "free") or "free",
is_active=getattr(user, "subscription_status", "") == "active",
)
return DashboardOverviewResponse(
total_assets=total_assets,
used_storage_bytes=used_storage_bytes,
total_titles=total_titles,
total_voices=total_voices,
total_tasks=total_tasks,
total_products=len(projects),
subscription=subscription,
recent_tasks=recent_tasks,
)
+31 -167
View File
@@ -10,8 +10,6 @@ RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
业务逻辑委托给 EditPlanService 服务层。
"""
@@ -24,14 +22,19 @@ from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService, PlanGeneratorService
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.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,
)
@@ -45,8 +48,6 @@ 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
@@ -202,47 +203,20 @@ class GenerateCoverResponse(BaseModel):
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
class GenerateFromTemplateRequest(BaseModel):
"""基于模板生成剪辑计划请求体"""
template_id: str = Field(..., description="剪辑模板 ID")
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
project_id: str = Field(default="", description="所属项目 ID")
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
class _PlanClipItem(BaseModel):
"""片段响应体"""
id: str
clip_type: str
order: int
asset_id: str
text_content: str
start_time: float
duration: float
transition_effect: str
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class GenerateFromTemplateResponse(BaseModel):
"""基于模板生成剪辑计划响应体"""
plan: EditPlanResponse
clips: List[_PlanClipItem]
# ── 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,
@@ -296,7 +270,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(
@@ -338,7 +312,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)
@@ -354,7 +328,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)
@@ -396,7 +370,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:
@@ -450,7 +424,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(
@@ -492,7 +466,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:
@@ -630,31 +604,6 @@ def generate_plan(
# 创建 GenerationTask
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 队列限流预检查(repository 不支持计数时跳过)
user_id = current_user.user.id
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
@@ -695,7 +644,7 @@ def generate_plan(
except HTTPException:
# 已处理的 HTTP 异常直接透传
raise
except Exception:
except Exception as exc:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
try:
@@ -734,7 +683,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 = [
@@ -776,7 +725,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)
@@ -845,7 +794,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
@@ -894,7 +843,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
except Exception as exc:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
# 尝试回滚未提交的变更
try:
@@ -975,7 +924,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
@@ -1095,7 +1044,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 排序
@@ -1129,88 +1078,3 @@ def get_plan_timeline(
total_duration=total_duration,
scenes=scenes,
)
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划
流程:
1. 获取模板及其片段配置
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
3. 返回完整的计划和片段列表
"""
from app.services import EditTemplateService
# 项目鉴权
if body.project_id:
check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
# 获取模板
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 获取模板片段配置
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 调用 PlanGeneratorService 生成计划
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
+279
View File
@@ -0,0 +1,279 @@
"""模板管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditTemplate:
- GET /api/v1/edit-templates 列表(分页 + 类型筛选)
- GET /api/v1/edit-templates/{id} 详情
- POST /api/v1/edit-templates 创建(管理员)
- PUT /api/v1/edit-templates/{id} 更新
- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive
业务逻辑委托给 EditTemplateService 服务层。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.domain.config_schemas import normalize_template_config
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditTemplateCreateRequest(BaseModel):
"""创建模板请求体"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=2000, description="模板描述")
template_type: str = Field(default="default", max_length=50, description="模板类型")
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
preview_url: str = Field(default="", max_length=500, description="预览地址")
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
class EditTemplateUpdateRequest(BaseModel):
"""更新模板请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
status: Optional[str] = Field(default=None, description="状态: active / inactive")
class EditTemplateResponse(BaseModel):
"""模板响应体"""
id: str
name: str
description: str
template_type: str
config: dict[str, Any]
preview_url: str
sort_weight: int
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditTemplateListResponse(BaseModel):
"""模板列表响应体"""
items: List[EditTemplateResponse]
total: int
page: int
page_size: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _require_admin(current_user: AuthenticatedUser) -> None:
"""校验当前用户是否为管理员,非管理员返回 403"""
if not getattr(current_user.user, "is_admin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅管理员可执行此操作",
)
def _to_response(t: EditTemplate) -> EditTemplateResponse:
return EditTemplateResponse(
id=t.id,
name=t.name,
description=t.description,
template_type=t.template_type,
config=t.config,
preview_url=t.preview_url,
sort_weight=t.sort_weight,
status=t.status.value if hasattr(t.status, "value") else t.status,
created_at=t.created_at,
updated_at=t.updated_at,
)
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditTemplateListResponse)
def list_templates(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_type: Optional[str] = Query(default=None, description="按类型筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: active / inactive",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateListResponse:
"""获取模板列表(支持分页、按类型/状态筛选)"""
svc = EditTemplateService(db)
# 解析状态筛选
status_enum: Optional[EditTemplateStatus] = None
if status_filter:
try:
status_enum = EditTemplateStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {status_filter},可选值: active, inactive",
)
skip = (page - 1) * page_size
templates = svc.list_templates(
template_type=template_type,
status=status_enum,
skip=skip,
limit=page_size,
)
total = svc.count_templates(
template_type=template_type,
status=status_enum,
)
return EditTemplateListResponse(
items=[_to_response(t) for t in templates],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{template_id}", response_model=EditTemplateResponse)
def get_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""获取单个模板详情"""
svc = EditTemplateService(db)
try:
template = svc.get_template_or_raise(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
return _to_response(template)
@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
body: EditTemplateCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""创建模板(管理员)"""
_require_admin(current_user)
svc = EditTemplateService(db)
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
normalized_config = normalize_template_config(body.config)
try:
created = svc.create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
config=normalized_config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id)
return _to_response(created)
@router.put("/{template_id}", response_model=EditTemplateResponse)
def update_template(
template_id: str,
body: EditTemplateUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""更新模板"""
_require_admin(current_user)
svc = EditTemplateService(db)
# 解析状态
status_enum: Optional[EditTemplateStatus] = None
if body.status is not None:
try:
status_enum = EditTemplateStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的状态值: {body.status},可选值: active, inactive",
)
# 标准化 config(如果提供了)
config_to_update = normalize_template_config(body.config) if body.config is not None else None
try:
result = svc.update_template(
template_id,
name=body.name,
description=body.description,
template_type=body.template_type,
config=config_to_update,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
status=status_enum,
)
except ValueError as exc:
err_msg = str(exc)
if "不存在" in err_msg:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id)
return _to_response(result)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_template(
template_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> Response:
"""删除模板(软删除 → 设为 inactive)"""
_require_admin(current_user)
svc = EditTemplateService(db)
try:
svc.deactivate_template(template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id)
return Response(status_code=204)
-194
View File
@@ -1,194 +0,0 @@
"""Feature Flag 内部管理接口。
通过内部 API Key 鉴权,支持查看和修改 Feature Flag 配置。
主要用于灰度发布期间的动态开关控制。
API:
GET /api/v1/internal/feature-flags - 列出所有 flag
GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
PUT /api/v1/internal/feature-flags/{name} - 设置 flag 配置
DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
鉴权:X-API-Key header,走内部 API Key 验证
"""
from __future__ import annotations
import logging
from typing import Optional
from app.api.routes.auth import _verify_internal_api_key
from app.config import settings
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
RedisFeatureFlagStore,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
ALLOWED_FLAGS = {
"render_engine",
}
def _get_feature_flag_store() -> RedisFeatureFlagStore:
"""获取 Feature Flag 存储实例。"""
return RedisFeatureFlagStore(redis_url=settings.REDIS_URL)
class FeatureFlagUpdateRequest(BaseModel):
"""Feature Flag 更新请求体。"""
enabled: bool = Field(..., description="是否启用")
percentage: int = Field(0, ge=0, le=100, description="灰度百分比 (0-100)")
whitelist: list[str] = Field(default_factory=list, description="白名单列表(如 user_id")
class FeatureFlagResponse(BaseModel):
"""Feature Flag 响应。"""
name: str
enabled: bool
percentage: int
whitelist: list[str]
@classmethod
def from_config(cls, config: FeatureFlagConfig) -> "FeatureFlagResponse":
return cls(
name=config.name,
enabled=config.enabled,
percentage=config.percentage,
whitelist=sorted(config.whitelist),
)
class FeatureFlagCheckResponse(BaseModel):
"""Flag 激活检查响应。"""
name: str
active: bool
identifier: Optional[str] = None
def _validate_flag_name(name: str) -> None:
"""校验 flag 名称是否在允许列表中。"""
if name not in ALLOWED_FLAGS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported flag: {name}. Allowed: {sorted(ALLOWED_FLAGS)}",
)
@router.get("", response_model=list[FeatureFlagResponse])
async def list_feature_flags(
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
):
"""列出所有 Feature Flag。"""
try:
flags = store.list_all()
# 同时返回预定义的 flag(即使未设置也显示默认值)
result = []
for name in sorted(ALLOWED_FLAGS):
config = flags.get(name) or FeatureFlagConfig(name=name, enabled=False)
result.append(FeatureFlagResponse.from_config(config))
# 加上已存在但不在白名单中的 flag(只读展示)
for name, config in flags.items():
if name not in ALLOWED_FLAGS:
result.append(FeatureFlagResponse.from_config(config))
return sorted(result, key=lambda x: x.name)
except Exception as exc:
logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
@router.get("/{name}", response_model=FeatureFlagResponse)
async def get_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
):
"""获取单个 Feature Flag 配置。"""
try:
config = store.get(name)
return FeatureFlagResponse.from_config(config)
except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
async def check_feature_flag(
name: str,
identifier: Optional[str] = Query(None, description="标识符,如 user_id"),
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
):
"""检查某个标识符是否命中 Feature Flag。"""
try:
active = store.is_active(name, identifier=identifier)
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
@router.put("/{name}", response_model=FeatureFlagResponse)
async def update_feature_flag(
name: str,
request: FeatureFlagUpdateRequest,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
):
"""更新 Feature Flag 配置。
只允许修改 ALLOWED_FLAGS 列表中的 flag。
"""
_validate_flag_name(name)
try:
config = FeatureFlagConfig(
name=name,
enabled=request.enabled,
percentage=request.percentage,
whitelist=set(request.whitelist),
)
store.set(config)
logger.info(
"Feature flag updated: name=%s enabled=%s percentage=%d whitelist=%d",
name,
config.enabled,
config.percentage,
len(config.whitelist),
)
return FeatureFlagResponse.from_config(config)
except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
):
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
"""
_validate_flag_name(name)
try:
deleted = store.delete(name)
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
return None
except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+123
View File
@@ -0,0 +1,123 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository, get_project_repository
from app.schemas.generated_video import (
GeneratedVideoDownloadUrlResponse,
GeneratedVideoResponse,
ListGeneratedVideosResponse,
UpdateGeneratedVideoReviewRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
GetGeneratedVideoDownloadUrlUseCase,
GetGeneratedVideoUseCase,
ListGeneratedVideosUseCase,
)
router = APIRouter()
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
file_url=item.file_url,
file_size=item.file_size,
duration=item.duration,
thumbnail_url=item.thumbnail_url,
width=item.width,
height=item.height,
fps=item.fps,
status=item.status,
review_status=item.review_status,
generation_params=item.generation_params,
download_url=download_url,
)
@router.get("", response_model=ListGeneratedVideosResponse)
def list_generated_videos(
project_id: str | None = Query(None),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
user_id = authenticated_user.user.id
use_case = ListGeneratedVideosUseCase(generated_video_repository)
if project_id:
# If project_id provided, check access and filter by project
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
items = use_case.execute(project_id)
else:
# If no project_id, list all videos from accessible projects
accessible_projects = project_repository.find_accessible_projects(user_id)
all_items = []
for proj in accessible_projects:
all_items.extend(use_case.execute(proj.id))
items = all_items
# Generate download URLs for each video
responses = []
for item in items:
download_url = storage_service.get_download_url(item.file_url)
responses.append(_to_generated_video_response(item, download_url=download_url))
return ListGeneratedVideosResponse(items=responses)
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
def get_generated_video(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
use_case = GetGeneratedVideoUseCase(generated_video_repository)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(item.file_url)
return _to_generated_video_response(item, download_url=download_url)
@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse)
def update_generated_video_review_status(
video_id: str,
request: UpdateGeneratedVideoReviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
video.review_status = request.review_status
updated = generated_video_repository.update(video)
download_url = storage_service.get_download_url(updated.file_url)
return _to_generated_video_response(updated, download_url=download_url)
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
def get_generated_video_download_url(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoDownloadUrlResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
file_url = use_case.execute(video_id)
if file_url is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(file_url)
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
+39 -154
View File
@@ -1,17 +1,9 @@
import logging
import random
import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
safe_enqueue_generation_task,
)
from app.core.celery_app import celery_app
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
@@ -31,8 +23,6 @@ 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,
@@ -40,10 +30,18 @@ from packages.application import (
ListGeneratedVideosByTaskUseCase,
)
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,
@@ -58,7 +56,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -66,7 +63,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
)
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
def _to_generated_video_response(item) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
project_id=item.project_id,
@@ -79,7 +76,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
width=item.width,
height=item.height,
fps=item.fps,
download_url=download_url,
)
@@ -183,37 +179,19 @@ def create_generation_task(
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> BatchGenerationTaskResponse:
logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
authenticated_user.user.id,
request.template_id,
len(request.asset_ids),
request.asset_select_mode,
request.count,
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
try:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
except HTTPException as e:
logger.warning("[生成任务] 校验失败: %s", e.detail)
raise
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
logger.warning("[生成任务] 素材库不存在: library_id=%s", asset_library_id)
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
assets = asset_repository.find_by_library(asset_library_id)
try:
_ensure_library_has_ready_video_assets(assets)
except HTTPException as e:
logger.warning("[生成任务] 素材校验失败: %s", e.detail)
raise
_ensure_library_has_ready_video_assets(assets)
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
@@ -226,85 +204,30 @@ def create_generation_task(
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks = []
failed_tasks = []
user_id = authenticated_user.user.id
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
# 预检查:批量提交前先看会不会超限,避免建一半才拒
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + count > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
if global_pending + count > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from e
)
celery_app.send_task("worker.generate_video", args=[task.id])
created_tasks.append(task)
try:
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
)
try:
if safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix="[生成任务]",
log_task_status=True,
):
created_tasks.append(task)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
)
break
except GlobalQueueFull:
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
break
except HTTPException:
raise
except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@@ -331,7 +254,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)
@@ -342,20 +265,15 @@ def list_generation_results(
generation_task_repository: Any = Depends(get_generation_task_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
if task.project_id:
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
_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 = []
for item in items:
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
responses.append(_to_generated_video_response(item, download_url=download_url))
return ListGeneratedVideosResponse(items=responses)
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
@@ -374,21 +292,6 @@ def retry_generation_task(
if status_val != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查:创建前判断,>= 上限就拒绝
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
@@ -400,28 +303,10 @@ def retry_generation_task(
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
try:
if not safe_enqueue_generation_task(
retried,
generation_task_repository,
user_id=user_id,
log_prefix="[生成任务]",
log_task_status=True,
):
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
celery_app.send_task("worker.generate_video", args=[retried.id])
return _to_generation_task_response(retried)
-120
View File
@@ -1,120 +0,0 @@
"""渲染结果内部下载接口。
通过内部 API Key 鉴权,为灰度对比工具等内部系统提供渲染结果下载能力。
API:
GET /api/v1/internal/render/videos/{video_id}/download-url - 获取单个视频下载URL
GET /api/v1/internal/render/tasks/{task_id}/videos - 获取任务下所有视频及下载URL
鉴权:X-API-Key header,走内部 API Key 验证
"""
from __future__ import annotations
import logging
from typing import Any
from app.api.routes.auth import _verify_internal_api_key
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/render", tags=["Internal"])
class InternalRenderVideoItem(BaseModel):
"""内部渲染视频项。"""
video_id: str
generation_task_id: str
project_id: str
name: str
file_url: str
file_size: int | None = None
duration: float | None = None
width: int | None = None
height: int | None = None
fps: float | None = None
status: str
download_url: str
class InternalRenderTaskVideosResponse(BaseModel):
"""任务下所有渲染视频响应。"""
task_id: str
count: int
videos: list[InternalRenderVideoItem]
class InternalRenderDownloadUrlResponse(BaseModel):
"""单个视频下载URL响应。"""
video_id: str
download_url: str
def _video_to_item(video: Any, download_url: str) -> InternalRenderVideoItem:
"""将 GeneratedVideo 领域对象转为响应项。"""
return InternalRenderVideoItem(
video_id=video.id,
generation_task_id=video.generation_task_id,
project_id=video.project_id,
name=video.name,
file_url=video.file_url,
file_size=getattr(video, "file_size", None),
duration=getattr(video, "duration", None),
width=getattr(video, "width", None),
height=getattr(video, "height", None),
fps=getattr(video, "fps", None),
status=video.status,
download_url=download_url,
)
@router.get("/videos/{video_id}/download-url", response_model=InternalRenderDownloadUrlResponse)
def get_render_video_download_url(
video_id: str,
_: bool = Depends(_verify_internal_api_key),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> InternalRenderDownloadUrlResponse:
"""获取单个渲染视频的下载URL(预签名)。"""
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
logger.info("内部渲染下载URL生成: video_id=%s", video_id)
return InternalRenderDownloadUrlResponse(video_id=video_id, download_url=download_url)
@router.get("/tasks/{task_id}/videos", response_model=InternalRenderTaskVideosResponse)
def get_render_task_videos(
task_id: str,
status: str | None = Query(None, description="按状态筛选,如 completed/failed"),
_: bool = Depends(_verify_internal_api_key),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> InternalRenderTaskVideosResponse:
"""获取生成任务下所有渲染视频及下载URL。"""
videos = generated_video_repository.list_by_generation_task(task_id)
# 状态筛选
if status:
videos = [v for v in videos if v.status == status]
items = []
for video in videos:
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
items.append(_video_to_item(video, download_url))
logger.info("内部渲染任务视频查询: task_id=%s count=%d", task_id, len(items))
return InternalRenderTaskVideosResponse(
task_id=task_id,
count=len(items),
videos=items,
)
+332
View File
@@ -0,0 +1,332 @@
"""Job API 路由 — Phase 8 任务 2.10.
提供统一异步任务管理 RESTful 接口:
- POST /api/v1/jobs 创建任务
- GET /api/v1/jobs/{job_id} 任务详情
- GET /api/v1/projects/{project_id}/jobs 项目任务列表
- GET /api/v1/projects/{project_id}/jobs/stats 任务统计
- PUT /api/v1/jobs/{job_id}/progress 更新进度
- POST /api/v1/jobs/{job_id}/complete 标记完成
- POST /api/v1/jobs/{job_id}/fail 标记失败
- POST /api/v1/jobs/{job_id}/retry 重试任务
- POST /api/v1/jobs/{job_id}/cancel 取消任务
- POST /api/v1/jobs/{job_id}/submit 提交执行
"""
from __future__ import annotations
import logging
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.schemas.job import (
CompleteJobRequest,
CreateJobRequest,
FailJobRequest,
JobResponse,
JobStatisticsResponse,
ListJobsResponse,
UpdateProgressRequest,
job_to_response,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
CompleteJobUseCase,
CreateJobCommand,
CreateJobUseCase,
FailJobCommand,
FailJobUseCase,
GetJobStatisticsUseCase,
GetJobUseCase,
ListJobsUseCase,
RetryJobUseCase,
SubmitJobUseCase,
UpdateJobProgressCommand,
UpdateJobProgressUseCase,
)
from packages.domain.job import JobType
logger = logging.getLogger(__name__)
router = APIRouter()
# 任务类型 → Celery task name 映射
_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
JobType.VIDEO_COMPOSE: "worker.compose_video",
JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan",
JobType.ASSET_INGEST: "worker.ingest_asset",
JobType.CLASSIFICATION: "worker.classify_asset",
JobType.VOICE_EXTRACTION: "worker.extract_voice",
JobType.GENERATION: "worker.generate_video",
}
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.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
def create_job(
request: CreateJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobResponse:
"""创建异步任务。
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
"""
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
# 校验 job_type
try:
JobType(request.job_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"不支持的任务类型: {request.job_type}" f"可选值: {[t.value for t in JobType]}",
)
use_case = CreateJobUseCase(job_repo)
job = use_case.execute(
CreateJobCommand(
project_id=request.project_id,
job_type=request.job_type,
payload=request.payload,
source_id=request.source_id,
created_by_user_id=authenticated_user.user.id,
max_retries=request.max_retries,
)
)
return job_to_response(job)
# ── 提交执行 ──────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/submit", response_model=JobResponse)
def submit_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""提交任务执行。
将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = SubmitJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Dispatch Celery 任务
celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value)
if celery_task_name:
result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload)
job.celery_task_id = result.id
job_repo.update(job)
logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id)
return job_to_response(job)
# ── 查询接口 ──────────────────────────────────────────────────────────────────
@router.get("/jobs/{job_id}", response_model=JobResponse)
def get_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""获取任务详情。"""
use_case = GetJobUseCase(job_repo)
job = use_case.execute(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return job_to_response(job)
@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse)
def list_project_jobs(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
job_type: str | None = Query(default=None, description="按任务类型过滤"),
status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> ListJobsResponse:
"""获取项目下的任务列表。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = ListJobsUseCase(job_repo)
jobs = use_case.execute(
project_id=project_id,
job_type=job_type,
status=status_filter,
limit=limit,
offset=offset,
)
items = [job_to_response(j) for j in jobs]
return ListJobsResponse(items=items, total=len(items))
@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse)
def get_job_statistics(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
project_repository: Any = Depends(get_project_repository),
) -> JobStatisticsResponse:
"""获取项目任务统计摘要。"""
_check_project_access(project_id, authenticated_user.user.id, project_repository)
use_case = GetJobStatisticsUseCase(job_repo)
stats = use_case.execute(project_id)
return JobStatisticsResponse(**stats)
# ── 进度更新 ──────────────────────────────────────────────────────────────────
@router.put("/jobs/{job_id}/progress", response_model=JobResponse)
def update_job_progress(
job_id: str,
request: UpdateProgressRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""更新任务进度。"""
use_case = UpdateJobProgressUseCase(job_repo)
try:
job = use_case.execute(
UpdateJobProgressCommand(
job_id=job_id,
progress=request.progress,
current_stage=request.current_stage,
)
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 完成 / 失败 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/complete", response_model=JobResponse)
def complete_job(
job_id: str,
request: CompleteJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务完成。"""
use_case = CompleteJobUseCase(job_repo)
try:
job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/fail", response_model=JobResponse)
def fail_job(
job_id: str,
request: FailJobRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""标记任务失败。"""
use_case = FailJobUseCase(job_repo)
try:
job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
# ── 重试 / 取消 ────────────────────────────────────────────────────────────────
@router.post("/jobs/{job_id}/retry", response_model=JobResponse)
def retry_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""重试失败任务。
将任务重置为 pendingretry_count + 1,但不自动 dispatch。
需要再次调用 /submit 提交执行。
"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = RetryJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
@router.post("/jobs/{job_id}/cancel", response_model=JobResponse)
def cancel_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
job_repo: Any = Depends(get_job_repository),
) -> JobResponse:
"""取消任务。"""
# 权限检查:先获取任务并验证权限,再执行状态变更
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this job")
use_case = CancelJobUseCase(job_repo)
try:
job = use_case.execute(job_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return job_to_response(job)
+212
View File
@@ -0,0 +1,212 @@
"""Recipe CRUD + use routes."""
from __future__ import annotations
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.recipe import (
CreateRecipeRequest,
ListRecipesResponse,
RecipeItemResponse,
RecipeResponse,
UpdateRecipeRequest,
UseRecipeResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.application.recipe.use_cases import (
CreateRecipeUseCase,
DeleteRecipeUseCase,
FeatureDisabledError,
GetRecipeUseCase,
ListRecipesUseCase,
NotFoundError,
UpdateRecipeUseCase,
UseRecipeUseCase,
)
from packages.ports.user_repository import UserRepository
router = APIRouter()
def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository:
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,
recipe_id=item.recipe_id,
item_type=item.item_type,
item_id=item.item_id,
position=item.position,
metadata=item.metadata_,
)
def _to_response(recipe) -> RecipeResponse:
return RecipeResponse(
id=recipe.id,
user_id=recipe.user_id,
name=recipe.name,
description=recipe.description,
template_id=recipe.template_id,
generation_params=recipe.generation_params,
items=[_item_to_response(i) for i in getattr(recipe, "items", [])],
is_active=recipe.is_active,
metadata=recipe.metadata_,
created_at=recipe.created_at,
updated_at=recipe.updated_at,
)
@router.get("", response_model=ListRecipesResponse)
def list_recipes(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> ListRecipesResponse:
user_id = authenticated_user.user.id
use_case = ListRecipesUseCase(recipe_repository)
recipes = use_case.execute(user_id, skip=skip, limit=limit)
total = recipe_repository.count_by_user(user_id)
return ListRecipesResponse(
items=[_to_response(r) for r in recipes],
total=total,
)
@router.get("/{recipe_id}", response_model=RecipeResponse)
def get_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
use_case = GetRecipeUseCase(recipe_repository)
recipe = use_case.execute(recipe_id, user_id)
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.post("", response_model=RecipeResponse, status_code=status.HTTP_201_CREATED)
def create_recipe(
request: CreateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = CreateRecipeCommand(
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
],
metadata_=request.metadata_,
)
use_case = CreateRecipeUseCase(recipe_repository)
recipe = use_case.execute(command)
return _to_response(recipe)
@router.patch("/{recipe_id}", response_model=RecipeResponse)
def update_recipe(
recipe_id: str,
request: UpdateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = UpdateRecipeCommand(
recipe_id=recipe_id,
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=(
[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
]
if request.items is not None
else None
),
metadata_=request.metadata_,
)
use_case = UpdateRecipeUseCase(recipe_repository)
try:
recipe = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteRecipeUseCase(recipe_repository)
deleted = use_case.execute(recipe_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return Response(status_code=204)
@router.post("/{recipe_id}/use", response_model=UseRecipeResponse)
def use_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> UseRecipeResponse:
user_id = authenticated_user.user.id
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)
except FeatureDisabledError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return UseRecipeResponse(
recipe=_to_response(result.recipe),
warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings],
)
+1 -1
View File
@@ -232,7 +232,7 @@ async def payment_callback(
# 创建账单记录
record_id = uuid.uuid4().hex
repo.create(
record = repo.create(
{
"id": record_id,
"user_id": user_id,
+5 -73
View File
@@ -1,15 +1,7 @@
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
safe_enqueue_generation_task,
)
from app.dependencies import (
get_generation_task_repository,
get_ingest_job_repository,
@@ -30,10 +22,9 @@ from packages.application import (
SubmitIngestJobUseCase,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _humanize_task_error(error_message: str) -> str:
raw = (error_message or "").strip()
if not raw:
@@ -148,21 +139,6 @@ def retry_task_by_id(
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
@@ -174,24 +150,10 @@ def retry_task_by_id(
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
created_by_user_id=authenticated_user.user.id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
):
logger.warning("[任务中心] 用户级重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
celery_app.send_task("worker.generate_video", args=[retried.id])
return UserTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
@@ -259,22 +221,6 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Generation task not found")
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
user_id = authenticated_user.user.id
# 预检查
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
@@ -286,24 +232,10 @@ def retry_project_task(
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=user_id,
created_by_user_id=authenticated_user.user.id,
)
)
try:
if not safe_enqueue_generation_task(
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
):
logger.warning("[任务中心] 项目级重试入队失败: task_id=%s", retried.id)
except UserPendingLimitExceeded:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from None
except GlobalQueueFull:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
celery_app.send_task("worker.generate_video", args=[retried.id])
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
+8 -3
View File
@@ -28,8 +28,6 @@ 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()
@@ -53,6 +51,13 @@ 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),
@@ -93,7 +98,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,
Executable → Regular
+6 -17
View File
@@ -7,7 +7,6 @@ from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_audio_url_signer,
get_cosyvoice_service,
get_db_session,
get_user_repository,
@@ -57,10 +56,7 @@ def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTS
return SQLAlchemyTTSJobRepository(session)
def _to_response(job, sign_url=None) -> TTSJobResponse:
output_url = job.output_audio_url
if sign_url and output_url:
output_url = sign_url(output_url)
def _to_response(job) -> TTSJobResponse:
return TTSJobResponse(
id=job.id,
user_id=job.user_id,
@@ -70,7 +66,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse:
project_id=job.project_id,
voice_clone_profile_id=job.voice_clone_profile_id,
status=job.status,
output_audio_url=output_url,
output_audio_url=job.output_audio_url,
output_audio_key=job.output_audio_key,
duration=job.duration,
file_size=job.file_size,
@@ -180,7 +176,6 @@ def list_tts_jobs(
status_filter: Optional[str] = Query(None, alias="status"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
sign_url=Depends(get_audio_url_signer),
) -> ListTTSJobResponse:
"""列出用户的 TTS 合成任务。"""
user_id = authenticated_user.user.id
@@ -188,7 +183,7 @@ def list_tts_jobs(
skip = (page - 1) * page_size
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
return ListTTSJobResponse(
items=[_to_response(j, sign_url) for j in items],
items=[_to_response(j) for j in items],
total=total,
page=page,
page_size=page_size,
@@ -200,7 +195,6 @@ def get_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
sign_url=Depends(get_audio_url_signer),
) -> TTSJobResponse:
"""获取 TTS 任务详情。"""
user_id = authenticated_user.user.id
@@ -209,7 +203,7 @@ def get_tts_job(
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url)
return _to_response(job)
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
@@ -217,7 +211,6 @@ def get_tts_job_status(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
sign_url=Depends(get_audio_url_signer),
) -> TTSStatusResponse:
"""查询 TTS 合成状态(用于前端轮询)。"""
user_id = authenticated_user.user.id
@@ -226,13 +219,10 @@ def get_tts_job_status(
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url
if output_url:
output_url = sign_url(output_url)
return TTSStatusResponse(
id=job.id,
status=job.status,
output_audio_url=output_url,
output_audio_url=job.output_audio_url,
error_message=job.error_message,
duration=job.duration,
retry_count=job.retry_count,
@@ -268,7 +258,6 @@ def save_tts_job_to_library(
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
user_repository: UserRepository = Depends(get_user_repository),
sign_url=Depends(get_audio_url_signer),
) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音库。
@@ -339,7 +328,7 @@ def save_tts_job_to_library(
return SaveToLibraryResponse(
id=item.id,
name=item.name,
audio_url=sign_url(item.audio_url) if item.audio_url else "",
audio_url=item.audio_url,
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
+21 -7
View File
@@ -1,5 +1,5 @@
import logging
from typing import Any
from typing import Annotated, Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
@@ -17,13 +17,12 @@ from app.schemas.upload import (
DirectUploadCompleteResponse,
DirectUploadPrepareRequest,
DirectUploadPrepareResponse,
UploadAssetRequest,
UploadAssetResponse,
)
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
logger = logging.getLogger(__name__)
@@ -81,6 +80,21 @@ 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,
@@ -121,7 +135,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,
@@ -169,7 +183,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,
@@ -238,7 +252,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 -1
View File
@@ -28,6 +28,7 @@ from packages.application.voice_clone.use_cases import (
VoiceCloneNotRetryableError,
)
from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowError,
VoiceCloneWorkflowService,
)
@@ -37,7 +38,6 @@ router = APIRouter()
def _to_response(profile) -> VoiceCloneProfileResponse:
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
return VoiceCloneProfileResponse(
id=profile.id,
user_id=profile.user_id,
+18 -25
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Literal, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.dependencies import get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
@@ -39,8 +39,6 @@ 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()
@@ -52,10 +50,7 @@ def _get_clone_profile_repository(session: Session = Depends(get_db_session)) ->
return SQLAlchemyVoiceCloneProfileRepository(session)
def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
audio = item.audio_url
if sign_url and audio:
audio = sign_url(audio)
def _to_response(item) -> VoiceLibraryItemResponse:
return VoiceLibraryItemResponse(
id=item.id,
user_id=item.user_id,
@@ -64,7 +59,7 @@ def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
voice_provider=item.voice_provider,
voice_id=item.voice_id,
voice_name=item.voice_name,
audio_url=audio,
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
@@ -75,20 +70,16 @@ def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
)
def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None) -> UnifiedVoiceItemResponse:
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
"""将数据库音色转换为统一响应格式。
Args:
item: VoiceLibraryItem
profile_id_map: voice_id profile_id 映射用于填充 voice_clone_profile_id
sign_url: 音频URL预签名函数
"""
profile_id = None
if profile_id_map and item.voice_id:
profile_id = profile_id_map.get(item.voice_id)
audio = item.audio_url
if sign_url and audio:
audio = sign_url(audio)
return UnifiedVoiceItemResponse(
id=item.id,
type="clone",
@@ -98,7 +89,7 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
language="zh-CN",
voice_id=item.voice_id,
voice_provider=item.voice_provider or "cosyvoice",
audio_url=audio,
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
@@ -127,6 +118,13 @@ 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"
# ==================== 统一配音列表(预置 + 克隆)====================
@@ -142,7 +140,6 @@ def list_voices_unified(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
sign_url=Depends(get_audio_url_signer),
) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。
@@ -170,7 +167,7 @@ def list_voices_unified(
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw]
# 组装结果
if type == "preset":
@@ -227,7 +224,6 @@ def list_voices_legacy(
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
sign_url=Depends(get_audio_url_signer),
) -> ListVoiceLibraryResponse:
"""原有配音列表接口(仅返回用户克隆音色)。
@@ -237,7 +233,7 @@ def list_voices_legacy(
use_case = ListVoiceLibraryUseCase(voice_repository)
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
return ListVoiceLibraryResponse(
items=[_to_response(i, sign_url) for i in items],
items=[_to_response(i) for i in items],
total=total,
)
@@ -247,14 +243,13 @@ def get_voice(
voice_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
use_case = GetVoiceLibraryUseCase(voice_repository)
item = use_case.execute(voice_id, user_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url)
return _to_response(item)
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
@@ -263,10 +258,9 @@ def create_voice(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
user_repository: UserRepository = Depends(get_user_repository),
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,
@@ -289,7 +283,7 @@ def create_voice(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
return _to_response(item, sign_url)
return _to_response(item)
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
@@ -298,7 +292,6 @@ def update_voice(
request: UpdateVoiceLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
sign_url=Depends(get_audio_url_signer),
) -> VoiceLibraryItemResponse:
user_id = authenticated_user.user.id
command = UpdateVoiceLibraryCommand(
@@ -320,7 +313,7 @@ def update_voice(
item = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url)
return _to_response(item)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
+10 -31
View File
@@ -19,25 +19,27 @@ class Settings(BaseSettings):
# Container bind address; external expose is controlled by Docker/Nginx.
API_HOST: str = "0.0.0.0" # nosec: B104
API_PORT: int = 8000
API_PREFIX: str = "/api/v1"
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
DATABASE_POOL_TIMEOUT: int = 30
DATABASE_POOL_RECYCLE: int = 3600
DATABASE_POOL_RECYLE: int = 3600
USE_IN_MEMORY_DB: bool = False
AUTO_CREATE_SCHEMA: bool = False
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_MAX_CONNECTION: int = 50
ENABLE_REDIS_SESSIONS: bool = False
# JWT secret key - MUST be set via environment variable, no default allowed
JWT_SECRET_KEY: Optional[str] = None
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
JWT_SECRET_KEY_OLD: Optional[str] = None
# 密钥轮换天数(到达此天数后建议更换密钥)
SECRET_ROTATION_DAYS: int = 90
@field_validator("JWT_SECRET_KEY", mode="before")
@classmethod
@@ -73,42 +75,19 @@ class Settings(BaseSettings):
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
# OSS 七牛云相关
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com"
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
@classmethod
def validate_oss_access_key_id(cls, v):
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
raise ValueError(
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
)
return v or ""
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
@classmethod
def validate_oss_access_key_secret(cls, v):
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
raise ValueError(
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
)
return v or ""
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
default=2000,
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
LOG_LEVEL: str = "INFO"
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
RENDER_ENGINE: str = "legacy"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
+1
View File
@@ -0,0 +1 @@
"""Core configuration package."""
+240 -11
View File
@@ -1,14 +1,243 @@
"""Backward-compatible re-export from shared storage.
"""阿里云 OSS 存储服务"""
All storage logic now lives in ``packages.shared.storage``.
This module keeps old import paths working so existing code
does not need to change.
"""
import base64
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
from urllib.parse import urlparse
from packages.shared.storage import (
SharedStorageService as OSSStorageService,
get_shared_storage_service,
get_storage_service,
)
try:
import oss2
except ImportError: # pragma: no cover - exercised in minimal local/test environments
oss2 = None
from app.config import get_settings
__all__ = ["OSSStorageService", "get_storage_service", "get_shared_storage_service"]
logger = logging.getLogger(__name__)
class OSSStorageService:
"""阿里云 OSS 存储服务"""
def __init__(self):
settings = get_settings()
self.bucket_name = settings.OSS_BUCKET_NAME
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
if has_key_id and has_key_secret:
if oss2 is not None:
try:
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.OSS_ACCESS_KEY_ID
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
self.endpoint = settings.OSS_ENDPOINT
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
return path.startswith(f"{self.local_url_prefix}/")
def create_direct_upload_post(
self,
storage_key: str,
content_type: str,
max_size_bytes: int,
expires_seconds: int,
) -> dict[str, object]:
"""创建浏览器直传 OSS 的 PostObject 表单。"""
if not self.access_key_id or not self.access_key_secret:
raise RuntimeError("OSS storage is not configured")
normalized_key = self._normalize_storage_key(storage_key)
if not normalized_key.startswith("uploads/"):
raise ValueError("direct upload key must be under uploads/")
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
)
policy = {
"expiration": expiration,
"conditions": [
{"bucket": self.bucket_name},
{"key": normalized_key},
["content-length-range", 1, max_size_bytes],
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
],
}
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
signature = base64.b64encode(
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
return {
"url": self.public_url,
"method": "POST",
"storage_key": normalized_key,
"expires_at": expiration,
"fields": {
"key": normalized_key,
"OSSAccessKeyId": self.access_key_id,
"policy": encoded_policy,
"Signature": signature,
"success_action_status": "201",
"Content-Type": content_type,
},
}
def upload_file(
self,
file_or_path,
storage_key: str,
content_type: str = "application/octet-stream",
) -> str:
"""
上传文件到 OSS
Args:
file_or_path: 文件对象或本地文件路径
storage_key: 存储键文件路径
content_type: 内容类型
Returns:
文件公网 URL
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
# 如果是字符串路径,从本地文件上传
if isinstance(file_or_path, str):
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
else:
# 文件对象
file_or_path.seek(0)
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str:
"""获取文件公网 URL"""
return f"{self.public_url}/{storage_key}"
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""
获取文件下载签名 URL用于私有文件
Args:
storage_key_or_url: 存储键或完整 URL
expires_seconds: 过期时间
Returns:
签名 URL
"""
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
return self.bucket.sign_url("GET", storage_key, expires_seconds)
except Exception:
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
"""从 URL 中提取存储键"""
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
parsed = urlparse(storage_key_or_url)
# 移除开头的 /
return parsed.path.lstrip("/")
return storage_key_or_url.lstrip("/")
def download_file(self, storage_key: str, local_path: str):
"""
OSS 下载文件到本地
Args:
storage_key: 存储键
local_path: 本地文件路径
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}")
def delete_file(self, storage_key: str):
"""
删除 OSS 文件
Args:
storage_key: 存储键
"""
if self.bucket is None:
return
try:
self.bucket.delete_object(storage_key)
except Exception as error:
logger.warning(
"Failed to delete file from OSS",
extra={"storage_key": storage_key, "error": str(error)},
)
def file_exists(self, storage_key: str) -> bool:
"""
检查文件是否存在
Args:
storage_key: 存储键
Returns:
是否存在
"""
if self.bucket is None:
return False
return self.bucket.object_exists(storage_key)
_storage_service = None
def get_storage_service() -> OSSStorageService:
"""获取存储服务实例(全局单例)"""
global _storage_service
if _storage_service is None:
_storage_service = OSSStorageService()
return _storage_service
-221
View File
@@ -1,221 +0,0 @@
import logging
from typing import Any
from app.core.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
class UserPendingLimitExceeded(Exception):
"""用户 pending 任务数超限,返回 429。"""
def __init__(self, user_id: str, pending_count: int, limit: int):
self.user_id = user_id
self.pending_count = pending_count
self.limit = limit
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
class GlobalQueueFull(Exception):
"""全局限流,返回 503。"""
def __init__(self, pending_count: int, limit: int):
self.pending_count = pending_count
self.limit = limit
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
def check_queue_limits(
user_id: str,
generation_task_repository: Any,
*,
user_pending_limit: int = USER_PENDING_LIMIT,
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
) -> None:
"""检查队列限流(预检查用,任务创建前调用),超限抛对应异常。
边界语义>= 上限即拒绝达到上限就不能再加新任务
Args:
user_id: 用户 ID
generation_task_repository: 任务仓储
user_pending_limit: 单用户 pending 上限默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限默认 GLOBAL_PENDING_LIMIT
Raises:
GlobalQueueFull: 全局超限时抛出优先级更高先查全局
UserPendingLimitExceeded: 用户超限时抛出
"""
# 先查全局(系统级保护优先级更高)
global_pending = generation_task_repository.count_pending_total()
if global_pending >= global_pending_limit:
logger.warning(
"[队列限流] 全局 pending 任务数超限: %d/%d, user_id=%s",
global_pending,
global_pending_limit,
user_id,
)
raise GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
# 再查用户级
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending >= user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超限: user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
def _mark_task_failed_safely(
task: Any,
generation_task_repository: Any,
log_prefix: str,
reason: str,
) -> None:
"""安全地把任务标记为 failed,更新失败只打日志不崩溃。"""
try:
task.mark_failed(f"任务被限流拒绝: {reason}")
generation_task_repository.update(task)
except Exception as update_err:
logger.error(
"%s 限流后更新状态也失败: task_id=%s error=%s",
log_prefix,
task.id,
update_err,
exc_info=True,
)
def safe_enqueue_generation_task(
task: Any,
generation_task_repository: Any,
*,
user_id: str = "",
log_prefix: str = "[任务队列]",
log_task_status: bool = False,
user_pending_limit: int = USER_PENDING_LIMIT,
global_pending_limit: int = GLOBAL_PENDING_LIMIT,
) -> bool:
"""安全入队:入队前限流检查 → 发送 Celery 任务 → 入队后最终校验兜底。
边界说明
入队前检查用 > 而非 >=因为调用此函数时 task 已经是 pending 状态并计入 DB
pending 总数包含了当前任务本身pending > limit 等价于"其他任务数 >= limit"
与预检查的 >= 语义一致都是达到上限就拒绝新任务
入队后最终校验发送 Celery 成功后再查一次 DB 计数处理并发竞态场景
两个请求同时通过入队前检查后到的那个在这里被兜住
Args:
task: 生成任务对象需有 id 属性和 mark_failed 方法状态已为 pending
generation_task_repository: 任务仓储用于更新状态
user_id: 用户 ID传了才做用户级限流检查
log_prefix: 日志前缀便于区分调用来源
log_task_status: 成功日志中是否额外打印任务状态
user_pending_limit: 单用户 pending 上限默认 USER_PENDING_LIMIT
global_pending_limit: 全局 pending 上限默认 GLOBAL_PENDING_LIMIT
Returns:
True 表示入队成功False 表示入队失败已标记为 failed
Raises:
GlobalQueueFull: 全局 pending 超限时抛出任务会被标记为 failed
UserPendingLimitExceeded: 用户 pending 超限时抛出任务会被标记为 failed
"""
# ── 入队前检查:任务已是 pending,用 > 判断(包含当前任务) ──
# 全局限流检查(始终生效)
global_pending = generation_task_repository.count_pending_total()
if global_pending > global_pending_limit:
logger.warning(
"[队列限流] 全局 pending 任务数超限(入队前): %d/%d, user_id=%s",
global_pending,
global_pending_limit,
user_id or "unknown",
)
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# 用户级限流检查(传了 user_id 才做)
if user_id:
user_pending = generation_task_repository.count_pending_by_user(user_id)
if user_pending > user_pending_limit:
logger.warning(
"[队列限流] 用户 pending 任务数超限(入队前): user_id=%s, count=%d/%d",
user_id,
user_pending,
user_pending_limit,
)
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending, limit=user_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc
# ── 发送 Celery 任务 ──
try:
celery_app.send_task("worker.generate_video", args=[task.id])
except Exception as e:
logger.error(
"%s 入队失败,标记为失败: task_id=%s error=%s",
log_prefix,
task.id,
e,
exc_info=True,
)
try:
task.mark_failed(f"任务入队失败: {e}")
generation_task_repository.update(task)
except Exception as update_err:
logger.error(
"%s 入队失败后更新状态也失败: task_id=%s error=%s",
log_prefix,
task.id,
update_err,
exc_info=True,
)
return False
# ── 入队后最终校验:并发竞态兜底 ──
# 发送成功后再查一次,防止两个请求同时通过入队前检查导致超限
global_after = generation_task_repository.count_pending_total()
user_after = generation_task_repository.count_pending_by_user(user_id) if user_id else 0
global_over = global_after > global_pending_limit
user_over = bool(user_id and user_after > user_pending_limit)
if global_over or user_over:
if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
logger.warning(
"[队列限流] %s, task_id=%s, user_id=%s — 回滚状态为 failed",
reason,
task.id,
user_id or "unknown",
)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, reason)
raise exc
# 入队成功日志
if log_task_status:
logger.info(
"%s 入队成功: task_id=%s, status=%s",
log_prefix,
task.id,
task.status,
)
else:
logger.info("%s 入队成功: task_id=%s", log_prefix, task.id)
return True
Regular → Executable
+14 -32
View File
@@ -50,8 +50,20 @@ 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)
@@ -189,37 +201,7 @@ def get_voice_clone_profile_repository(
def get_cosyvoice_service():
"""Provide the CosyVoice service instance.
注入 OSS 音频URL预签名函数确保私有bucket下的参考音频
能被 CosyVoice 服务器下载
"""
from app.core.storage import get_storage_service
"""Provide the CosyVoice service instance."""
from packages.application.cosyvoice_service import CosyVoiceService
storage = get_storage_service()
def _sign_audio_url(url: str) -> str:
"""对音频URL做预签名,私有bucket下 CosyVoice 服务器才能下载."""
return storage.get_download_url(url, expires_seconds=86400)
return CosyVoiceService(audio_url_signer=_sign_audio_url)
def get_audio_url_signer():
"""提供音频URL预签名函数(24小时有效期)。
用于所有 API 返回给前端的音频 URL确保私有 OSS bucket 下可正常访问
URL OSS URL 直接原样返回签名失败时回退到原始 URL
"""
from app.core.storage import get_storage_service
storage = get_storage_service()
def sign_audio_url(url: str) -> str:
if not url:
return url
return storage.get_download_url(url, expires_seconds=86400)
return sign_audio_url
return CosyVoiceService()
+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, HTTPException
from fastapi import Depends
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
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from pydantic import BaseModel, Field
class RecentTaskItem(BaseModel):
id: str
task_type: str = "generation"
status: str
current_step: str = ""
error_message: str = ""
updated_at: datetime | None = None
class SubscriptionInfo(BaseModel):
"""用户订阅信息。"""
plan: str = "free"
is_active: bool = False
class DashboardOverviewResponse(BaseModel):
"""Dashboard 概览数据。"""
total_assets: int = 0
used_storage_bytes: int = 0
total_titles: int = 0
total_voices: int = 0
total_tasks: int = 0
total_products: int = 0
subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo)
recent_tasks: list[RecentTaskItem] = Field(default_factory=list)
+1 -18
View File
@@ -1,6 +1,4 @@
import json
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, model_validator
class CreateGenerationTaskRequest(BaseModel):
@@ -64,21 +62,6 @@ class GenerationTaskResponse(BaseModel):
progress: float
result_count: int
error_message: str
logs: list[dict] = Field(default_factory=list)
@field_validator("logs", mode="before")
@classmethod
def _parse_logs(cls, v: object) -> list[dict]:
"""将 JSON 字符串解析为 list[dict]。"""
if isinstance(v, str):
try:
parsed = json.loads(v)
return parsed if isinstance(parsed, list) else []
except (json.JSONDecodeError, TypeError):
return []
if isinstance(v, list):
return v
return []
class BatchGenerationTaskResponse(BaseModel):
+109
View File
@@ -0,0 +1,109 @@
"""Job API schemas — Phase 8 任务 2.10."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class CreateJobRequest(BaseModel):
"""创建任务请求体。"""
project_id: str = Field(..., min_length=1, description="项目 ID")
job_type: str = Field(
...,
description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation",
)
payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数")
source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id")
max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数")
class UpdateProgressRequest(BaseModel):
"""更新任务进度请求体。"""
progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比")
current_stage: str = Field(default="", description="当前阶段描述")
class CompleteJobRequest(BaseModel):
"""完成任务请求体。"""
result: dict[str, Any] = Field(default_factory=dict, description="任务结果")
class FailJobRequest(BaseModel):
"""标记任务失败请求体。"""
error_message: str = Field(..., min_length=1, description="错误信息")
class JobResponse(BaseModel):
"""任务响应体。"""
id: str
project_id: str
job_type: str
status: str
progress: float
current_stage: str
payload: dict[str, Any]
result: dict[str, Any]
error_message: str
retry_count: int
max_retries: int
celery_task_id: str
source_id: str
created_by_user_id: str
is_retryable: bool
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ListJobsResponse(BaseModel):
"""任务列表响应体。"""
items: list[JobResponse]
total: int
class JobStatisticsResponse(BaseModel):
"""任务统计响应体。"""
project_id: str
total: int
pending: int
running: int
success: int
failed: int
def job_to_response(job) -> JobResponse:
"""将 Job 领域对象转换为 API 响应。"""
return JobResponse(
id=job.id,
project_id=job.project_id,
job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type),
status=job.status.value if hasattr(job.status, "value") else str(job.status),
progress=job.progress,
current_stage=job.current_stage,
payload=job.payload,
result=job.result,
error_message=job.error_message,
retry_count=job.retry_count,
max_retries=job.max_retries,
celery_task_id=job.celery_task_id,
source_id=job.source_id,
created_by_user_id=job.created_by_user_id,
is_retryable=job.is_retryable,
started_at=job.started_at,
completed_at=job.completed_at,
created_at=job.created_at,
updated_at=job.updated_at,
)
+86
View File
@@ -0,0 +1,86 @@
"""Recipe API schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Response ──
class RecipeItemResponse(BaseModel):
id: str
recipe_id: str
item_type: str
item_id: str
position: int
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class RecipeResponse(BaseModel):
id: str
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemResponse] = Field(default_factory=list)
is_active: bool = True
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
class ListRecipesResponse(BaseModel):
items: List[RecipeResponse]
total: int = 0
class UseRecipeResponse(BaseModel):
recipe: RecipeResponse
warnings: List[Dict[str, Any]] = Field(default_factory=list)
# ── Request ──
class RecipeItemRequest(BaseModel):
item_type: str
item_id: str
position: int = 0
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class CreateRecipeRequest(BaseModel):
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemRequest] = Field(default_factory=list)
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class UpdateRecipeRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
template_id: Optional[str] = None
generation_params: Optional[Dict[str, Any]] = None
items: Optional[List[RecipeItemRequest]] = None
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata")
class Config:
populate_by_name = True
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel, Field
-2
View File
@@ -4,7 +4,6 @@ from .auto_clip_service import AutoClipService
from .edit_plan_service import EditPlanService
from .edit_template_service import EditTemplateService
from .job_service import JobService
from .plan_generator_service import PlanGeneratorService
from .video_compose_service import VideoComposeService
__all__ = [
@@ -12,6 +11,5 @@ __all__ = [
"EditPlanService",
"EditTemplateService",
"JobService",
"PlanGeneratorService",
"VideoComposeService",
]
+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
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
@@ -18,6 +18,7 @@ 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__)
@@ -100,7 +100,6 @@ class EditTemplateService:
*,
description: str = "",
template_type: str = "default",
editing_mode: str = "one_take",
config: Optional[dict[str, Any]] = None,
preview_url: str = "",
sort_weight: int = 0,
@@ -125,7 +124,6 @@ class EditTemplateService:
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=config,
preview_url=preview_url,
sort_weight=sort_weight,
@@ -141,7 +139,6 @@ class EditTemplateService:
name: Optional[str] = None,
description: Optional[str] = None,
template_type: Optional[str] = None,
editing_mode: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
preview_url: Optional[str] = None,
sort_weight: Optional[int] = None,
@@ -168,7 +165,6 @@ class EditTemplateService:
name=new_name,
description=description.strip() if description is not None else existing.description,
template_type=template_type.strip() if template_type is not None else existing.template_type,
editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode,
config=config if config is not None else existing.config,
preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
+1
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import logging
from typing import Any
from sqlalchemy.orm import Session
from packages.application.jobs import (
CancelJobUseCase,
@@ -1,391 +0,0 @@
"""PlanGeneratorService — 基于模板+素材自动生成剪辑计划.
核心职责:
- 根据 EditTemplate editing_mode TemplateClipConfig 列表
自动生成 EditPlan + EditPlanClip 列表
- 四种模式素材分配策略:
- ONE_TAKE: 素材顺序分配给 main 类型 clips
- PIP: 第1个素材main(全屏背景)其余overlay clips
- VOICE_OVER: 素材main clips (B-roll)标记需要配音叠加
- VOICE_PIP: 第1个background, 第2个corner_voice, 其余b_roll
"""
from __future__ import annotations
import logging
from typing import Any, List
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
)
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
logger = logging.getLogger(__name__)
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
_DEFAULT_CLIP_DURATION = 5.0
_DEFAULT_INTRO_DURATION = 3.0
_DEFAULT_OUTRO_DURATION = 3.0
class PlanGeneratorService:
"""剪辑计划生成器
基于模板 + 素材自动生成 EditPlan EditPlanClip 列表
"""
def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开接口 ─────────────────────────────────────────────────────────────
def generate_from_template(
self,
template: EditTemplate,
clip_configs: List[TemplateClipConfig],
asset_ids: List[str],
*,
project_id: str = "",
created_by_user_id: str = "",
name: str = "",
) -> dict[str, Any]:
"""基于模板+素材生成剪辑计划
Args:
template: 剪辑模板实体
clip_configs: 模板片段配置列表可为空自动生成默认结构
asset_ids: 素材 ID 列表
project_id: 所属项目 ID
created_by_user_id: 创建者用户 ID
name: 计划名称为空则自动取模板名
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
editing_mode = template.editing_mode or EditingMode.ONE_TAKE.value
plan_name = name.strip() or f"{template.name} - 剪辑计划"
# 1. 构建 plan config(继承模板的 title/subtitle/bgm,记录 editing_mode
plan_config = self._build_plan_config(template, editing_mode)
# 2. 创建 EditPlan
plan = EditPlan.create(
template_id=template.id,
name=plan_name,
config=plan_config,
total_duration=0.0,
project_id=project_id,
created_by_user_id=created_by_user_id,
)
plan = self._plan_repo.create(plan)
logger.info(
"生成剪辑计划: plan_id=%s template=%s mode=%s assets=%d",
plan.id,
template.id,
editing_mode,
len(asset_ids),
)
# 3. 生成片段列表
if clip_configs:
clips = self._create_clips_from_configs(plan.id, clip_configs)
else:
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
# 4. 按 editing_mode 分配素材
if asset_ids:
self._distribute_assets(clips, asset_ids, editing_mode)
# 5. 持久化所有 clips 并计算总时长
created_clips: List[EditPlanClip] = []
total_duration = 0.0
for clip in clips:
saved = self._clip_repo.create(clip)
created_clips.append(saved)
total_duration += saved.duration
# 6. 更新 plan 的 total_duration
plan.total_duration = total_duration
plan = self._plan_repo.update(plan)
# 7. 流转到 editing 状态
try:
plan.start_editing()
plan = self._plan_repo.update(plan)
except ValueError as exc:
logger.warning("计划状态流转失败: plan_id=%s error=%s", plan.id, exc)
logger.info(
"剪辑计划生成完成: plan_id=%s clips=%d duration=%.1f",
plan.id,
len(created_clips),
total_duration,
)
return {"plan": plan, "clips": created_clips}
# ── 内部方法 ─────────────────────────────────────────────────────────────
def _build_plan_config(
self,
template: EditTemplate,
editing_mode: str,
) -> dict[str, Any]:
"""从模板配置构建 plan config"""
template_config = template.config or {}
plan_config: dict[str, Any] = {
"editing_mode": editing_mode,
}
# 继承模板的 cover/title/subtitle/bgm 配置
for key in ("cover", "title", "subtitle", "bgm"):
if key in template_config:
plan_config[key] = template_config[key]
return normalize_plan_config(plan_config)
def _create_clips_from_configs(
self,
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = (
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
)
clips.append(clip)
return clips
def _generate_default_clips(
self,
plan_id: str,
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
- ONE_TAKE: N main clipsN = asset_count至少1个
- PIP: 1 main + (N-1) overlayN = asset_count
- VOICE_OVER: N main clips + 标记需要配音
- VOICE_PIP: 1 background + 1 corner_voice + (N-2) b_roll
"""
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for i in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for i in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
def _distribute_assets(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
editing_mode: str,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材main(全屏背景)其余交替分配给 overlay clips
- VOICE_OVER: 素材main clips (B-roll)
- VOICE_PIP: 第1个background, 第2个corner_voice, 其余b_roll
"""
if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
@@ -16,6 +16,7 @@ FFmpeg 视频合成编排服务:
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from typing import Any
@@ -27,7 +28,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 EditPlanStatus
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect
+161
View File
@@ -0,0 +1,161 @@
/**
* Mock API
*
* /
*
*/
/* ── 类型定义 ───────────────────────────────────────────── */
/** 平台 ID */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
/** 账号状态 */
export type AccountStatus = "active" | "expired" | "limited";
/** 已绑定的账号 */
export interface Account {
id: string;
platform_id: PlatformId;
name: string;
avatar?: string;
status: AccountStatus;
bound_at: string;
}
/** 平台信息 */
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
/** 绑定账号请求 */
export interface BindAccountRequest {
platform_id: PlatformId;
name: string;
}
/* ── 平台配置 ───────────────────────────────────────────── */
export const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布平台",
icon: "📱",
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布平台",
icon: "🎬",
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记发布平台",
icon: "📕",
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布平台",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
},
];
/* ── Mock 数据 ───────────────────────────────────────────── */
let MOCK_ACCOUNTS: Account[] = [
{
id: "acc-001",
platform_id: "douyin",
name: "小虾官方号",
avatar: "🦐",
status: "active",
bound_at: "2025-12-01T10:00:00Z",
},
{
id: "acc-002",
platform_id: "douyin",
name: "小虾日常",
avatar: "🐟",
status: "active",
bound_at: "2025-12-15T14:30:00Z",
},
{
id: "acc-003",
platform_id: "kuaishou",
name: "小虾剪辑",
avatar: "🎬",
status: "active",
bound_at: "2026-01-05T09:00:00Z",
},
{
id: "acc-004",
platform_id: "xiaohongshu",
name: "小虾种草",
avatar: "📕",
status: "limited",
bound_at: "2026-02-20T16:00:00Z",
},
];
/* ── 模拟延迟 ───────────────────────────────────────────── */
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ── API 函数 ───────────────────────────────────────────── */
/** 获取指定平台的账号列表 */
export async function getAccountsByPlatform(
platformId: PlatformId,
): Promise<Account[]> {
await delay(300);
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
}
/** 获取所有平台的账号总数 */
export async function getAllAccounts(): Promise<Account[]> {
await delay(200);
return [...MOCK_ACCOUNTS];
}
/** 绑定新账号 */
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
await delay(500);
const newAccount: Account = {
id: `acc-${Date.now()}`,
platform_id: data.platform_id,
name: data.name,
avatar: undefined,
status: "active",
bound_at: new Date().toISOString(),
};
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
return newAccount;
}
/** 解绑账号 */
export async function unbindAccount(accountId: string): Promise<void> {
await delay(400);
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
}
/* ── 状态配置 ───────────────────────────────────────────── */
export const ACCOUNT_STATUS_CONFIG: Record<
AccountStatus,
{ label: string; className: string }
> = {
active: { label: "正常", className: "acc-status--active" },
expired: { label: "已过期", className: "acc-status--expired" },
limited: { label: "受限", className: "acc-status--limited" },
};
+42
View File
@@ -0,0 +1,42 @@
/**
* 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;
};
@@ -0,0 +1,365 @@
/* 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;
}
@@ -0,0 +1,290 @@
/**
* CloneVoiceModal
*
* input uploading success
* mock
*
* V21 Design System antd
*/
import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { uploadAsset } from "@/api/assets";
import "./clone-voice-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalStep = "input" | "uploading" | "success";
export interface CloneVoiceModalProps {
/** 弹窗是否可见 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
}
/* ── 默认音色名称计数器 ─────────────────────────────────── */
let cloneCounter = 1;
const getNextDefaultName = (): string => {
const name = `我的声音 ${cloneCounter}`;
cloneCounter += 1;
return name;
};
/* ── 组件 ───────────────────────────────────────────────── */
const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [step, setStep] = useState<ModalStep>("input");
const [voiceName, setVoiceName] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setStep("input");
setVoiceName("");
setSelectedFile(null);
setIsRecording(false);
setDragActive(false);
}, []);
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
/** 上传区域点击 */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
/** 文件选择 */
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
// 清除之前的录制状态
setIsRecording(false);
}
// 清空 input 以允许重复选择同一文件
e.target.value = "";
};
/** 拖拽事件 */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext === "mp3" || ext === "wav") {
setSelectedFile(file);
setIsRecording(false);
}
}
};
/** 录制按钮(mock */
const handleRecord = () => {
setIsRecording((prev) => !prev);
if (!isRecording) {
// 开始录制 — 清除已选文件
setSelectedFile(null);
}
};
/** 开始克隆 */
const handleStartClone = async () => {
const name = voiceName.trim() || getNextDefaultName();
setStep("uploading");
try {
// 先上传音频文件获取真实 URL
let audioUrl: string;
if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("kind", "voice");
const uploadResult = await uploadAsset(formData);
audioUrl = uploadResult.url;
} else {
// 录制功能暂未实现,提示用户上传
setStep("input");
return;
}
// 提交克隆请求
const result = await createVoiceClone({
name,
audio_url: audioUrl,
});
setStep("success");
// 2秒后自动关闭
setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
} catch {
setStep("input");
}
};
/** 弹窗打开时初始化默认名称 */
const handleAfterOpenChange = (visible: boolean) => {
if (visible) {
setVoiceName(getNextDefaultName());
}
};
const canStart = selectedFile || isRecording;
return (
<Modal
open={open}
onCancel={handleClose}
title="🎤 克隆新音色"
width={520}
footer={null}
destroyOnClose
afterOpenChange={handleAfterOpenChange}
>
{/* ── 输入步骤 ──────────────────────────────────── */}
{step === "input" && (
<div className="cvm-body">
{/* 音色名称 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<input
type="text"
className="cvm-input"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
placeholder="输入音色名称"
/>
</div>
{/* 上传区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div
className={`cvm-upload-zone${dragActive ? " cvm-upload-zone--active" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="cvm-upload-icon">🎵</div>
<p className="cvm-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处"}
</p>
<p className="cvm-upload-hint"> MP3WAV </p>
<input
ref={fileInputRef}
type="file"
accept=".mp3,.wav,audio/mpeg,audio/wav"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
</div>
{/* 或分隔 */}
<div className="cvm-divider">
<div className="cvm-divider-line" />
<span className="cvm-divider-text"></span>
<div className="cvm-divider-line" />
</div>
{/* 录制区域 */}
<div className="cvm-field">
<label className="cvm-label"></label>
<div className="cvm-record-area">
<p className="cvm-record-hint">
{isRecording
? "录制中…再次点击停止"
: "点击按钮开始录制你的声音"}
</p>
<button
type="button"
className={`cvm-record-btn${isRecording ? " cvm-record-btn--recording" : ""}`}
onClick={handleRecord}
>
🎙
</button>
</div>
</div>
{/* 提示 */}
<div className="cvm-tip">
<span className="cvm-tip-icon">💡</span>
<span>
10~3
</span>
</div>
{/* 底部按钮 */}
<div className="cvm-footer">
<Button buttonType="ghost" onClick={handleClose}>
</Button>
<Button
buttonType="primary"
disabled={!canStart}
onClick={handleStartClone}
>
🎤
</Button>
</div>
</div>
)}
{/* ── 上传中步骤 ────────────────────────────────── */}
{step === "uploading" && (
<div className="cvm-uploading">
<div className="cvm-uploading-spinner" />
<p className="cvm-uploading-text"></p>
<p className="cvm-uploading-sub">AI </p>
</div>
)}
{/* ── 成功步骤 ──────────────────────────────────── */}
{step === "success" && (
<div className="cvm-success">
<div className="cvm-success-icon"></div>
<h3 className="cvm-success-title"></h3>
<p className="cvm-success-desc">
</p>
</div>
)}
</Modal>
);
};
export default CloneVoiceModal;
@@ -0,0 +1,325 @@
/**
* CloneVoiceModal V21 Design System
*
* 音色克隆弹窗样式
* 三步骤状态input uploading success
*/
/* ── 弹窗内容区 ─────────────────────────────────────────── */
.cvm-body {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── 表单区 ─────────────────────────────────────────────── */
.cvm-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.cvm-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #475467);
}
.cvm-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-sm);
background: var(--bg-surface, #fff);
color: var(--text-primary, #101828);
font-size: 14px;
line-height: 1.5;
transition:
border-color 0.2s,
box-shadow 0.2s;
outline: none;
}
.cvm-input:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cvm-input::placeholder {
color: var(--muted, #98a2b3);
}
/* ── 上传区域 ───────────────────────────────────────────── */
.cvm-upload-zone {
border: 2px dashed var(--line, #e4e7ec);
border-radius: var(--radius-md);
padding: 28px 20px;
text-align: center;
background: var(--bg-subtle, #f8fafc);
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
}
.cvm-upload-zone:hover {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
}
.cvm-upload-zone.cvm-upload-zone--active {
border-color: var(--primary, #6366f1);
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
}
.cvm-upload-icon {
font-size: 36px;
margin-bottom: 8px;
line-height: 1;
}
.cvm-upload-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #101828);
margin: 0 0 4px;
}
.cvm-upload-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 或分隔线 ───────────────────────────────────────────── */
.cvm-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 4px 0;
}
.cvm-divider-line {
flex: 1;
height: 1px;
background: var(--line, #e4e7ec);
}
.cvm-divider-text {
font-size: 13px;
color: var(--muted, #98a2b3);
flex-shrink: 0;
}
/* ── 录制区域 ───────────────────────────────────────────── */
.cvm-record-area {
border: 1px solid var(--line, #e4e7ec);
border-radius: var(--radius-md);
padding: 24px;
text-align: center;
}
.cvm-record-hint {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0 0 14px;
}
.cvm-record-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 32px;
line-height: 1;
padding: 0;
background: linear-gradient(
135deg,
var(--error-color, #ef4444),
var(--error-dark, #dc2626)
);
color: var(--text-inverse);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
transition:
transform 0.15s,
box-shadow 0.15s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cvm-record-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 20px
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
}
.cvm-record-btn:active {
transform: scale(0.96);
}
.cvm-record-btn--recording {
animation: cvm-pulse 1.2s ease-in-out infinite;
}
@keyframes cvm-pulse {
0%,
100% {
box-shadow: 0 4px 14px
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
}
50% {
box-shadow: 0 4px 28px
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
}
}
/* ── 提示条 ─────────────────────────────────────────────── */
.cvm-tip {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
background: var(--warning-soft, #fef3c7);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--warning-color, #92400e);
line-height: 1.5;
}
.cvm-tip-icon {
flex-shrink: 0;
font-size: 14px;
line-height: 1.5;
}
/* ── 底部按钮 ───────────────────────────────────────────── */
.cvm-footer {
display: flex;
gap: 12px;
margin-top: 4px;
}
.cvm-footer .xx-btn {
flex: 1;
}
/* ── 上传中状态 ─────────────────────────────────────────── */
.cvm-uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 16px;
}
.cvm-uploading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--line, #e4e7ec);
border-top-color: var(--primary, #6366f1);
border-radius: 50%;
animation: cvm-spin 0.8s linear infinite;
}
@keyframes cvm-spin {
to {
transform: rotate(360deg);
}
}
.cvm-uploading-text {
font-size: 15px;
font-weight: 500;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-uploading-sub {
font-size: 13px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 成功状态 ───────────────────────────────────────────── */
.cvm-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
gap: 12px;
}
.cvm-success-icon {
font-size: 56px;
line-height: 1;
}
.cvm-success-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #101828);
margin: 0;
}
.cvm-success-desc {
font-size: 14px;
color: var(--muted, #98a2b3);
margin: 0;
}
/* ── 响应式 ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.cvm-overlay {
padding: var(--space-md);
}
.cvm-modal {
width: 100%;
max-width: 100%;
padding: var(--space-lg);
}
}
@media (max-width: 576px) {
.cvm-upload-zone {
padding: 20px 14px;
}
.cvm-record-btn {
width: 64px;
height: 64px;
font-size: 26px;
}
.cvm-footer {
flex-direction: column;
}
}
@media (max-width: 480px) {
.cvm-record-btn {
width: 60px;
height: 60px;
}
.cvm-tip {
font-size: 12px;
padding: var(--space-sm);
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* V21 Form
* Ant Design Form V21
*/
import React from "react";
import { Form as AntForm } from "antd";
import type { FormProps as AntFormProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface FormProps extends AntFormProps {
/** 紧凑模式(减小表单项间距) */
compact?: boolean;
}
const Form = ({ className, compact, children, ...rest }: FormProps) => {
const v21Class = classNames(
"xx-form",
compact && "xx-form-compact",
className,
);
return (
<AntForm
className={v21Class}
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
>
{children as React.ReactNode}
</AntForm>
);
};
/** 导出 Form 的子组件(保持 antd API 一致) */
export const FormItem = AntForm.Item;
export const FormList = AntForm.List;
export const FormProvider = AntForm.Provider;
export default Form;
+25
View File
@@ -0,0 +1,25 @@
/**
* V21 Pagination
* Ant Design Pagination V21
*/
import React from "react";
import { Pagination as AntPagination } from "antd";
import type { PaginationProps as AntPaginationProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface PaginationProps extends AntPaginationProps {
/** 使用 V21 样式 */
v21?: boolean;
}
const Pagination: React.FC<PaginationProps> = ({
className,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-pagination", className);
return <AntPagination className={v21Class} {...rest} />;
};
export default Pagination;
+29
View File
@@ -0,0 +1,29 @@
/**
* V21 Table
* Ant Design Table V21
*/
import React from "react";
import { Table as AntTable } from "antd";
import type { TableProps as AntTableProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface TableProps<
RecordType = unknown,
> extends AntTableProps<RecordType> {
/** 使用 V21 样式 */
v21?: boolean;
}
function Table<RecordType extends object = Record<string, unknown>>({
className,
v21 = true,
...rest
}: TableProps<RecordType>) {
const v21Class = classNames(v21 && "xx-table", className);
return <AntTable<RecordType> className={v21Class} {...rest} />;
}
export default Table as <RecordType extends object = Record<string, unknown>>(
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
) => React.ReactElement;
+9
View File
@@ -15,6 +15,9 @@ export type { SelectProps } from "./Select";
export { default as Modal } from "./Modal";
export type { ModalProps } from "./Modal";
export { default as Table } from "./Table";
export type { TableProps } from "./Table";
export { default as Card } from "./Card";
export type { CardProps } from "./Card";
@@ -23,3 +26,9 @@ export type { TagProps, TagVariant } from "./Tag";
export { Tooltip, Popover } from "./Tooltip";
export type { TooltipProps, PopoverProps } from "./Tooltip";
export { default as Form, FormItem, FormList, FormProvider } from "./Form";
export type { FormProps } from "./Form";
export { default as Pagination } from "./Pagination";
export type { PaginationProps } from "./Pagination";
-20
View File
@@ -532,23 +532,3 @@
padding: 8px 16px !important;
}
}
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
.xx-card .ant-card-head {
border-bottom: 1px solid var(--border-color);
padding: 20px 24px;
}
.xx-card .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--text-primary);
}
.xx-card .ant-card-body {
padding: 24px;
}
+211
View File
@@ -0,0 +1,211 @@
/**
*
* 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 />,
},
],
},
];
+198 -75
View File
@@ -2,66 +2,194 @@
* V21 Design System
*
* ///
* API
* /
*
* antd 使 CSS
*/
import React from "react";
import React, { useState, useCallback } from "react";
import { useQueries, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
PLATFORMS,
getAccountsByPlatform,
unbindAccount,
bindAccount,
ACCOUNT_STATUS_CONFIG,
type Platform,
type Account,
type PlatformId,
} from "@/api/accounts";
import "./accounts.css";
/* ── 类型定义 ───────────────────────────────────────────── */
/* ── Toast 系统 ─────────────────────────────────────────── */
export type PlatformId =
| "douyin"
| "kuaishou"
| "xiaohongshu"
| "wechat";
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
interface Toast {
id: number;
message: string;
type: "success" | "error";
}
/** 支持的平台列表 */
const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布",
icon: "🎵",
gradient: "linear-gradient(135deg, #000 0%, #333 100%)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布",
icon: "📹",
gradient: "linear-gradient(135deg, #ff6600 0%, #ff9933 100%)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记 + 视频",
icon: "📕",
gradient: "linear-gradient(135deg, #fe2c55 0%, #ff6680 100%)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)",
},
];
let toastIdCounter = 0;
/* ── 平台卡片组件 ───────────────────────────────────────── */
interface PlatformCardProps {
platform: Platform;
accounts: Account[];
isLoading: boolean;
onBind: (platformId: PlatformId) => void;
onUnbind: (accountId: string, accountName: string) => void;
}
const PlatformCard: React.FC<PlatformCardProps> = ({
platform,
accounts,
isLoading,
onBind,
onUnbind,
}) => {
return (
<div className="acc-card">
{/* 平台头部 */}
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
{/* 账号列表 */}
<div className="acc-account-list">
{isLoading ? (
<div className="acc-empty">
<p className="acc-empty-text"></p>
</div>
) : accounts.length > 0 ? (
accounts.map((account) => {
const statusCfg = ACCOUNT_STATUS_CONFIG[account.status];
return (
<div key={account.id} className="acc-account-row">
<div
className="acc-account-avatar"
style={{ background: platform.gradient }}
>
{account.avatar || platform.icon}
</div>
<div className="acc-account-info">
<div className="acc-account-name">{account.name}</div>
<span className={`acc-status-pill ${statusCfg.className}`}>
{statusCfg.label}
</span>
</div>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onUnbind(account.id, account.name)}
>
</Button>
</div>
);
})
) : (
<div className="acc-empty">
<div className="acc-empty-icon">🔓</div>
<p className="acc-empty-text"></p>
</div>
)}
</div>
{/* 绑定按钮 */}
<Button buttonType="ghost" onClick={() => onBind(platform.id)}>
+
</Button>
</div>
);
};
/* ── 主页面 ─────────────────────────────────────────────── */
const Accounts: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 查询所有平台的账号 */
const _queriesResults = useQueries({
queries: PLATFORMS.map((platform) => ({
queryKey: ["accounts", platform.id] as const,
queryFn: () => getAccountsByPlatform(platform.id),
})),
});
const accountQueries = PLATFORMS.map((platform, i) => ({
platform,
..._queriesResults[i],
}));
/** 解绑 mutation */
const unbindMutation = useMutation({
mutationFn: unbindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("已解绑账号", "success");
},
onError: () => {
showToast("解绑失败", "error");
},
});
/** 绑定 mutationmock */
const bindMutation = useMutation({
mutationFn: bindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("账号绑定成功", "success");
},
onError: () => {
showToast("绑定失败", "error");
},
});
/** 绑定新账号(mock:直接创建) */
const handleBind = (platformId: PlatformId) => {
const platform = PLATFORMS.find((p) => p.id === platformId);
if (!platform) return;
const name = window.prompt(`请输入要绑定的${platform.name}账号名称:`);
if (name && name.trim()) {
bindMutation.mutate({ platform_id: platformId, name: name.trim() });
}
};
/** 解绑账号 */
const handleUnbind = (accountId: string, accountName: string) => {
if (window.confirm(`确定解绑账号「${accountName}」吗?`)) {
unbindMutation.mutate(accountId);
}
};
/** 统计已绑定账号数 */
const totalBound = accountQueries.reduce(
(sum, q) => sum + (q.data?.length ?? 0),
0,
);
const totalPlatforms = PLATFORMS.length;
return (
<div className="acc-page">
<PageHead
@@ -69,34 +197,17 @@ const Accounts: React.FC = () => {
description="绑定您的社交平台账号,用于视频一键发布到各平台"
/>
{/* 平台卡片网格 — 占位状态 */}
{/* 平台卡片网格 */}
<div className="acc-grid">
{PLATFORMS.map((platform) => (
<div key={platform.id} className="acc-card">
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
<div className="acc-account-list">
<div className="acc-empty">
<div className="acc-empty-icon">🔒</div>
<p className="acc-empty-text">线</p>
</div>
</div>
<Button buttonType="ghost" disabled>
</Button>
</div>
{accountQueries.map(({ platform, data, isLoading }) => (
<PlatformCard
key={platform.id}
platform={platform}
accounts={data ?? []}
isLoading={isLoading}
onBind={handleBind}
onUnbind={handleUnbind}
/>
))}
</div>
@@ -104,10 +215,22 @@ const Accounts: React.FC = () => {
<div className="acc-stats-bar">
<span className="acc-stats-icon">📊</span>
<span className="acc-stats-text">
<span className="acc-stats-highlight">{PLATFORMS.length}</span>{" "}
线
<span className="acc-stats-highlight">{totalBound}</span>{" "}
/ {" "}
<span className="acc-stats-highlight">{totalPlatforms}</span>
</span>
</div>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
+82
View File
@@ -82,6 +82,77 @@
gap: 10px;
}
/* ── 账号行 ─────────────────────────────────────────────── */
.acc-account-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.acc-account-row:hover {
background: var(--primary-soft);
}
.acc-account-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text-inverse);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.acc-account-info {
flex: 1;
min-width: 0;
}
.acc-account-name {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 状态标签 ───────────────────────────────────────────── */
.acc-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
margin-top: 2px;
}
.acc-status--active {
background: var(--success-soft);
color: var(--secondary-color);
}
.acc-status--expired {
background: var(--error-soft);
color: var(--error-color);
}
.acc-status--limited {
background: var(--warning-soft);
color: var(--accent-color);
}
/* ── 空状态 ─────────────────────────────────────────────── */
.acc-empty {
@@ -164,6 +235,17 @@
font-size: var(--font-size-base);
}
.acc-account-row {
padding: 10px;
gap: 8px;
}
.acc-account-avatar {
width: 32px;
height: 32px;
font-size: var(--font-size-xs);
}
.acc-stats-bar {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
+500 -10
View File
@@ -1,20 +1,482 @@
/* Admin 页面样式Phase 3 精简
*
* 原始 477 精简至仅保留实际使用的 class
* 已迁移至 global.css / ui.css 的样式不再重复定义
* .xx-page-head global.css
* .xx-primary-btn global.css
* .xx-tag / .xx-card ui.css / global.css
*
* 以下 class 仅被 AdminComingSoon.tsx 使用
*/
/* V21 Admin 页面样式 */
/* 页面容器 */
.dashboard-page,
.analytics-page,
.user-management-page,
.log-viewer-page,
.system-monitor-page,
.admin-coming-soon-page {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
/* 页面头部 */
.xx-page-head {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-page-head-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-page-head h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0;
letter-spacing: -0.02em;
}
.xx-page-head p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
.xx-page-head-actions {
display: flex;
gap: 12px;
align-items: center;
}
/* 简化页面头部(无操作按钮) */
.xx-page-head-simple {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
}
.xx-page-head-simple h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 8px;
letter-spacing: -0.02em;
}
.xx-page-head-simple p {
font-size: 15px;
color: var(--muted, #64748b);
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;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 32px;
}
@media (max-width: 1200px) {
.xx-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-grid-4 {
grid-template-columns: 1fr;
}
}
/* 统计卡片 */
.xx-stat-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-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.xx-stat-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.xx-stat-card-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.xx-stat-card-icon.primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.xx-stat-card-icon.success {
background: linear-gradient(135deg, #34d399, #10b981);
color: white;
}
.xx-stat-card-icon.warning {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
}
.xx-stat-card-icon.purple {
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
color: white;
}
.xx-stat-card-icon.info {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
color: white;
}
.xx-stat-card-icon.orange {
background: linear-gradient(135deg, #fb923c, #f97316);
color: white;
}
.xx-stat-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.xx-stat-card-value {
font-size: 32px;
font-weight: 900;
color: var(--slate, #0f172a);
line-height: 1.2;
letter-spacing: -0.02em;
}
.xx-stat-card-value.primary {
color: var(--indigo, #4f46e5);
}
.xx-stat-card-value.success {
color: var(--green, #10b981);
}
.xx-stat-card-value.warning {
color: var(--amber, #f59e0b);
}
.xx-stat-card-value.purple {
color: #8b5cf6;
}
.xx-stat-card-growth {
font-size: 13px;
color: var(--green, #10b981);
font-weight: 600;
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
/* 数据表格 */
.xx-table-wrapper {
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);
overflow: hidden;
}
.xx-table-wrapper .ant-table {
background: transparent;
}
.xx-table-wrapper .ant-table-thead > tr > th {
background: rgba(248, 250, 252, 0.8);
font-weight: 800;
font-size: 13px;
color: var(--slate, #0f172a);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 16px 20px;
}
.xx-table-wrapper .ant-table-tbody > tr > td {
padding: 16px 20px;
font-size: 14px;
}
.xx-table-wrapper .ant-table-tbody > tr:hover > td {
background: rgba(79, 70, 229, 0.03);
}
/* V21 按钮 */
.xx-primary-btn {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
border: none !important;
border-radius: var(--radius-md) !important;
font-weight: 700 !important;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25) !important;
transition: all 0.2s !important;
}
.xx-primary-btn:hover {
box-shadow: 0 12px 28px rgba(79, 70, 229, 0.3) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: white !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
color: var(--slate, #0f172a) !important;
border-radius: var(--radius-md) !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.xx-ghost-btn:hover {
border-color: var(--indigo, #4f46e5) !important;
color: var(--indigo, #4f46e5) !important;
}
/* V21 输入框 */
.xx-search-input {
border-radius: var(--radius-md) !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
padding: 8px 16px !important;
}
.xx-search-input:hover,
.xx-search-input:focus {
border-color: var(--indigo, #4f46e5) !important;
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;
font-weight: 600 !important;
font-size: 12px !important;
padding: 4px 10px !important;
}
.xx-tag.info {
background: rgba(59, 130, 246, 0.1) !important;
color: #3b82f6 !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
}
.xx-tag.success {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
}
.xx-tag.warning {
background: rgba(245, 158, 11, 0.1) !important;
color: #f59e0b !important;
border: 1px solid rgba(245, 158, 11, 0.2) !important;
}
.xx-tag.error {
background: rgba(239, 68, 68, 0.1) !important;
color: #ef4444 !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
}
.xx-tag.debug {
background: rgba(100, 116, 139, 0.1) !important;
color: #64748b !important;
border: 1px solid rgba(100, 116, 139, 0.2) !important;
}
/* V21 Progress */
.xx-progress-primary .ant-progress-circle .ant-progress-text {
color: var(--indigo, #4f46e5) !important;
font-weight: 700 !important;
}
.xx-progress-success .ant-progress-circle .ant-progress-text {
color: var(--green, #10b981) !important;
font-weight: 700 !important;
}
.xx-progress-warning .ant-progress-circle .ant-progress-text {
color: var(--amber, #f59e0b) !important;
font-weight: 700 !important;
}
/* 图表容器 */
.xx-chart-container {
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;
}
.xx-chart-container .ant-card-head {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-chart-container .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--slate, #0f172a);
}
/* 筛选器区域 */
.xx-filter-bar {
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: 20px 24px;
margin-bottom: 24px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
/* 资源监控卡片 */
.xx-resource-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;
text-align: center;
}
.xx-resource-card-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
}
.xx-resource-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 600;
margin-bottom: 16px;
}
/* 日期选择器 */
.xx-date-picker {
border-radius: var(--radius-md) !important;
}
/* Drawer */
.xx-drawer .ant-drawer-header {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-drawer .ant-drawer-title {
font-weight: 800;
font-size: 18px;
color: var(--slate, #0f172a);
}
/* 日志详情 */
.xx-log-detail {
padding: 4px 0;
}
.xx-log-detail-label {
font-size: 13px;
font-weight: 700;
color: var(--slate, #0f172a);
margin-bottom: 4px;
}
.xx-log-detail-value {
font-size: 14px;
color: var(--muted, #64748b);
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.xx-log-detail-code {
font-family: "JetBrains Mono", "Fira Code", monospace;
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
word-break: break-all;
}
/* Result 页面居中 */
.xx-result-center {
display: flex;
justify-content: center;
@@ -25,3 +487,31 @@
.xx-result-center .ant-result {
padding: 48px;
}
/* 刷新时间显示 */
.xx-refresh-time {
font-size: 13px;
color: var(--muted, #64748b);
margin-right: 12px;
}
/* 图表配色覆盖 */
.recharts-text {
fill: #64748b !important;
font-size: 12px !important;
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: rgba(226, 232, 240, 0.8) !important;
}
/* 图表 Legend */
.recharts-legend-wrapper {
padding-top: 16px !important;
}
.recharts-legend-item-text {
color: #64748b !important;
font-size: 13px !important;
}
+423 -57
View File
@@ -1,85 +1,451 @@
/**
* V21
* KPI + + + 使 +
* CSS V21
* 使 mock CSS V21
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui";
import { DatabaseOutlined } from "@ant-design/icons";
import { Button, Tag } from "@/components/ui";
import {
VideoCameraOutlined,
AppstoreOutlined,
ThunderboltOutlined,
DatabaseOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import "./dashboard.css";
/* ── 主组件 ─────────────────────────────────────────────── */
/* ============================================================
* Mock
* ============================================================ */
interface KpiItem {
key: string;
icon: string;
iconGradient: string;
value: string;
label: string;
trend: string;
trendDirection: "up" | "down" | "neutral";
accent: string;
}
const kpiData: KpiItem[] = [
{
key: "projects",
icon: "video",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
value: "12",
label: "项目总数",
trend: "↑ 2 本月新增",
trendDirection: "up",
accent: "#6366f1",
},
{
key: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
value: "486",
label: "素材总数",
trend: "↑ 38 本月上传",
trendDirection: "up",
accent: "#0ea5e9",
},
{
key: "generations",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
value: "156",
label: "本月生成数",
trend: "↑ 23% 较上月",
trendDirection: "up",
accent: "#10b981",
},
{
key: "storage",
icon: "database",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
value: "2.4GB",
label: "存储空间",
trend: "已用 24%",
trendDirection: "neutral",
accent: "#f59e0b",
},
];
interface QuickEntry {
id: string;
icon: string;
iconGradient: string;
title: string;
description: string;
path: string;
}
const quickEntries: QuickEntry[] = [
{
id: "titles",
icon: "filetext",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
title: "标题库",
description: "24条标题 · 5个分类",
path: "/app/titles",
},
{
id: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
title: "素材库",
description: "486个素材 · 3个素材库",
path: "/app/assets",
},
{
id: "generate",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
title: "一键生成",
description: "开始创作新视频",
path: "/app/generate",
},
{
id: "products",
icon: "video",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
title: "成片库",
description: "89个成片 · 3个待复核",
path: "/app/products",
},
];
type TaskStatus = "completed" | "processing" | "pending" | "failed";
interface RecentTask {
id: string;
name: string;
type: string;
template: string;
status: TaskStatus;
date: string;
duration?: string;
}
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
};
const recentTasks: RecentTask[] = [
{
id: "t-1",
name: "产品介绍视频_春季促销",
type: "视频生成",
template: "商品展示模板",
status: "completed",
date: "2026-07-01 09:30",
duration: "2分18秒",
},
{
id: "t-2",
name: "品牌宣传片_终版",
type: "视频生成",
template: "品牌宣传模板",
status: "processing",
date: "2026-07-01 10:15",
},
{
id: "t-3",
name: "用户评价合集",
type: "视频生成",
template: "评价展示模板",
status: "completed",
date: "2026-06-30 16:42",
duration: "1分45秒",
},
{
id: "t-4",
name: "新品发布预告",
type: "视频生成",
template: "新品预告模板",
status: "pending",
date: "2026-06-30 14:20",
},
{
id: "t-5",
name: "活动回顾_618大促",
type: "视频生成",
template: "活动回顾模板",
status: "failed",
date: "2026-06-29 11:05",
},
];
interface ChartItem {
label: string;
value: number;
}
const weeklyData: ChartItem[] = [
{ label: "周一", value: 18 },
{ label: "周二", value: 25 },
{ label: "周三", value: 32 },
{ label: "周四", value: 28 },
{ label: "周五", value: 42 },
{ label: "周六", value: 15 },
{ label: "周日", value: 8 },
];
interface Announcement {
id: string;
tag: "update" | "notice" | "activity";
tagLabel: string;
title: string;
date: string;
}
const announcements: Announcement[] = [
{
id: "a-1",
tag: "update",
tagLabel: "更新",
title: "系统已升级至 v2.0,新增批量生成功能",
date: "2026-07-01",
},
{
id: "a-2",
tag: "activity",
tagLabel: "活动",
title: "7月创作挑战赛已开启,参与赢积分奖励",
date: "2026-06-28",
},
{
id: "a-3",
tag: "notice",
tagLabel: "公告",
title: "7月3日凌晨 2:00-4:00 系统维护通知",
date: "2026-06-25",
},
];
/* ============================================================
*
* ============================================================ */
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return "夜深了";
if (hour < 12) return "早上好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
};
const formatDate = () => {
const d = new Date();
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${weekDays[d.getDay()]}`;
};
/** 图标名称 → Ant Design 组件映射 */
const iconMap: Record<string, React.ReactNode> = {
video: <VideoCameraOutlined />,
appstore: <AppstoreOutlined />,
thunderbolt: <ThunderboltOutlined />,
database: <DatabaseOutlined />,
filetext: <FileTextOutlined />,
};
/* ============================================================
*
* ============================================================ */
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const maxChart = Math.max(...weeklyData.map((d) => d.value));
return (
<div className="xx-dashboard-page">
{/* KPI 卡片网格 */}
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
<div className="xx-dashboard-welcome">
<h2>{getGreeting()}</h2>
<p>{formatDate()} </p>
</div>
{/* ── KPI 卡片网格 ─────────────────────────────────────── */}
<div className="xx-kpi-grid">
<div className="xx-dashboard-empty">
<p></p>
{kpiData.map((item) => (
<div
key={item.key}
className="xx-kpi-card"
style={{ "--kpi-accent": item.accent } as React.CSSProperties}
>
<div
className="xx-kpi-icon"
style={{ background: item.iconGradient }}
>
{iconMap[item.icon] ?? item.icon}
</div>
<div className="xx-kpi-value">{item.value}</div>
<div className="xx-kpi-label">{item.label}</div>
<span
className={`xx-kpi-trend xx-kpi-trend--${item.trendDirection}`}
>
{item.trend}
</span>
</div>
))}
</div>
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
<div className="xx-dashboard-main">
{/* 左列 */}
<div className="xx-dashboard-left-col">
{/* 最近任务 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<button onClick={() => navigate("/app/history")}></button>
</div>
<div className="xx-task-list">
{recentTasks.map((task) => (
<div key={task.id} className="xx-task-item">
<div className="xx-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
<Tag
variant={
task.status === "completed"
? "success"
: task.status === "processing"
? "info"
: task.status === "failed"
? "error"
: "warning"
}
>
{statusLabel[task.status]}
</Tag>
<div className="xx-task-time">
<span>{task.date}</span>
{task.status === "completed"
? `耗时 ${task.duration}`
: task.status === "processing"
? "生成中..."
: task.status === "failed"
? "请重试"
: "等待中"}
</div>
<div className="xx-task-action">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/app/history")}
>
</Button>
</div>
</div>
))}
</div>
</div>
{/* 使用统计图表 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<span className="xx-chart-total">
{weeklyData.reduce((s, d) => s + d.value, 0)}
</span>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-bar-wrapper">
<div
className="xx-chart-bar"
style={{
height: `${(d.value / maxChart) * 100}%`,
}}
>
<span className="xx-chart-bar-value">{d.value}</span>
</div>
</div>
))}
</div>
<div className="xx-chart-labels">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-label">
{d.label}
</div>
))}
</div>
</div>
</div>
</div>
{/* 右列 — 公告 + 存储用量 */}
<div className="xx-dashboard-section xx-dashboard-section--start">
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
{announcements.map((a) => (
<div key={a.id} className="xx-announcement-item">
<span
className={`xx-announcement-tag xx-announcement-tag--${a.tag}`}
>
{a.tagLabel}
</span>
<div className="xx-announcement-content">
<h4>{a.title}</h4>
<time>{a.date}</time>
</div>
</div>
))}
</div>
{/* 存储用量 */}
<div className="xx-storage-section">
<div className="xx-storage-section-title"></div>
<div className="xx-storage-bar">
<div className="xx-storage-bar-track">
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
</div>
<div className="xx-storage-bar-label">
<span>2.4 GB </span>
<span>10 GB </span>
</div>
</div>
</div>
</div>
</div>
{/* 快速入口 */}
{/* ── 快速入口 ─────────────────────────────────────────── */}
<div className="xx-quick-entry-section">
<div className="xx-quick-entry-header">
<h3 className="xx-quick-entry-title"></h3>
</div>
<div className="xx-quick-grid">
<div className="xx-dashboard-empty">
<p></p>
</div>
{quickEntries.map((entry) => (
<div
key={entry.id}
className="xx-quick-card"
onClick={() => navigate(entry.path)}
>
<div
className="xx-quick-card-icon"
style={{ background: entry.iconGradient }}
>
{iconMap[entry.icon] ?? entry.icon}
</div>
<h3>{entry.title}</h3>
<p>{entry.description}</p>
</div>
))}
</div>
</div>
{/* 最近任务 */}
<section className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
</Button>
</div>
<div className="xx-task-list">
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
</section>
{/* 使用统计 */}
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3>使</h3>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
<div className="xx-dashboard-empty" style={{ width: "100%" }}>
<DatabaseOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<p></p>
</div>
</div>
</div>
</section>
{/* 公告 */}
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
<div className="xx-announcement-item">
<span className="xx-announcement-tag xx-announcement-tag--notice"></span>
<div className="xx-announcement-content">
<h4>使 SaaS </h4>
<time></time>
</div>
</div>
</div>
</section>
</div>
);
};
+364 -34
View File
@@ -1,6 +1,6 @@
/**
* 控制台页面 - V21 设计系统样式
* KPI 卡片网格 + 快速入口 + 最近任务 + 使用统计图表 + 公告
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* 统一使用 CSS 变量支持深色/浅色主题
*/
@import "../../styles/global.css";
@@ -13,6 +13,26 @@
padding: var(--space-xl);
}
/* ============================================================
欢迎头部
============================================================ */
.xx-dashboard-welcome {
margin-bottom: var(--space-lg);
}
.xx-dashboard-welcome h2 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-dashboard-welcome p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
KPI 卡片网格
============================================================ */
@@ -23,6 +43,76 @@
margin-bottom: var(--space-lg);
}
.xx-kpi-card {
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
transition: 0.18s ease;
position: relative;
overflow: hidden;
}
.xx-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.xx-kpi-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
.xx-kpi-value {
font-size: 30px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
margin-bottom: var(--space-xs);
}
.xx-kpi-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
.xx-kpi-trend {
font-size: 12px;
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-kpi-trend--up {
color: var(--success-color);
}
.xx-kpi-trend--down {
color: var(--error-color);
}
.xx-kpi-trend--neutral {
color: var(--text-tertiary);
}
/* ============================================================
主内容区两栏布局
============================================================ */
.xx-dashboard-main {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* ============================================================
区块卡片
============================================================ */
@@ -71,25 +161,7 @@
}
/* ============================================================
空状态
============================================================ */
.xx-dashboard-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-md);
text-align: center;
color: var(--text-tertiary);
}
.xx-dashboard-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ============================================================
最近任务列表
最近任务卡片列表
============================================================ */
.xx-task-list {
display: flex;
@@ -97,6 +169,86 @@
gap: var(--space-sm);
}
.xx-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: var(--space-md);
align-items: center;
transition: var(--transition-all);
}
.xx-task-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.xx-task-info h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.xx-task-info span {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-task-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 3px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
}
.xx-task-status--completed {
color: var(--success-color);
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.xx-task-status--processing {
color: var(--info-color);
background: var(--primary-soft);
border: 1px solid var(--color-primary-200);
}
.xx-task-status--pending {
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.xx-task-status--failed {
color: var(--error-color);
background: var(--error-soft);
border: 1px solid var(--error-border);
}
.xx-task-time {
text-align: right;
font-size: var(--font-size-xs);
color: var(--text-secondary);
min-width: 100px;
}
.xx-task-time span {
display: block;
margin-bottom: var(--space-xxs);
}
.xx-task-action {
font-size: var(--font-size-xs);
}
/* ============================================================
使用统计图表 CSS 柱状图
============================================================ */
@@ -112,27 +264,69 @@
padding-top: var(--space-sm);
}
/* ============================================================
快速入口
============================================================ */
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
.xx-chart-bar-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
height: 100%;
justify-content: flex-end;
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
.xx-chart-bar {
width: 100%;
max-width: 36px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--gradient-primary);
transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
min-height: 4px;
cursor: pointer;
}
.xx-chart-bar:hover {
opacity: 0.85;
}
.xx-chart-bar:active {
opacity: 0.7;
transform: scaleY(0.97);
transform-origin: bottom;
}
.xx-chart-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
opacity: 0;
transition: var(--transition-opacity);
}
.xx-chart-bar:hover .xx-chart-bar-value {
opacity: 1;
}
.xx-chart-labels {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.xx-chart-label {
flex: 1;
text-align: center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* ============================================================
快速入口卡片网格
============================================================ */
.xx-quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -140,6 +334,53 @@
margin-bottom: var(--space-lg);
}
.xx-quick-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
cursor: pointer;
transition: var(--transition-all);
text-align: center;
}
.xx-quick-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.xx-quick-card:active {
transform: translateY(0) scale(0.98);
box-shadow: var(--shadow-sm);
transition-duration: 0.1s;
}
.xx-quick-card-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
margin: 0 auto var(--space-md);
color: var(--text-inverse);
}
.xx-quick-card h3 {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-card p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* ============================================================
公告区域
============================================================ */
@@ -210,10 +451,90 @@
color: var(--text-secondary);
}
/* ============================================================
存储用量条
============================================================ */
.xx-storage-bar {
margin-top: var(--space-sm);
}
.xx-storage-bar-track {
height: var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--space-xs);
overflow: hidden;
}
.xx-storage-bar-fill {
height: 100%;
border-radius: var(--space-xs);
background: var(--gradient-primary);
transition: width 0.6s ease;
}
.xx-storage-bar-label {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* ============================================================
迁移自内联样式的工具类
============================================================ */
.xx-dashboard-left-col {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.xx-chart-total {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-dashboard-section--start {
align-self: start;
}
.xx-storage-section {
margin-top: var(--space-md);
}
.xx-storage-section-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-dashboard-main {
grid-template-columns: 1fr;
}
.xx-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
@@ -236,6 +557,15 @@
grid-template-columns: 1fr;
}
.xx-task-item {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.xx-task-time {
text-align: left;
}
.xx-chart-bars {
height: 120px;
}
+24 -12
View File
@@ -69,6 +69,22 @@ const VOICE_GENDER_ICON: Record<string, string> = {
neutral: "✨",
};
/* 线 Mock
* TODO: 后端暂无时间线场景数据 API使
* timeline/scene API
*/
interface TimelineScene {
scene: string;
time: string;
duration: number;
}
const MOCK_TIMELINE: TimelineScene[] = [
{ scene: "主讲口播 · 开场钩子", time: "0-8s", duration: 8 },
{ scene: "产品特写 · B-roll", time: "8-20s", duration: 12 },
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
];
/* ── 步骤定义 ── */
const STEPS = [
{ key: 1, label: "选择模板" },
@@ -1742,19 +1758,15 @@ const GeneratePage: React.FC = () => {
<div className="xx-preview-title"></div>
{/* 时间线列表 */}
{generated ? (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
<div className="xx-preview-timeline">
{MOCK_TIMELINE.map((item, idx) => (
<div key={idx} className="xx-timeline-item">
<div className="num">{idx + 1}</div>
<span className="scene-name">{item.scene}</span>
<span>{item.time}</span>
</div>
</div>
) : (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
</div>
</div>
)}
))}
</div>
{/* 生成操作按钮 */}
<div className="xx-generate-actions">
+48
View File
@@ -612,6 +612,54 @@
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
============================================================ */
+118 -27
View File
@@ -36,24 +36,40 @@ type TitleType = "hot" | "normal" | "creative";
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel";
type Frequency = "all" | "high" | "medium" | "low";
interface CategoryItem {
id: string;
name: string;
count: number;
}
interface TitleData {
id: string;
content: string;
type: TitleType;
industry: Industry;
category: string;
usageCount: number;
isFavorited: boolean;
createdAt: string;
}
/* ============================================================
* Mock
* ============================================================ */
const MOCK_CATEGORIES: CategoryItem[] = [
{ id: "cat-all", name: "全部标题", count: 15 },
{ id: "cat-1", name: "美食探店", count: 4 },
{ id: "cat-2", name: "科技数码", count: 3 },
{ id: "cat-3", name: "生活日常", count: 4 },
{ id: "cat-4", name: "美妆穿搭", count: 2 },
{ id: "cat-5", name: "教育学习", count: 2 },
];
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
@@ -234,8 +250,9 @@ const TitleCard: React.FC<{
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all");
/* 分类数据 */
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
@@ -248,23 +265,6 @@ const TitleLibrary: React.FC = () => {
[apiTitles],
);
/* 从真实标题数据动态派生分类(无需后端分类 API) */
const categories = useMemo(() => {
const cats = new Map<string, number>();
apiTitles.forEach((t) => {
const cat = t.category || "未分类";
cats.set(cat, (cats.get(cat) || 0) + 1);
});
return [
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
];
}, [apiTitles]);
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
@@ -301,6 +301,10 @@ const TitleLibrary: React.FC = () => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState("");
/* 新建分类 */
const [createCatModalOpen, setCreateCatModalOpen] = useState(false);
const [newCatName, setNewCatName] = useState("");
/* 新建标题 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false);
const [newTitleContent, setNewTitleContent] = useState("");
@@ -318,11 +322,19 @@ const TitleLibrary: React.FC = () => {
const filteredTitles = useMemo(() => {
let list = titles;
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
/* 按分类过滤("全部标题" 不过滤) */
if (activeCatId !== "cat-all") {
const catName = activeCategory?.name || "";
if (catName) {
list = list.filter((t) => t.category === catName);
const catToIndustry: Record<string, Industry> = {
: "food",
: "tech",
: "general",
穿: "beauty",
: "education",
};
const mappedIndustry = catToIndustry[catName];
if (mappedIndustry) {
list = list.filter((t) => t.industry === mappedIndustry);
}
}
@@ -416,6 +428,32 @@ const TitleLibrary: React.FC = () => {
[deleteMutation],
);
/* 新建分类 */
const handleCreateCategory = () => {
if (!newCatName.trim()) {
message.warning("请输入分类名称");
return;
}
const cat: CategoryItem = {
id: `cat-${Date.now()}`,
name: newCatName.trim(),
count: 0,
};
setCategories((prev) => [...prev, cat]);
setActiveCatId(cat.id);
setCreateCatModalOpen(false);
setNewCatName("");
message.success(`分类 "${cat.name}" 创建成功`);
};
/* 删除分类 */
const handleDeleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
if (activeCatId === id) {
setActiveCatId("cat-all");
}
message.success("分类已删除");
};
/* 新建标题 */
const handleCreateTitle = () => {
@@ -503,12 +541,38 @@ const TitleLibrary: React.FC = () => {
</h4>
<span>{cat.count} </span>
</div>
{cat.id !== "cat-all" && (
<Popconfirm
title={`确定删除分类 "${cat.name}"`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteCategory(cat.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-title-category-delete"
onClick={(e) => e.stopPropagation()}
title="删除分类"
>
<DeleteOutlined />
</button>
</Popconfirm>
)}
</div>
</div>
))}
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
{/* 新建分类 */}
<div
className="xx-title-category-add"
onClick={() => setCreateCatModalOpen(true)}
>
<PlusOutlined />
</div>
</div>
{/* ─── 右侧:内容区 ─── */}
@@ -614,7 +678,34 @@ const TitleLibrary: React.FC = () => {
</div>
</div>
{/* ─── 新建分类弹窗 ─── */}
<AntModal
title="新建分类"
open={createCatModalOpen}
onCancel={() => setCreateCatModalOpen(false)}
onOk={handleCreateCategory}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div style={{ padding: "8px 0" }}>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="请输入分类名称"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
maxLength={30}
/>
</div>
</AntModal>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
@@ -18,7 +18,7 @@ import {
CloseCircleOutlined,
} from "@ant-design/icons";
import PageHead from "@/components/layout/PageHead";
import CloneModal from "@/components/voice/CloneModal";
import CloneVoiceModal from "@/components/modals/CloneVoiceModal";
import {
getVoiceClones,
deleteVoiceClone,
@@ -356,7 +356,7 @@ const VoiceClone: React.FC = () => {
)}
{/* 克隆音色弹窗 */}
<CloneModal
<CloneVoiceModal
open={cloneModalOpen}
onClose={() => setCloneModalOpen(false)}
onSuccess={() => {
+7
View File
@@ -170,6 +170,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
},
{
path: "accounts",
lazy: () =>
+2 -12
View File
@@ -1,17 +1,7 @@
"""
视频处理模块
轻量工具ffmpeg_utils / oss_helpers / dedup_helpers顶层直接导出
无额外依赖渲染相关组件UnifiedRenderService / RenderAdapter /
VideoProcessor 按需从子模块导入避免 __init__ 阶段引入
packages / DB 等重依赖
"""
# 共享工具模块(零外部依赖,供 editing_modes / generation / edit_plan_generation 等复用)
from . import dedup_helpers, ffmpeg_utils, oss_helpers
from .processor import VideoProcessor, VideoResult
__all__ = [
"ffmpeg_utils",
"oss_helpers",
"dedup_helpers",
]
__all__ = ["VideoProcessor", "VideoResult"]
+6 -7
View File
@@ -1,8 +1,10 @@
"""Video deduplication module - compute fingerprints and detect duplicates."""
import hashlib
import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Optional
@@ -91,16 +93,12 @@ class VideoFingerprint:
resolution: tuple[int, int]
def to_dict(self) -> dict:
# 注意:color_histograms 里的值可能是 np.float32(来自 cv2.normalize),
# 直接存进 dict 后 SQLAlchemy JSON 序列化会报 "float32 is not JSON serializable"。
# 这里统一转成 Python 原生 float。
native_histograms = [[float(v) for v in hist] for hist in self.color_histograms]
return {
"md5": self.md5,
"keyframe_phashes": self.keyframe_phashes,
"color_histograms": native_histograms,
"duration": float(self.duration),
"resolution": [int(self.resolution[0]), int(self.resolution[1])],
"color_histograms": self.color_histograms,
"duration": self.duration,
"resolution": list(self.resolution),
}
@@ -325,6 +323,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
raise ValueError(f"Generated video {generated_video_id} not found")
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_key = video.file_url.split("/")[-1]
storage_service.download_file(
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
)
@@ -1,126 +0,0 @@
"""查重辅助函数 — 从 generation.py 提取的 GeneratedVideo 记录 + 查重逻辑.
render_edit_plan generate_video 共同复用
创建 GeneratedVideo 记录后计算指纹并执行项目级 + 批次内查重
"""
from __future__ import annotations
import logging
from uuid import uuid4
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def create_video_record_and_dedup(
*,
generation_task_id: str,
project_id: str,
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
mode: str,
session: Session,
width: int = 1280,
height: int = 720,
fps: float = 25.0,
) -> int:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
Args:
generation_task_id: 生成任务 ID
project_id: 项目 ID
batch_id: 批次 ID可为空字符串
file_url: 视频文件 URL
file_size: 文件大小字节
duration: 视频时长
video_path: 视频本地路径用于计算指纹
mode: 剪辑模式名称
session: 数据库会话
width: 视频宽度
height: 视频高度
fps: 视频帧率
Returns:
创建的视频记录数量1 表示成功0 表示失败
"""
from video_processing.dedup import VideoDeduplicator
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
try:
video_id = uuid4().hex
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id=generation_task_id,
name=f"generated-{generation_task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
width=width,
height=height,
fps=fps,
status="completed",
generation_params={"mode": mode},
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning("Fingerprint computation failed for %s: %s", video_id, fp_err)
session.commit()
return 1
generated_video.video_fingerprint = fingerprint.to_dict()
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (b) 批次内查重(仅当有 batch_id 时)
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
logger.info(
"Duplicate detected: %s -> %s (reason=%s, similarity=%.3f)",
video_id,
duplicate_result["duplicate_of"],
duplicate_result["reason"],
duplicate_result["similarity"],
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
video_repo.update(generated_video)
session.commit()
logger.info(
"GeneratedVideo record created: %s (task=%s, dup=%s)",
video_id,
generation_task_id,
generated_video.is_duplicate,
)
return 1
except Exception as e:
logger.error(
"Failed to create video record / dedup for task %s: %s",
generation_task_id,
e,
)
session.rollback()
return 0
@@ -0,0 +1,700 @@
"""
视频剪辑模式处理器
支持四种剪辑模式一镜到底画中画口播口播+画中画
"""
import logging
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from enum import Enum
class StrEnum(str, Enum):
pass
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# 从 domain 层导入 EditingMode,避免重复定义
from packages.domain.editing_mode import EditingMode
class PIPPosition(StrEnum):
"""画中画位置枚举"""
TOP_LEFT = "top_left"
TOP_RIGHT = "top_right"
BOTTOM_LEFT = "bottom_left"
BOTTOM_RIGHT = "bottom_right"
@dataclass
class EditingModeConfig:
"""剪辑模式配置"""
mode: EditingMode
output_width: int = 1280
output_height: int = 720
output_fps: int = 25
pip_position: PIPPosition = PIPPosition.TOP_RIGHT
pip_scale: float = 0.25 # 画中画占主画面的比例
transition_duration: float = 0.5 # 转场时长(秒)
output_codec: str = "libx264"
output_preset: str = "medium"
output_crf: int = 23
class EditingModeProcessor:
"""剪辑模式处理器"""
def __init__(self, config: EditingModeConfig, work_dir: Optional[str] = None):
"""
初始化剪辑模式处理器
Args:
config: 剪辑模式配置
work_dir: 工作目录默认使用系统临时目录
"""
self.config = config
self.work_dir = work_dir or tempfile.gettempdir()
self._ffmpeg_bin = "ffmpeg"
self._ffprobe_bin = "ffprobe"
def process(
self,
video_paths: list[str],
audio_path: Optional[str] = None,
output_path: Optional[str] = None,
) -> str:
"""
根据模式处理视频返回输出文件路径
Args:
video_paths: 视频素材路径列表
audio_path: 音频路径用于口播模式
output_path: 输出文件路径默认自动生成
Returns:
输出文件路径
"""
if not video_paths:
raise ValueError("video_paths cannot be empty")
self._validate_inputs(video_paths, audio_path)
if output_path is None:
output_path = self._generate_output_path()
logger.info(f"Processing videos with mode: {self.config.mode}, count: {len(video_paths)}")
try:
if self.config.mode == EditingMode.ONE_TAKE:
return self._one_take(video_paths, output_path)
elif self.config.mode == EditingMode.PIP:
return self._pip(video_paths, output_path)
elif self.config.mode == EditingMode.VOICE_OVER:
return self._voice_over(video_paths, audio_path, output_path)
elif self.config.mode == EditingMode.VOICE_PIP:
return self._voice_pip(video_paths, audio_path, output_path)
else:
raise ValueError(f"Unsupported editing mode: {self.config.mode}")
except Exception as e:
logger.error(f"Error processing videos: {e}")
raise
def _validate_inputs(self, video_paths: list[str], audio_path: Optional[str]) -> None:
"""验证输入文件"""
for path in video_paths:
if not os.path.exists(path):
raise FileNotFoundError(f"Video file not found: {path}")
if not os.path.getsize(path) > 0:
raise ValueError(f"Video file is empty: {path}")
if audio_path and not os.path.exists(audio_path):
raise FileNotFoundError(f"Audio file not found: {audio_path}")
def _generate_output_path(self) -> str:
"""生成输出文件路径"""
os.makedirs(self.work_dir, exist_ok=True)
return os.path.join(self.work_dir, f"output_{self.config.mode}_{os.getpid()}.mp4")
def _run_ffmpeg(self, command: list[str], capture_output: bool = True) -> tuple:
"""执行 FFmpeg 命令"""
logger.debug(f"Running FFmpeg: {' '.join(command)}")
try:
result = subprocess.run(
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=capture_output,
)
return result.stdout or "", result.stderr or ""
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode() if e.stderr else str(e)
logger.error(f"FFmpeg error: {stderr}")
raise RuntimeError(f"FFmpeg execution failed: {stderr}") from e
def _get_video_info(self, video_path: str) -> dict:
"""获取视频信息"""
try:
result = subprocess.run(
[
self._ffprobe_bin,
"-v",
"error",
"-show_entries",
"stream=width,height,r_frame_rate,duration,codec_name",
"-show_entries",
"format=duration,size",
"-of",
"json",
video_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
import json
data = json.loads(result.stdout)
streams = data.get("streams", [{}])
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
fmt = data.get("format", {})
fps_str = video_stream.get("r_frame_rate", "25/1")
fps_parts = fps_str.split("/")
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0])
return {
"width": int(video_stream.get("width", 0)),
"height": int(video_stream.get("height", 0)),
"fps": fps,
"duration": float(fmt.get("duration", 0)),
"codec": video_stream.get("codec_name", "unknown"),
"size": int(fmt.get("size", 0)),
}
except Exception as e:
logger.warning(f"Failed to get video info for {video_path}: {e}")
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
def _get_pip_position_offset(
self, main_width: int, main_height: int, pip_width: int, pip_height: int
) -> tuple[int, int]:
"""获取画中画位置偏移量"""
margin = 10
position_offsets = {
PIPPosition.TOP_LEFT: (margin, margin),
PIPPosition.TOP_RIGHT: (main_width - pip_width - margin, margin),
PIPPosition.BOTTOM_LEFT: (margin, main_height - pip_height - margin),
PIPPosition.BOTTOM_RIGHT: (main_width - pip_width - margin, main_height - pip_height - margin),
}
return position_offsets.get(self.config.pip_position, position_offsets[PIPPosition.TOP_RIGHT])
def _normalize_video(self, input_path: str, output_path: str) -> dict:
"""标准化视频格式:先统一帧率,再缩放/填充"""
command = [
self._ffmpeg_bin,
"-y",
"-i",
input_path,
"-r",
str(self.config.output_fps), # 先统一帧率
"-vf",
f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
"-r",
str(self.config.output_fps),
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-an",
output_path,
]
self._run_ffmpeg(command)
return self._get_video_info(output_path)
def _one_take(self, video_paths: list[str], output_path: str) -> str:
"""一镜到底模式:顺序拼接视频,添加淡入淡出转场"""
if len(video_paths) == 1:
return self._normalize_video(video_paths[0], output_path)
normalized_paths = []
for i, path in enumerate(video_paths):
normalized = os.path.join(self.work_dir, f"normalized_{i}_{os.getpid()}.mp4")
self._normalize_video(path, normalized)
normalized_paths.append(normalized)
durations = [self._get_video_info(p)["duration"] for p in normalized_paths]
if len(normalized_paths) <= 5:
output_path = self._one_take_with_xfade(normalized_paths, durations, output_path)
else:
output_path = self._one_take_simple_concat(normalized_paths, output_path)
for p in normalized_paths:
try:
if p != output_path:
os.remove(p)
except Exception as e:
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
return output_path
def _one_take_with_xfade(self, normalized_paths: list[str], durations: list[float], output_path: str) -> str:
"""使用 xfade 滤镜实现转场"""
if len(normalized_paths) == 2:
transition = self.config.transition_duration
offset1 = durations[0] - transition / 2
command = [
self._ffmpeg_bin,
"-y",
"-i",
normalized_paths[0],
"-i",
normalized_paths[1],
"-filter_complex",
f"[0:v][1:v]xfade=transition=fade:duration={transition}:offset={offset1}[v]",
"-map",
"[v]",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
self._run_ffmpeg(command)
return output_path
else:
return self._one_take_simple_concat(normalized_paths, output_path)
def _one_take_simple_concat(self, normalized_paths: list[str], output_path: str) -> str:
"""使用 concat demuxer 简单拼接"""
concat_file = os.path.join(self.work_dir, f"concat_list_{os.getpid()}.txt")
with open(concat_file, "w") as f:
for path in normalized_paths:
f.write(f"file '{os.path.abspath(path)}'\n")
command = [
self._ffmpeg_bin,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
"-c",
"copy",
output_path,
]
self._run_ffmpeg(command)
try:
os.remove(concat_file)
except Exception as e:
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
return output_path
def _pip(self, video_paths: list[str], output_path: str) -> str:
"""画中画模式:主视频全屏,后续视频叠加在角落"""
if not video_paths:
raise ValueError("No video paths provided")
main_video = video_paths[0]
main_normalized = os.path.join(self.work_dir, f"main_{os.getpid()}.mp4")
main_info = self._normalize_video(main_video, main_normalized)
if len(video_paths) == 1:
os.rename(main_normalized, output_path)
return output_path
pip_width = int(self.config.output_width * self.config.pip_scale)
pip_height = int(self.config.output_height * self.config.pip_scale)
x_offset, y_offset = self._get_pip_position_offset(
self.config.output_width, self.config.output_height, pip_width, pip_height
)
pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4")
pip_info = self._get_video_info(video_paths[1])
if pip_info["duration"] > main_info["duration"]:
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
video_paths[1],
"-t",
str(main_info["duration"]),
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
temp_pip,
]
self._run_ffmpeg(command)
pip_normalized_input = temp_pip
else:
command = [
self._ffmpeg_bin,
"-y",
"-i",
video_paths[1],
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
pip_normalized,
]
self._run_ffmpeg(command)
pip_normalized_input = pip_normalized
if main_info["duration"] > pip_info["duration"]:
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-stream_loop",
"-1",
"-i",
pip_normalized_input,
"-t",
str(main_info["duration"]),
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
looped_pip,
]
self._run_ffmpeg(command)
pip_normalized_input = looped_pip
command = [
self._ffmpeg_bin,
"-y",
"-i",
main_normalized,
"-i",
pip_normalized_input,
"-filter_complex",
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
"-map",
"[v]",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
self._run_ffmpeg(command)
for temp_file in [main_normalized, pip_normalized]:
if temp_file and temp_file != output_path:
try:
os.remove(temp_file)
except Exception as e:
logger.warning(
f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True
)
return output_path
def _voice_over(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str:
"""口播模式:背景画面 + 配音"""
if not audio_path:
raise ValueError("audio_path is required for VOICE_OVER mode")
if not video_paths:
raise ValueError("No background video provided")
audio_info = self._get_video_info(audio_path)
audio_duration = audio_info["duration"]
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
bg_info = self._normalize_video(video_paths[0], bg_normalized)
if bg_info["duration"] < audio_duration:
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-stream_loop",
"-1",
"-i",
bg_normalized,
"-t",
str(audio_duration),
"-vf",
f"scale={self.config.output_width}:{self.config.output_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
looped_bg,
]
self._run_ffmpeg(command)
bg_normalized = looped_bg
elif bg_info["duration"] > audio_duration:
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
"-t",
str(audio_duration),
"-c:v",
"copy",
temp_bg,
]
self._run_ffmpeg(command)
bg_normalized = temp_bg
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
"-vf",
f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
blurred_bg,
]
self._run_ffmpeg(command)
command = [
self._ffmpeg_bin,
"-y",
"-i",
blurred_bg,
"-i",
audio_path,
"-filter_complex",
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
"-map",
"[v]",
"-map",
"1:a",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
"-shortest",
output_path,
]
self._run_ffmpeg(command)
for temp_file in [bg_normalized, blurred_bg]:
try:
if temp_file != output_path:
os.remove(temp_file)
except Exception as e:
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
return output_path
def _voice_pip(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str:
"""口播+画中画模式:口播视频在角落,其他视频作为背景"""
if not video_paths:
raise ValueError("No video paths provided")
if len(video_paths) == 1:
return self._normalize_video(video_paths[0], output_path)
voice_video = video_paths[0]
bg_video = video_paths[1] if len(video_paths) > 1 else video_paths[0]
voice_normalized = os.path.join(self.work_dir, f"voice_{os.getpid()}.mp4")
voice_info = self._normalize_video(voice_video, voice_normalized)
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
bg_info = self._normalize_video(bg_video, bg_normalized)
final_duration = min(voice_info["duration"], bg_info["duration"])
pip_width = int(self.config.output_width * self.config.pip_scale)
pip_height = int(self.config.output_height * self.config.pip_scale)
x_offset, y_offset = self._get_pip_position_offset(
self.config.output_width, self.config.output_height, pip_width, pip_height
)
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
voice_normalized,
"-t",
str(final_duration),
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
voice_adjusted,
]
self._run_ffmpeg(command)
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
"-t",
str(final_duration),
"-c:v",
"copy",
bg_adjusted,
]
self._run_ffmpeg(command)
if audio_path:
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_adjusted,
"-i",
voice_adjusted,
"-i",
audio_path,
"-filter_complex",
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
"-map",
"[v]",
"-map",
"2:a",
"-shortest",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
else:
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_adjusted,
"-i",
voice_adjusted,
"-filter_complex",
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
"-map",
"[v]",
"-map",
"1:a",
"-shortest",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
self._run_ffmpeg(command)
for temp_file in [voice_normalized, voice_adjusted, bg_normalized, bg_adjusted]:
try:
if temp_file != output_path:
os.remove(temp_file)
except Exception as e:
logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True)
return output_path
def create_processor(mode: str, work_dir: Optional[str] = None, **kwargs) -> EditingModeProcessor:
"""便捷工厂函数:创建剪辑模式处理器"""
try:
editing_mode = EditingMode(mode)
except ValueError:
raise ValueError(f"Invalid editing mode: {mode}. Valid modes: {[m.value for m in EditingMode]}")
config = EditingModeConfig(
mode=editing_mode,
output_width=kwargs.get("output_width", 1280),
output_height=kwargs.get("output_height", 720),
output_fps=kwargs.get("output_fps", 25),
pip_position=PIPPosition(kwargs.get("pip_position", "top_right")),
pip_scale=kwargs.get("pip_scale", 0.25),
transition_duration=kwargs.get("transition_duration", 0.5),
)
return EditingModeProcessor(config=config, work_dir=work_dir)
@@ -1,396 +0,0 @@
"""FFmpeg 工具函数 — 共享原语.
提供 FFmpeg / FFprobe 调用视频信息探测视频标准化xfade 转场滤镜构建
等底层能力 UnifiedRenderServiceVideoComposeService 等复用
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
XFADE_TRANSITION_MAP: dict[str, str] = {
"fade": "fade",
"slideleft": "slideleft",
"slide_left": "slideleft",
"slideright": "slideright",
"slide_right": "slideright",
"slideup": "slideup",
"slide_up": "slideup",
"slidedown": "slidedown",
"slide_down": "slidedown",
"dissolve": "dissolve",
"wipe": "wipeleft",
"wipeleft": "wipeleft",
}
DEFAULT_TRANSITION_DURATION = 0.5
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
DEFAULT_FFMPEG_TIMEOUT = 1800
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
command: list[str],
*,
capture_output: bool = True,
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
) -> tuple[str, str]:
"""执行 FFmpeg 命令。
Args:
command: 完整的 ffmpeg 命令列表 "ffmpeg" 本身
capture_output: 是否捕获 stdout/stderr
timeout: 超时时间默认 1800s30分钟None 表示不设超时不推荐
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出
异常信息包含完整 stderr 以便排查
subprocess.TimeoutExpired: 超时未完成时抛出FFmpeg 进程会被 kill
"""
try:
result = subprocess.run( # nosec B603
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=True,
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
" ".join(str(c) for c in command[:20]),
)
raise
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
)
raise
def probe_has_audio(local_path: str | Path) -> bool:
"""探测文件是否包含音频流。
Args:
local_path: 本地文件路径
Returns:
True 表示有音频流或探测失败保守返回False 表示确认无音频流
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=codec_type",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_path),
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
return result.stdout.strip() == "audio"
except Exception:
# 探测失败保守返回 True,让 FFmpeg 自己处理(避免误删音频)
return True
def probe_duration(local_path: str | Path) -> float:
"""用 ffprobe 获取视频时长(秒)。
失败时返回默认值 5.0
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_path),
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return round(float(result.stdout.strip()), 3)
except Exception:
return 5.0
def probe_video_info(video_path: str) -> dict[str, Any]:
"""获取视频信息(宽、高、时长、fps、编码、像素格式)。
Returns:
{
"width": int, "height": int, "duration": float, "fps": float,
"video_codec": str, "audio_codec": str, "pix_fmt": str,
"has_audio": bool,
}
失败时返回默认值
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"stream=width,height,r_frame_rate,duration,codec_name,codec_type,pix_fmt",
"-show_entries",
"format=duration",
"-of",
"json",
video_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=15,
)
import json
info = json.loads(result.stdout)
streams = info.get("streams", [])
fmt = info.get("format", {})
video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {})
width = int(video_stream.get("width", DEFAULT_OUTPUT_WIDTH))
height = int(video_stream.get("height", DEFAULT_OUTPUT_HEIGHT))
video_codec = video_stream.get("codec_name", "") or ""
pix_fmt = video_stream.get("pix_fmt", "") or ""
# 解析帧率
fps_str = video_stream.get("r_frame_rate", "25/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps = float(num) / float(den) if float(den) > 0 else DEFAULT_FPS
else:
fps = float(fps_str) if fps_str else DEFAULT_FPS
# 时长
duration = float(fmt.get("duration", 0)) or float(video_stream.get("duration", 0))
has_audio = bool(audio_stream)
audio_codec = audio_stream.get("codec_name", "") or ""
return {
"width": width,
"height": height,
"duration": duration,
"fps": round(fps, 2),
"video_codec": video_codec,
"audio_codec": audio_codec,
"pix_fmt": pix_fmt,
"has_audio": has_audio,
}
except Exception as e:
logger.warning("获取视频信息失败: %s, error: %s", video_path, e)
return {
"width": DEFAULT_OUTPUT_WIDTH,
"height": DEFAULT_OUTPUT_HEIGHT,
"duration": 0.0,
"fps": DEFAULT_FPS,
"video_codec": "",
"audio_codec": "",
"pix_fmt": "",
"has_audio": True,
}
def normalize_video(
input_path: str,
output_path: str,
*,
width: int = DEFAULT_OUTPUT_WIDTH,
height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
) -> dict[str, Any]:
"""标准化视频(缩放 + 恒定帧率)。
使用 scale + pad 保持宽高比黑边填充到目标分辨率
Returns:
{"width": int, "height": int, "path": str}
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
input_path,
"-vf",
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
f"fps={fps}",
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
output_path,
]
run_ffmpeg(command)
return {"width": width, "height": height, "path": output_path}
# ── xfade / concat 滤镜构建 ──────────────────────────────────────────────────
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
chain_filters(["scale=1280:720", "fps=25"], "v0")
"[0:v]scale=1280:720,fps=25[v0]"
"""
filter_body = ",".join(filters)
return f"[{input_label}]{filter_body}[{output_label}]"
def resolve_xfade_transition(transition_name: str) -> str:
"""将转场效果名称映射为 FFmpeg xfade transition 名称。
支持 TransitionEffect 枚举值和字符串名称未知值回退到 "fade"
"""
# 兼容 TransitionEffect 枚举(有 .value 属性)
if hasattr(transition_name, "value"):
transition_name = transition_name.value
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
def build_xfade_filter_chain(
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
对每步 xfade 自动钳制 transition duration确保
``offset + td first_input_duration``避免 FFmpeg exit 234
Args:
clip_durations: 每个片段的时长必须与 trim 后的实际时长一致
clip_video_labels: 每个片段的视频流标签 "v0", "v1"
transitions: 每个片段对应的转场效果第一个片段的转场被忽略
transition_duration: 转场时长
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
n = len(clip_durations)
parts: list[str] = []
if n == 0:
return "", 0.0
if n == 1:
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
return ";".join(parts), clip_durations[0]
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
cumulative = 0.0
prev_label = clip_video_labels[0]
total_transition = 0.0 # 累计已使用的转场时长
for i in range(1, n):
cumulative += clip_durations[i - 1]
# 当前 xfade 的第一个输入时长
if i == 1:
first_input_dur = clip_durations[0]
else:
first_input_dur = cumulative - total_transition
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
safe_td = min(transition_duration, available)
# 同时不能超过剩余总时长
remaining = max(0.0, sum(clip_durations) - cumulative)
safe_td = min(safe_td, remaining)
# 同时不能超过当前第二个输入(单个片段)的时长
safe_td = min(safe_td, clip_durations[i])
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = resolve_xfade_transition(transition)
if i == n - 1:
out_label = output_label
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_video_labels[i]}]"
f"xfade=transition={xfade_transition}"
f":duration={safe_td:.3f}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
total_transition += safe_td
# 总时长减去转场重叠部分
total_duration = sum(clip_durations) - total_transition
return ";".join(parts), max(0.0, total_duration)
-265
View File
@@ -1,265 +0,0 @@
"""OSS 工具函数 — 从 generation.py / edit_plan_generation.py 提取的共享 OSS 操作.
提供 OSS 配置读取Bucket 创建素材上传/下载asset_id 本地路径解析
等能力 render_edit_plan generate_video 共同复用
"""
from __future__ import annotations
import hashlib
import logging
import os
import threading
from pathlib import Path
from urllib.parse import urlparse
import oss2
logger = logging.getLogger(__name__)
# OSS 上传配置
OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死
OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住
OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片
OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB
OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数
# ── OSS 配置 ──────────────────────────────────────────────────────────────────
def oss_settings() -> tuple[str, str, str, str] | None:
"""获取 OSS 配置。
Returns:
(access_key_id, access_key_secret, endpoint, bucket_name) 元组
配置缺失时返回 None
"""
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
endpoint = os.getenv("OSS_ENDPOINT")
bucket_name = os.getenv("OSS_BUCKET_NAME")
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
return None
return access_key_id, access_key_secret, endpoint, bucket_name
def oss_bucket() -> oss2.Bucket | None:
"""获取 OSS Bucket 实例。
P0-2 修复endpoint 不带 scheme 时自动补 https:// 前缀
确保 sign_url 等依赖 scheme 的方法返回 HTTPS URL
P0-staging 修复增加 connect_timeout=10s防止网络抖动时
TCP 握手阶段无限挂死导致 worker 进程卡死
Returns:
oss2.Bucket 实例配置缺失时返回 None
"""
settings = oss_settings()
if settings is None:
return None
access_key_id, access_key_secret, endpoint, bucket_name = settings
# endpoint 无 scheme 时补 https://,与 API 端 storage.py 保持一致
if not endpoint.startswith(("http://", "https://")):
endpoint = f"https://{endpoint}"
return oss2.Bucket(
oss2.Auth(access_key_id, access_key_secret),
endpoint,
bucket_name,
connect_timeout=OSS_CONNECT_TIMEOUT,
)
def normalize_storage_key(storage_key_or_url: str) -> str:
"""标准化存储键 — 如果是完整 URL 则提取 path 部分。
Examples:
"https://bucket.oss-cn-hangzhou.aliyuncs.com/path/to/file.mp4"
"path/to/file.mp4"
"path/to/file.mp4" "path/to/file.mp4"
"""
if storage_key_or_url.startswith(("http://", "https://")):
return urlparse(storage_key_or_url).path.lstrip("/")
return storage_key_or_url.lstrip("/")
# ── 上传 / 下载 ───────────────────────────────────────────────────────────────
def download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""从 OSS 下载素材文件到本地路径。
Args:
asset_storage_key: 素材的存储键或完整 URL
local_path: 本地保存路径
Returns:
True 表示下载成功False 表示失败
"""
bucket = oss_bucket()
if bucket is None:
return False
try:
bucket.get_object_to_file(normalize_storage_key(asset_storage_key), str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key)
return False
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL。
大文件>100MB自动走分片上传降低内存峰值减少 OOM 风险
上传加总超时保护默认 300s防止网络异常时无限挂死
Args:
local_path: 本地文件路径
storage_key: 目标存储键
Returns:
公开访问 URL上传失败或 OSS 未配置时返回 None
"""
bucket = oss_bucket()
if bucket is None:
return None
result: dict = {"url": None, "error": None, "file_size": 0}
done = threading.Event()
def _do_upload():
try:
# 尝试获取文件大小,用于分片判断和日志;stat 失败时 fallback 走普通上传
try:
file_size = local_path.stat().st_size
result["file_size"] = file_size
use_multipart = file_size >= OSS_MULTIPART_THRESHOLD
except OSError:
use_multipart = False
file_size = 0
if use_multipart:
# 分片上传:降低内存峰值,每片 8MB,3 线程并发
logger.info(
"大文件分片上传: storage_key=%s, size=%.1fMB, part_size=%dMB, threads=%d",
storage_key[:80],
file_size / 1024 / 1024,
OSS_PART_SIZE // 1024 // 1024,
OSS_MULTIPART_NUM_THREADS,
)
oss2.resumable_upload(
bucket,
storage_key,
str(local_path),
multipart_threshold=OSS_MULTIPART_THRESHOLD,
part_size=OSS_PART_SIZE,
num_threads=OSS_MULTIPART_NUM_THREADS,
)
else:
bucket.put_object_from_file(storage_key, str(local_path))
# 构造返回 URL
settings = oss_settings()
if settings:
_, _, endpoint, bucket_name = settings
endpoint_clean = endpoint.replace("https://", "").replace("http://", "")
result["url"] = f"https://{bucket_name}.{endpoint_clean}/{storage_key}"
except Exception as e:
result["error"] = e
logger.exception("上传 OSS 失败: %s", storage_key)
finally:
done.set()
upload_thread = threading.Thread(target=_do_upload, daemon=True)
upload_thread.start()
finished = done.wait(timeout=OSS_UPLOAD_TOTAL_TIMEOUT)
if not finished:
logger.error(
"OSS 上传超时(%.0fs),强制中止: storage_key=%s, size=%.1fMB",
OSS_UPLOAD_TOTAL_TIMEOUT,
storage_key[:80],
result["file_size"] / 1024 / 1024 if result["file_size"] else 0,
)
return None
if result["error"]:
return None
return result["url"]
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | None:
"""生成预签名下载 URL(用于私有 bucket 的 URL 校验或临时下载)。
Args:
storage_key_or_url: 存储键或完整 URLURL 会自动提取 path
expires_seconds: 签名有效期
Returns:
预签名 URL失败或 OSS 未配置时返回 None
"""
bucket = oss_bucket()
if bucket is None:
return None
try:
storage_key = normalize_storage_key(storage_key_or_url)
signed = bucket.sign_url("GET", storage_key, expires_seconds)
logger.info("生成预签名URL: key=%s url_prefix=%s", storage_key[:80], signed[:60])
return signed
except Exception:
logger.exception("生成预签名URL失败: %s", storage_key_or_url[:80])
return None
# ── Asset 解析 ────────────────────────────────────────────────────────────────
def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
"""从 asset_id 解析到本地文件路径。
策略按优先级
1. 如果 asset_id 是本地绝对路径/var/storage/... 直接返回
2. 如果 work_dir 下已有缓存文件 返回缓存路径
3. OSS 下载到 work_dir/{hash}.mp4 返回下载路径
4. 下载失败 返回 None
缓存策略 asset_id SHA256 16 位为文件名避免重复下载
"""
# 1. 本地绝对路径
if asset_id.startswith("/") and os.path.exists(asset_id):
return Path(asset_id)
# 2. 缓存命中
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
cached_path = work_dir / f"{cache_hash}.mp4"
if cached_path.exists() and cached_path.stat().st_size > 0:
return cached_path
# 3. 从 OSS 下载
if download_asset(asset_id, cached_path):
return cached_path
return None
def resolve_asset_ids_to_paths(
asset_ids: list[str],
work_dir: Path,
) -> dict[str, Path]:
"""批量解析 asset_id → 本地路径。
Args:
asset_ids: 素材 ID 列表
work_dir: 工作目录
Returns:
{asset_id: local_path} 映射仅包含成功解析的条目
"""
result: dict[str, Path] = {}
for aid in asset_ids:
local_path = resolve_asset_path(aid, work_dir)
if local_path:
result[aid] = local_path
return result
@@ -5,6 +5,7 @@
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List
import ffmpeg
@@ -1,299 +0,0 @@
"""统一渲染引擎适配层 — Phase 2.
EditPlan + EditPlanClips来自 DB适配为 UnifiedRenderService 的输入格式
封装素材下载渲染执行结果上传的完整流程
职责
1. DB 读取 EditPlan + EditPlanClips
2. 下载素材到本地构建 asset_path_map
3. 调用 UnifiedRenderService 执行渲染
4. 上传渲染结果到 OSS
5. 支持进度回调对接 JobService
"""
from __future__ import annotations
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from sqlalchemy.orm import Session
from video_processing.oss_helpers import download_asset, upload_to_oss
from video_processing.unified_render_service import UnifiedRenderService
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass
class RenderAdapterResult:
"""渲染适配结果。"""
success: bool
output_url: str = ""
output_path: Path | None = None
duration: float = 0.0
file_size: int = 0
width: int = 0
height: int = 0
clip_count: int = 0
error_message: str = ""
ProgressCallback = Callable[[float, str], None]
"""进度回调:(progress_0_100, stage_description) → None"""
# ── 适配层主体 ────────────────────────────────────────────────────────────────
class RenderAdapter:
"""统一渲染引擎适配层。
桥接 EditPlan 领域模型与 UnifiedRenderService 图层模型
用法::
adapter = RenderAdapter(db)
result = adapter.render_plan(
plan_id=plan_id,
job_id=job_id,
progress_cb=lambda p, s: job_service.update_progress(job_id, p, s),
)
"""
def __init__(self, db: Session) -> None:
self._db = db
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开方法 ──────────────────────────────────────────────────────────
def render_plan(
self,
plan_id: str,
*,
job_id: str = "",
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
) -> RenderAdapterResult:
"""渲染一个 EditPlan。
完整流程
1. 加载计划与片段
2. 下载素材
3. 执行统一渲染
4. 上传结果
Args:
plan_id: EditPlan ID
job_id: 关联的 Job ID用于结果存储路径
work_dir: 工作目录不传则使用临时目录
progress_cb: 进度回调函数
Returns:
RenderAdapterResult
"""
temp_dir = None
try:
# 0. 准备工作目录
if work_dir is None:
temp_dir = tempfile.mkdtemp(prefix="render_")
work_dir = Path(temp_dir)
work_dir.mkdir(parents=True, exist_ok=True)
self._report_progress(progress_cb, 5.0, "加载剪辑计划")
# 1. 加载计划与片段
plan = self._plan_repo.get(plan_id)
if plan is None:
return RenderAdapterResult(
success=False,
error_message=f"剪辑计划不存在: {plan_id}",
)
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id]
ready_clips.sort(key=lambda c: c.order)
if not ready_clips:
return RenderAdapterResult(
success=False,
error_message="没有可渲染的就绪片段",
clip_count=0,
)
logger.info(
"开始渲染: plan_id=%s job_id=%s ready_clips=%d engine=unified",
plan_id,
job_id,
len(ready_clips),
)
self._report_progress(progress_cb, 15.0, f"下载素材({len(ready_clips)} 个)")
# 2. 下载素材
asset_path_map = self._download_assets(ready_clips, work_dir)
if not asset_path_map:
return RenderAdapterResult(
success=False,
error_message="所有素材下载失败",
clip_count=len(ready_clips),
)
self._report_progress(progress_cb, 40.0, "执行视频渲染")
# 3. 执行统一渲染
render_svc = UnifiedRenderService(
plan=plan,
clips=ready_clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
)
result = render_svc.render()
self._report_progress(progress_cb, 80.0, "上传渲染结果")
# 4. 上传结果
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
output_url = upload_to_oss(result.output_path, storage_key)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
plan_id,
job_id,
result.duration,
result.file_size,
result.width,
result.height,
len(ready_clips),
)
return RenderAdapterResult(
success=True,
output_url=output_url or "",
output_path=result.output_path,
duration=result.duration,
file_size=result.file_size,
width=result.width,
height=result.height,
clip_count=len(ready_clips),
)
except Exception as exc:
logger.exception(
"[render-adapter] render failed: plan_id=%s job_id=%s engine=unified error=%s",
plan_id,
job_id,
str(exc)[:200],
)
return RenderAdapterResult(
success=False,
error_message=str(exc)[:500],
)
finally:
# 清理临时目录
if temp_dir:
import shutil
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception:
pass
def validate_plan(self, plan_id: str) -> tuple[bool, list[str], list[str], int, int]:
"""校验计划是否可渲染(兼容 VideoComposeService.validate_compose 接口)。
Returns:
(valid, errors, warnings, ready_clip_count, total_clip_count)
"""
errors: list[str] = []
warnings: list[str] = []
plan = self._plan_repo.get(plan_id)
if plan is None:
return False, [f"剪辑计划不存在: {plan_id}"], [], 0, 0
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status}")
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
errors.append("计划没有任何片段")
return False, errors, warnings, 0, 0
clips.sort(key=lambda c: c.order)
ready_count = 0
pending_count = 0
no_asset_count = 0
for clip in clips:
if clip.status == EditPlanClipStatus.READY:
ready_count += 1
if not clip.asset_id:
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
no_asset_count += 1
elif clip.status == EditPlanClipStatus.PENDING:
pending_count += 1
elif clip.status == EditPlanClipStatus.FAILED:
warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过")
if ready_count == 0:
errors.append("没有就绪(ready)的片段可以合成")
if pending_count > 0:
warnings.append(f"{pending_count} 个片段仍处于 pending 状态")
return len(errors) == 0, errors, warnings, ready_count, len(clips)
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _report_progress(progress_cb: ProgressCallback | None, progress: float, stage: str) -> None:
"""上报进度。"""
if progress_cb is not None:
try:
progress_cb(progress, stage)
except Exception:
logger.exception("进度回调失败")
@staticmethod
def _download_assets(clips: list[EditPlanClip], work_dir: Path) -> dict[str, Path]:
"""下载片段素材到本地,返回 asset_id → local_path 映射。
只保留下载成功的素材
"""
asset_dir = work_dir / "assets"
asset_dir.mkdir(exist_ok=True)
asset_path_map: dict[str, Path] = {}
for clip in clips:
asset_id = clip.asset_id
if not asset_id:
continue
# 生成安全的本地文件名
safe_name = f"clip_{clip.order:04d}_{abs(hash(asset_id)) % 100000:05d}.mp4"
local_path = asset_dir / safe_name
if download_asset(asset_id, local_path):
asset_path_map[asset_id] = local_path
logger.debug("素材下载成功: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
else:
logger.warning("素材下载失败: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
return asset_path_map
@@ -1,204 +0,0 @@
"""渲染引擎 Feature Flag 解析器。
封装渲染引擎选择逻辑支持
- 环境变量作为默认值RENDER_ENGINE=legacy/unified
- Redis Feature Flag 运行时覆盖白名单 + 百分比 + 全局开关
- 定时刷新支持热更新不重启 worker
使用方式
resolver = RenderEngineResolver(redis_url="redis://...", default_engine="legacy")
engine = resolver.get_engine(user_id="user123")
# engine: "legacy" 或 "unified"
"""
from __future__ import annotations
import logging
import threading
from typing import Optional
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
FeatureFlagStore,
InMemoryFeatureFlagStore,
RedisFeatureFlagStore,
)
logger = logging.getLogger(__name__)
# Feature Flag 名称常量
FLAG_RENDER_ENGINE = "render_engine"
# 引擎常量
ENGINE_LEGACY = "legacy"
ENGINE_UNIFIED = "unified"
VALID_ENGINES = {ENGINE_LEGACY, ENGINE_UNIFIED}
class RenderEngineResolver:
"""渲染引擎选择器。
判定逻辑从高到低
1. Redis flag 白名单匹配 unified
2. Redis flag 百分比命中 unified
3. Redis flag 全局开启100% unified
4. 环境变量默认值 legacy / unified
Redis 不可用时自动降级到环境变量默认值不影响业务
"""
def __init__(
self,
default_engine: str = ENGINE_LEGACY,
redis_url: Optional[str] = None,
refresh_interval: float = 30.0,
store: Optional[FeatureFlagStore] = None,
) -> None:
"""
Args:
default_engine: 环境变量默认的引擎名legacy / unified
redis_url: Redis 连接 URL None 时使用内存实现测试用
refresh_interval: Redis flag 配置刷新间隔
store: 直接传入 store 实例测试用优先级高于 redis_url
"""
self._default_engine = default_engine.lower() if default_engine else ENGINE_LEGACY
if self._default_engine not in VALID_ENGINES:
logger.warning(
"Invalid default engine '%s', fallback to '%s'",
self._default_engine,
ENGINE_LEGACY,
)
self._default_engine = ENGINE_LEGACY
if store is not None:
self._store = store
elif redis_url:
self._store = RedisFeatureFlagStore(redis_url=redis_url)
else:
self._store = InMemoryFeatureFlagStore()
logger.info("No Redis configured, using in-memory feature flag store")
self._refresh_interval = refresh_interval
self._lock = threading.Lock()
self._cached_config: Optional[FeatureFlagConfig] = None
self._last_refresh: float = 0.0
def _maybe_refresh(self) -> None:
"""惰性刷新配置,超过刷新间隔时从存储重新读取。"""
import time
now = time.time()
if now - self._last_refresh < self._refresh_interval:
return
try:
config = self._store.get(FLAG_RENDER_ENGINE)
with self._lock:
self._cached_config = config
self._last_refresh = now
except Exception as exc:
logger.warning("Failed to refresh render engine flag: %s", exc)
# 刷新失败时保留旧缓存,不中断业务
if self._cached_config is None:
# 首次就读失败,设一个默认值
with self._lock:
self._cached_config = FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
self._last_refresh = now
def _get_config(self) -> FeatureFlagConfig:
"""获取当前 flag 配置(带缓存)。"""
if self._cached_config is None:
self._maybe_refresh()
else:
self._maybe_refresh()
return self._cached_config or FeatureFlagConfig(name=FLAG_RENDER_ENGINE)
def get_engine(self, user_id: Optional[str] = None) -> str:
"""获取当前应该使用的渲染引擎。
Args:
user_id: 用户ID用于白名单匹配和百分比哈希
None 时只看全局开关
Returns:
"legacy" "unified"
"""
config = self._get_config()
# 全局关闭 → 用默认值
if not config.enabled:
return self._default_engine
# 白名单匹配 / 百分比命中 → unified
if config.is_active(user_id):
return ENGINE_UNIFIED
# 未命中灰度 → 用默认值
return self._default_engine
def should_use_unified(self, user_id: Optional[str] = None) -> bool:
"""便捷方法:是否应该使用统一渲染引擎。"""
return self.get_engine(user_id) == ENGINE_UNIFIED
def force_refresh(self) -> None:
"""强制立即刷新配置(用于管理接口修改后立即生效)。"""
self._last_refresh = 0.0
if isinstance(self._store, RedisFeatureFlagStore):
self._store.invalidate_cache(FLAG_RENDER_ENGINE)
self._maybe_refresh()
def get_config_snapshot(self) -> dict:
"""获取当前配置快照(用于管理接口展示)。"""
config = self._get_config()
return {
"flag_name": FLAG_RENDER_ENGINE,
"default_engine": self._default_engine,
"enabled": config.enabled,
"percentage": config.percentage,
"whitelist": sorted(config.whitelist),
"refresh_interval": self._refresh_interval,
"last_refresh": self._last_refresh,
}
def set_flag(self, config: FeatureFlagConfig) -> None:
"""设置 flag 配置(管理接口用)。"""
config.name = FLAG_RENDER_ENGINE
self._store.set(config)
self.force_refresh()
# 全局单例
_resolver: Optional[RenderEngineResolver] = None
_resolver_lock = threading.Lock()
def get_render_engine_resolver() -> RenderEngineResolver:
"""获取全局单例(基于 worker 配置)。"""
global _resolver
if _resolver is not None:
return _resolver
with _resolver_lock:
if _resolver is not None:
return _resolver
try:
from worker_app.core.config import get_settings
settings = get_settings()
redis_url = getattr(settings, "redis_url", None) or getattr(settings, "broker_url", None)
default = getattr(settings, "render_engine", ENGINE_LEGACY)
_resolver = RenderEngineResolver(
default_engine=default,
redis_url=redis_url,
)
logger.info(
"RenderEngineResolver initialized: default=%s, redis=%s",
default,
bool(redis_url),
)
except Exception as exc:
logger.warning("Failed to init RenderEngineResolver from settings: %s", exc)
_resolver = RenderEngineResolver(default_engine=ENGINE_LEGACY)
return _resolver
File diff suppressed because it is too large Load Diff
+821
View File
@@ -0,0 +1,821 @@
"""
视频合成服务
支持多种剪辑模式和转场效果包含完整的安全校验
"""
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
from enum import Enum
try:
from enum import StrEnum
except ImportError:
class StrEnum(str, Enum): # type: ignore[no-redef]
"""Python 3.10 兼容的 StrEnum 回退实现。"""
pass
from pathlib import Path
from typing import Optional
from packages.domain.editing_mode import EditingMode
logger = logging.getLogger(__name__)
# ========== 安全常量 ==========
# 允许的输出目录白名单(使用环境变量或系统临时目录,避免硬编码 /tmp)
_VIDEO_OUTPUT_DIR = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
ALLOWED_OUTPUT_DIRS = [_VIDEO_OUTPUT_DIR, "/var/app/rendered"]
# 允许的输入路径前缀白名单
ALLOWED_INPUT_PREFIXES = ("s3://", "oss://", "local://", "/var/storage/")
# 允许的转场效果白名单
ALLOWED_TRANSITIONS = {
"fade",
"slideleft",
"slideright",
"dissolve",
"wipeleft",
"wiperight",
"cut",
"slideup",
"slidedown",
}
# 转场效果映射
_XFADE_TRANSITION_MAP = {
"fade": "fade",
"slideleft": "slideleft",
"slideright": "slideright",
"dissolve": "dissolve",
"wipeleft": "wipeleft",
"wiperight": "wiperight",
"cut": "cut",
"slideup": "slideup",
"slidedown": "slidedown",
}
class VideoComposeError(Exception):
"""视频合成服务异常"""
pass
class PIPPosition(StrEnum):
"""画中画位置枚举"""
TOP_LEFT = "top_left"
TOP_RIGHT = "top_right"
BOTTOM_LEFT = "bottom_left"
BOTTOM_RIGHT = "bottom_right"
@dataclass
class Clip:
"""视频片段"""
asset_id: str # 资源ID,对应输入路径
start_time: float = 0.0
duration: float = 0.0
transition: str = "fade" # 转场效果
@dataclass
class EditingModeConfig:
"""剪辑模式配置"""
mode: EditingMode
output_width: int = 1280
output_height: int = 720
output_fps: int = 25
pip_position: PIPPosition = PIPPosition.TOP_RIGHT
pip_scale: float = 0.25 # 画中画占主画面的比例
transition_duration: float = 0.5 # 转场时长(秒)
output_codec: str = "libx264"
output_preset: str = "medium"
output_crf: int = 23
class VideoComposeService:
"""视频合成服务"""
def __init__(self, config: EditingModeConfig, work_dir: Optional[str] = None):
"""
初始化视频合成服务
Args:
config: 剪辑模式配置
work_dir: 工作目录默认使用系统临时目录
"""
self.config = config
self.work_dir = work_dir or tempfile.gettempdir()
self._ffmpeg_bin = "ffmpeg"
self._ffprobe_bin = "ffprobe"
def _validate_output_path(self, path: str) -> str:
"""
校验输出路径是否在允许范围内 (P0 修复)
防止路径穿越攻击 /app/config/../../../etc/passwd
Args:
path: 用户提供的输出路径
Returns:
标准化后的绝对路径
Raises:
ValueError: 路径不在允许范围内
"""
abs_path = os.path.abspath(path)
for allowed_dir in ALLOWED_OUTPUT_DIRS:
allowed_abs = os.path.abspath(allowed_dir)
if abs_path.startswith(allowed_abs):
return abs_path
raise ValueError(f"输出路径不在允许范围内: {path}")
def _validate_input_path(self, path: str) -> bool:
"""
校验输入路径格式是否合法 (P1-1 修复)
Args:
path: 输入文件路径
Returns:
是否合法
"""
return any(path.startswith(prefix) for prefix in ALLOWED_INPUT_PREFIXES)
def _validate_transition(self, transition: str) -> str:
"""
校验转场效果是否在白名单内 (P1-2 修复)
Args:
transition: 转场效果名称
Returns:
安全的转场效果名称
"""
if transition not in ALLOWED_TRANSITIONS:
logger.warning(f"未知的转场效果 '{transition}',使用默认 'fade'")
return "fade"
return transition
def _get_validated_transition(self, transition: str) -> str:
"""获取白名单校验后的转场效果名称"""
return _XFADE_TRANSITION_MAP.get(self._validate_transition(transition), "fade")
def compose(self, clips: list[Clip], output_path: Optional[str] = None) -> str:
"""
合成视频
Args:
clips: 视频片段列表每个片段包含 asset_id 和转场配置
output_path: 输出文件路径
Returns:
输出文件路径
"""
if not clips:
raise ValueError("clips 不能为空")
# P1-1: 校验所有输入路径
for clip in clips:
if not self._validate_input_path(clip.asset_id):
raise ValueError(f"不合法的输入路径: {clip.asset_id}")
# 生成默认输出路径并校验
if output_path is None:
output_path = self._generate_output_path()
# P0: 校验输出路径
validated_output = self._validate_output_path(output_path)
logger.info(f"合成视频,片段数: {len(clips)}, 输出: {validated_output}")
# 获取输入路径列表
input_paths = [clip.asset_id for clip in clips]
try:
if self.config.mode == EditingMode.ONE_TAKE:
return self._one_take(input_paths, validated_output, clips)
elif self.config.mode == EditingMode.PIP:
return self._pip(input_paths, validated_output)
elif self.config.mode == EditingMode.VOICE_OVER:
return self._voice_over(input_paths, validated_output)
elif self.config.mode == EditingMode.VOICE_PIP:
return self._voice_pip(input_paths, validated_output)
else:
raise ValueError(f"不支持的剪辑模式: {self.config.mode}")
except Exception as e:
logger.error(f"视频合成失败: {e}")
raise VideoComposeError(f"视频合成失败: {e}") from e
def _generate_output_path(self) -> str:
"""生成输出文件路径"""
os.makedirs(self.work_dir, exist_ok=True)
return os.path.join(self.work_dir, f"output_{self.config.mode}_{os.getpid()}.mp4")
def _validate_inputs(self, video_paths: list[str], audio_path: Optional[str] = None) -> None:
"""验证输入文件存在"""
for path in video_paths:
if not os.path.exists(path):
raise FileNotFoundError(f"视频文件不存在: {path}")
if not os.path.getsize(path) > 0:
raise ValueError(f"视频文件为空: {path}")
if audio_path and not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
def _run_ffmpeg(self, command: list[str], capture_output: bool = True) -> tuple:
"""执行 FFmpeg 命令"""
logger.debug(f"Running FFmpeg: {' '.join(command)}")
try:
result = subprocess.run(
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=capture_output,
)
return result.stdout or "", result.stderr or ""
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode() if e.stderr else str(e)
logger.error(f"FFmpeg error: {stderr}")
raise RuntimeError(f"FFmpeg 执行失败: {stderr}") from e
def _get_video_info(self, video_path: str) -> dict:
"""获取视频信息"""
try:
result = subprocess.run(
[
self._ffprobe_bin,
"-v",
"error",
"-show_entries",
"stream=width,height,r_frame_rate,duration,codec_name",
"-show_entries",
"format=duration,size",
"-of",
"json",
video_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
import json
data = json.loads(result.stdout)
streams = data.get("streams", [{}])
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
fmt = data.get("format", {})
fps_str = video_stream.get("r_frame_rate", "25/1")
fps_parts = fps_str.split("/")
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0])
return {
"width": int(video_stream.get("width", 0)),
"height": int(video_stream.get("height", 0)),
"fps": fps,
"duration": float(fmt.get("duration", 0)),
"codec": video_stream.get("codec_name", "unknown"),
"size": int(fmt.get("size", 0)),
}
except Exception as e:
logger.warning(f"获取视频信息失败 {video_path}: {e}")
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
def _get_pip_position_offset(
self, main_width: int, main_height: int, pip_width: int, pip_height: int
) -> tuple[int, int]:
"""获取画中画位置偏移量"""
margin = 10
position_offsets = {
PIPPosition.TOP_LEFT: (margin, margin),
PIPPosition.TOP_RIGHT: (main_width - pip_width - margin, margin),
PIPPosition.BOTTOM_LEFT: (margin, main_height - pip_height - margin),
PIPPosition.BOTTOM_RIGHT: (main_width - pip_width - margin, main_height - pip_height - margin),
}
return position_offsets.get(self.config.pip_position, position_offsets[PIPPosition.TOP_RIGHT])
def _normalize_video(self, input_path: str, output_path: str) -> dict:
"""标准化视频格式"""
command = [
self._ffmpeg_bin,
"-y",
"-i",
input_path,
"-r",
str(self.config.output_fps),
"-vf",
f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
"-r",
str(self.config.output_fps),
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-an",
output_path,
]
self._run_ffmpeg(command)
return self._get_video_info(output_path)
def _one_take(self, video_paths: list[str], output_path: str, clips: list[Clip]) -> str:
"""一镜到底模式"""
if len(video_paths) == 1:
return self._normalize_video(video_paths[0], output_path)
normalized_paths = []
for i, path in enumerate(video_paths):
normalized = os.path.join(self.work_dir, f"normalized_{i}_{os.getpid()}.mp4")
self._normalize_video(path, normalized)
normalized_paths.append(normalized)
durations = [self._get_video_info(p)["duration"] for p in normalized_paths]
if len(normalized_paths) <= 5:
output_path = self._one_take_with_xfade(normalized_paths, durations, output_path, clips)
else:
output_path = self._one_take_simple_concat(normalized_paths, output_path)
for p in normalized_paths:
try:
if p != output_path:
os.remove(p)
except Exception as e:
logger.warning(
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
)
return output_path
def _one_take_with_xfade(
self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip]
) -> str:
"""使用 xfade 滤镜实现转场 (P1-2: 转场参数白名单校验)"""
if len(normalized_paths) == 2:
# 获取当前片段的转场效果并校验白名单
transition = "fade"
if len(clips) > 1:
transition = self._get_validated_transition(clips[1].transition)
trans_duration = self.config.transition_duration
offset1 = durations[0] - trans_duration / 2
command = [
self._ffmpeg_bin,
"-y",
"-i",
normalized_paths[0],
"-i",
normalized_paths[1],
"-filter_complex",
f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]",
"-map",
"[v]",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
self._run_ffmpeg(command)
return output_path
else:
return self._one_take_simple_concat(normalized_paths, output_path)
def _one_take_simple_concat(self, normalized_paths: list[str], output_path: str) -> str:
"""使用 concat demuxer 简单拼接"""
concat_file = os.path.join(self.work_dir, f"concat_list_{os.getpid()}.txt")
with open(concat_file, "w") as f:
for path in normalized_paths:
f.write(f"file '{os.path.abspath(path)}'\n")
command = [
self._ffmpeg_bin,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
"-c",
"copy",
output_path,
]
self._run_ffmpeg(command)
try:
os.remove(concat_file)
except Exception as e:
logger.warning(
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
)
return output_path
def _pip(self, video_paths: list[str], output_path: str) -> str:
"""画中画模式"""
if not video_paths:
raise ValueError("No video paths provided")
main_video = video_paths[0]
main_normalized = os.path.join(self.work_dir, f"main_{os.getpid()}.mp4")
main_info = self._normalize_video(main_video, main_normalized)
if len(video_paths) == 1:
os.rename(main_normalized, output_path)
return output_path
pip_width = int(self.config.output_width * self.config.pip_scale)
pip_height = int(self.config.output_height * self.config.pip_scale)
x_offset, y_offset = self._get_pip_position_offset(
self.config.output_width, self.config.output_height, pip_width, pip_height
)
pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4")
pip_info = self._get_video_info(video_paths[1])
if pip_info["duration"] > main_info["duration"]:
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
video_paths[1],
"-t",
str(main_info["duration"]),
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
temp_pip,
]
self._run_ffmpeg(command)
pip_normalized_input = temp_pip
else:
command = [
self._ffmpeg_bin,
"-y",
"-i",
video_paths[1],
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
pip_normalized,
]
self._run_ffmpeg(command)
pip_normalized_input = pip_normalized
if main_info["duration"] > pip_info["duration"]:
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-stream_loop",
"-1",
"-i",
pip_normalized_input,
"-t",
str(main_info["duration"]),
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
looped_pip,
]
self._run_ffmpeg(command)
pip_normalized_input = looped_pip
command = [
self._ffmpeg_bin,
"-y",
"-i",
main_normalized,
"-i",
pip_normalized_input,
"-filter_complex",
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
"-map",
"[v]",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
self._run_ffmpeg(command)
for temp_file in [main_normalized, pip_normalized]:
if temp_file and temp_file != output_path:
try:
os.remove(temp_file)
except Exception as e:
logger.warning(
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
)
return output_path
def _voice_over(self, video_paths: list[str], audio_path: str, output_path: str) -> str:
"""口播模式"""
if not audio_path:
raise ValueError("audio_path is required for VOICE_OVER mode")
if not video_paths:
raise ValueError("No background video provided")
audio_info = self._get_video_info(audio_path)
audio_duration = audio_info["duration"]
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
bg_info = self._normalize_video(video_paths[0], bg_normalized)
if bg_info["duration"] < audio_duration:
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-stream_loop",
"-1",
"-i",
bg_normalized,
"-t",
str(audio_duration),
"-vf",
f"scale={self.config.output_width}:{self.config.output_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
looped_bg,
]
self._run_ffmpeg(command)
bg_normalized = looped_bg
elif bg_info["duration"] > audio_duration:
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
"-t",
str(audio_duration),
"-c:v",
"copy",
temp_bg,
]
self._run_ffmpeg(command)
bg_normalized = temp_bg
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
"-vf",
f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
blurred_bg,
]
self._run_ffmpeg(command)
command = [
self._ffmpeg_bin,
"-y",
"-i",
blurred_bg,
"-i",
audio_path,
"-filter_complex",
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
"-map",
"[v]",
"-map",
"1:a",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
"-shortest",
output_path,
]
self._run_ffmpeg(command)
for temp_file in [bg_normalized, blurred_bg]:
try:
if temp_file != output_path:
os.remove(temp_file)
except Exception as e:
logger.warning(
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
)
return output_path
def _voice_pip(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str:
"""口播+画中画模式"""
if not video_paths:
raise ValueError("No video paths provided")
if len(video_paths) == 1:
return self._normalize_video(video_paths[0], output_path)
voice_video = video_paths[0]
bg_video = video_paths[1] if len(video_paths) > 1 else video_paths[0]
voice_normalized = os.path.join(self.work_dir, f"voice_{os.getpid()}.mp4")
voice_info = self._normalize_video(voice_video, voice_normalized)
bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4")
bg_info = self._normalize_video(bg_video, bg_normalized)
final_duration = min(voice_info["duration"], bg_info["duration"])
pip_width = int(self.config.output_width * self.config.pip_scale)
pip_height = int(self.config.output_height * self.config.pip_scale)
x_offset, y_offset = self._get_pip_position_offset(
self.config.output_width, self.config.output_height, pip_width, pip_height
)
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
voice_normalized,
"-t",
str(final_duration),
"-vf",
f"scale={pip_width}:{pip_height}",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
voice_adjusted,
]
self._run_ffmpeg(command)
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_normalized,
"-t",
str(final_duration),
"-c:v",
"copy",
bg_adjusted,
]
self._run_ffmpeg(command)
if audio_path:
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_adjusted,
"-i",
voice_adjusted,
"-i",
audio_path,
"-filter_complex",
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
"-map",
"[v]",
"-map",
"2:a",
"-shortest",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
else:
command = [
self._ffmpeg_bin,
"-y",
"-i",
bg_adjusted,
"-i",
voice_adjusted,
"-filter_complex",
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
"-map",
"[v]",
"-map",
"1:a",
"-shortest",
"-c:v",
self.config.output_codec,
"-preset",
self.config.output_preset,
"-crf",
str(self.config.output_crf),
"-pix_fmt",
"yuv420p",
output_path,
]
self._run_ffmpeg(command)
for temp_file in [voice_normalized, voice_adjusted, bg_normalized, bg_adjusted]:
try:
if temp_file != output_path:
os.remove(temp_file)
except Exception as e:
logger.warning(
f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True
)
return output_path
def create_compose_service(mode: str, work_dir: Optional[str] = None, **kwargs) -> VideoComposeService:
"""便捷工厂函数:创建视频合成服务"""
try:
editing_mode = EditingMode(mode)
except ValueError:
raise ValueError(f"无效的剪辑模式: {mode}. 有效模式: {[m.value for m in EditingMode]}")
config = EditingModeConfig(
mode=editing_mode,
output_width=kwargs.get("output_width", 1280),
output_height=kwargs.get("output_height", 720),
output_fps=kwargs.get("output_fps", 25),
pip_position=PIPPosition(kwargs.get("pip_position", "top_right")),
pip_scale=kwargs.get("pip_scale", 0.25),
transition_duration=kwargs.get("transition_duration", 0.5),
)
return VideoComposeService(config=config, work_dir=work_dir)
-4
View File
@@ -17,10 +17,6 @@ class WorkerSettings(BaseSettings):
database_pool_recycle: int = 3600
environment: str = "development"
auto_create_schema: bool = False
redis_url: str = "redis://redis:6379/0"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
render_engine: str = "legacy"
model_config = SettingsConfigDict(
env_file=".env",
@@ -10,10 +10,12 @@ from __future__ import annotations
import json
import logging
import math
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from PIL import Image
@@ -1,3 +1,4 @@
from celery import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
@@ -8,6 +9,7 @@ from packages.adapters.sqlalchemy_impl.classification_job_repository import (
SQLAlchemyClassificationJobRepository,
)
from packages.domain import (
ClassificationJob,
ClassificationJobStatus,
ClassificationStatus,
)
+68 -155
View File
@@ -5,9 +5,12 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from celery.utils.log import get_task_logger
@@ -36,10 +39,6 @@ def _get_job_service():
def compose_video(self, job_id: str, **kwargs):
"""视频合成任务。
根据 RENDER_ENGINE 配置选择渲染引擎
- legacy: VideoComposeServicefilter_complex 模式
- unified: UnifiedRenderService图层架构
Args:
job_id: JobService 中的任务 ID
**kwargs: 来自 Job.payload 的额外参数plan_id, output_path
@@ -57,18 +56,66 @@ def compose_video(self, job_id: str, **kwargs):
job_service.fail_job(job_id, "Missing plan_id in job payload")
return {"status": "error", "message": "Missing plan_id"}
# 判断使用哪个渲染引擎
# 优先级:Redis Feature Flag(白名单 > 百分比) > 环境变量默认
from video_processing.render_engine_resolver import get_render_engine_resolver
# 标记为 running
job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境")
resolver = get_render_engine_resolver()
user_id = job.created_by_user_id or None
engine = resolver.get_engine(user_id=user_id)
# 延迟导入 VideoComposeService
from apps.api.app.services.video_compose_service import VideoComposeService
if engine == "unified":
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
else:
return _compose_with_legacy_engine(self, job_service, job, plan_id, db)
compose_svc = VideoComposeService(db)
# 校验合成条件
job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件")
validation = compose_svc.validate_compose(plan_id)
if not validation.valid:
error_msg = "; ".join(validation.errors)
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 构建合成命令
job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令")
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
compose_cmd = compose_svc.build_compose_command(plan_id, output_path)
# 执行 FFmpeg
job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成")
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
try:
subprocess.run(
compose_cmd.command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3600,
)
except subprocess.CalledProcessError as e:
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
raise
# 上传结果
job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果")
storage_key = f"rendered/{plan_id}/{job_id}.mp4"
from worker_app.tasks.edit_plan_generation import _upload_to_oss
output_url = _upload_to_oss(Path(output_path), storage_key)
# 更新 Job 状态为完成
result_data = {
"plan_id": plan_id,
"output_path": output_path,
"storage_key": storage_key,
"output_url": output_url or "",
"estimated_duration": compose_cmd.estimated_duration,
"clip_count": len(compose_cmd.clip_chains),
}
job_service.complete_job(job_id, result=result_data)
logger.info("视频合成完成: job_id=%s, plan_id=%s", job_id, plan_id)
return {"status": "completed", "job_id": job_id, "result": result_data}
except self.retry_exc as exc:
logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc)
@@ -82,145 +129,11 @@ def compose_video(self, job_id: str, **kwargs):
raise self.retry(exc=exc, countdown=60)
finally:
db.close()
def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dict:
"""旧引擎渲染路径(VideoComposeService)。"""
job_id = job.id
# 标记为 running
job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境")
# 延迟导入 VideoComposeService
from apps.api.app.services.video_compose_service import VideoComposeService
compose_svc = VideoComposeService(db)
# 校验合成条件
job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件")
validation = compose_svc.validate_compose(plan_id)
if not validation.valid:
error_msg = "; ".join(validation.errors)
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 构建合成命令
job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令")
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
compose_cmd = compose_svc.build_compose_command(plan_id, output_path)
# 执行 FFmpeg
job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成")
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
try:
subprocess.run(
compose_cmd.command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3600,
)
except subprocess.CalledProcessError as e:
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
raise
# 上传结果
job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果")
storage_key = f"rendered/{plan_id}/{job_id}.mp4"
from worker_app.tasks.edit_plan_generation import _upload_to_oss
output_url = _upload_to_oss(Path(output_path), storage_key)
# 更新 Job 状态为完成
result_data = {
"plan_id": plan_id,
"output_path": output_path,
"storage_key": storage_key,
"output_url": output_url or "",
"estimated_duration": compose_cmd.estimated_duration,
"clip_count": len(compose_cmd.clip_chains),
"engine": "legacy",
}
job_service.complete_job(job_id, result=result_data)
logger.info("视频合成完成(legacy): job_id=%s, plan_id=%s", job_id, plan_id)
return {"status": "completed", "job_id": job_id, "result": result_data}
def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict:
"""新引擎渲染路径(UnifiedRenderService + RenderAdapter)。"""
job_id = job.id
# 标记为 running
job_service.update_progress(job_id, progress=10.0, current_stage="初始化统一渲染引擎")
from video_processing.render_adapter import RenderAdapter
adapter = RenderAdapter(db)
# 校验合成条件
job_service.update_progress(job_id, progress=15.0, current_stage="校验合成条件")
valid, errors, warnings, ready_count, total_count = adapter.validate_plan(plan_id)
if not valid:
error_msg = "; ".join(errors)
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 进度回调
def progress_cb(progress: float, stage: str) -> None:
# 清理临时文件
try:
job_service.update_progress(job_id, progress=progress, current_stage=stage)
except Exception:
logger.exception("更新进度失败")
# 执行渲染
job_service.update_progress(job_id, progress=20.0, current_stage="开始渲染")
logger.info("统一渲染引擎开始: job_id=%s plan_id=%s", job_id, plan_id)
result = adapter.render_plan(
plan_id=plan_id,
job_id=job_id,
progress_cb=progress_cb,
)
if not result.success:
job_service.fail_job(job_id, f"渲染失败: {result.error_message}")
raise RuntimeError(result.error_message)
# 更新 Job 状态为完成
result_data = {
"plan_id": plan_id,
"output_path": str(result.output_path) if result.output_path else "",
"storage_key": f"rendered/{plan_id}/{job_id}.mp4",
"output_url": result.output_url,
"estimated_duration": result.duration,
"clip_count": result.clip_count,
"engine": "unified",
"width": result.width,
"height": result.height,
"file_size": result.file_size,
}
job_service.complete_job(job_id, result=result_data)
logger.info(
"视频合成完成(unified): job_id=%s plan_id=%s duration=%.2fs",
job_id,
plan_id,
result.duration,
)
return {"status": "completed", "job_id": job_id, "result": result_data}
def _cleanup_output(job_id: str) -> None:
"""清理临时输出文件。"""
try:
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
if Path(output_path).exists():
Path(output_path).unlink()
except Exception as e:
logger.warning(f"清理输出文件失败: {e}", exc_info=True)
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
if Path(output_path).exists():
Path(output_path).unlink()
except Exception as e:
logger.warning(f"Operation failed in apps/worker/worker_app/tasks/compose_video.py: {e}", exc_info=True)
+221 -347
View File
@@ -1,45 +1,179 @@
"""剪辑计划渲染任务 — 支持 Feature Flag 灰度.
"""剪辑计划渲染任务 — Phase 8 任务 2.05.
Celery 任务 worker.render_edit_plan:
1. 加载 EditPlan + EditPlanClips
2. 根据 Feature Flag 选择渲染引擎legacy / unified
3. 下载各片段素材 + 渲染
2. 下载各片段素材
3. order 顺序拼接片段
4. 上传渲染结果到 OSS
5. 创建 GeneratedVideo 记录 + 查重
6. 更新 EditPlan / EditPlanClip 状态
7. 更新 GenerationTask 进度
渲染引擎灰度
- Feature Flag (render_engine) 控制
- legacy: VideoComposeService + FFmpeg filter_complex
- unified: UnifiedRenderService 图层架构
5. 更新 EditPlan / EditPlanClip 状态
6. 更新 GenerationTask 进度
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess # nosec B404
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import oss2
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
logger = logging.getLogger(__name__)
FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN = shutil.which("ffprobe") or "ffprobe"
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
OUTPUT_FPS = 25.0
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
# ── OSS helpers ───────────────────────────────────────────────────────────────
def _oss_settings() -> tuple[str, str, str, str] | None:
"""获取 OSS 配置"""
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
endpoint = os.getenv("OSS_ENDPOINT")
bucket_name = os.getenv("OSS_BUCKET_NAME")
if not all([access_key_id, access_key_secret, endpoint, bucket_name]):
return None
return access_key_id, access_key_secret, endpoint, bucket_name
def _oss_bucket() -> oss2.Bucket | None:
"""获取 OSS Bucket"""
settings = _oss_settings()
if settings is None:
return None
access_key_id, access_key_secret, endpoint, bucket_name = settings
return oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
def _normalize_storage_key(storage_key_or_url: str) -> str:
"""标准化存储键"""
if storage_key_or_url.startswith(("http://", "https://")):
return urlparse(storage_key_or_url).path.lstrip("/")
return storage_key_or_url.lstrip("/")
def _download_asset(asset_storage_key: str, local_path: Path) -> bool:
"""下载素材文件到本地"""
bucket = _oss_bucket()
if bucket is None:
return False
try:
bucket.get_object_to_file(_normalize_storage_key(asset_storage_key), str(local_path))
return local_path.exists() and local_path.stat().st_size > 0
except Exception:
logger.exception("下载素材失败: %s", asset_storage_key)
return False
def _upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL"""
bucket = _oss_bucket()
if bucket is None:
return None
try:
bucket.put_object_from_file(storage_key, str(local_path))
settings = _oss_settings()
if settings:
_, _, endpoint, bucket_name = settings
return f"https://{bucket_name}.{endpoint.replace('https://', '').replace('http://', '')}/{storage_key}"
return None
except Exception:
logger.exception("上传 OSS 失败: %s", storage_key)
return None
# ── FFmpeg helpers ────────────────────────────────────────────────────────────
def _run_ffmpeg(command: list[str]) -> None:
"""执行 FFmpeg 命令"""
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603
def _probe_duration(local_path: Path) -> float:
"""获取视频/音频时长"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_path),
],
check=True,
capture_output=True,
text=True,
)
return float(result.stdout.strip())
except Exception:
return 0.0
def _concatenate_clips(
clip_paths: list[Path],
output_path: Path,
transition_effects: list[str] | None = None,
) -> bool:
"""将多个片段拼接为最终视频
使用 FFmpeg concat demuxer 实现
"""
if not clip_paths:
return False
if len(clip_paths) == 1:
# 单片段直接复制
try:
shutil.copy2(str(clip_paths[0]), str(output_path))
return True
except Exception:
return False
# 多片段:使用 concat demuxer
concat_file = output_path.parent / "concat_list.txt"
try:
with open(concat_file, "w") as f:
for p in clip_paths:
f.write(f"file '{p}'\n")
command = [
FFMPEG_BIN,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_file),
"-c",
"copy",
str(output_path),
]
_run_ffmpeg(command)
return output_path.exists() and output_path.stat().st_size > 0
except Exception:
logger.exception("拼接片段失败")
return False
finally:
if concat_file.exists():
concat_file.unlink()
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.oss_helpers import (
download_asset,
upload_to_oss,
)
from video_processing.unified_render_service import UnifiedRenderService
# ── Repository imports (延迟导入避免循环依赖) ─────────────────────────────────
@@ -67,277 +201,21 @@ def _get_repos():
# ── Celery Task ───────────────────────────────────────────────────────────────
def _resolve_render_engine(user_id: str) -> str:
"""根据 Feature Flag 决定使用哪个渲染引擎。
Returns:
"legacy" "unified"
"""
try:
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
return resolver.get_engine(user_id=user_id)
except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
return "legacy"
def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg: str):
"""统一的计划失败标记工具。"""
plan = plan_repo.get(plan_id)
if plan and plan.status.value == "rendering":
plan.mark_failed()
plan_repo.update(plan)
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.status.value != "failed":
gen_task.status = "failed"
gen_task.error_message = error_msg
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
def _finalize_render_success(
plan,
plan_repo,
clip_repo,
gen_task_repo,
db,
plan_id: str,
output_url: str,
storage_key: str,
duration: float,
file_size: int,
width: int,
height: int,
rendered_clip_ids: list[str],
failed_clip_ids: list[str],
generation_task_id: str,
output_path: Path,
engine: str,
) -> dict:
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
# 创建 GeneratedVideo 记录 + 查重
project_id = plan.project_id or ""
batch_id = plan.config.get("batch_id", "")
mode = plan.config.get("mode", "edit_plan")
if generation_task_id and project_id:
try:
create_video_record_and_dedup(
generation_task_id=generation_task_id,
project_id=project_id,
batch_id=batch_id,
file_url=output_url or "",
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=mode,
session=db,
width=width,
height=height,
fps=OUTPUT_FPS,
)
except Exception as dedup_err:
logger.warning("查重失败(不影响渲染结果): %s", dedup_err)
# 更新片段状态为 rendered
for clip_id in rendered_clip_ids:
clip = clip_repo.get(clip_id)
if clip and clip.status.value == "ready":
clip.mark_rendered()
clip_repo.update(clip)
# 更新 EditPlan 状态为 completed
plan.config["rendered_url"] = output_url or ""
plan.config["rendered_storage_key"] = storage_key
plan.mark_completed()
plan_repo.update(plan)
# 更新 GenerationTask 状态为 completed
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.status = "completed"
gen_task.progress = 100.0
gen_task.result_count = len(rendered_clip_ids)
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
logger.info(
"剪辑计划渲染完成: plan_id=%s engine=%s rendered=%d failed=%d duration=%.1fs",
plan_id,
engine,
len(rendered_clip_ids),
len(failed_clip_ids),
duration,
)
return {
"status": "completed",
"plan_id": plan_id,
"rendered_count": len(rendered_clip_ids),
"failed_count": len(failed_clip_ids),
"output_url": output_url,
"duration": duration,
}
def _render_with_unified(
plan,
clips,
asset_path_map: dict[str, Path],
tmpdir_path: Path,
rendered_clip_ids: list[str],
plan_id: str,
generation_task_id: str,
plan_repo,
clip_repo,
gen_task_repo,
db,
) -> dict:
"""统一渲染引擎路径(UnifiedRenderService 图层架构)。"""
render_service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=tmpdir_path,
output_width=OUTPUT_WIDTH,
output_height=OUTPUT_HEIGHT,
output_fps=int(OUTPUT_FPS),
)
try:
render_result = render_service.render()
except Exception as render_err:
logger.error("渲染失败(unified): %s%s", plan_id, render_err)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"渲染失败: {render_err}")
return {"status": "error", "message": f"渲染失败: {render_err}"}
output_path = render_result.output_path
# 上传到 OSS
storage_key = f"rendered/{plan_id}/output.mp4"
output_url = upload_to_oss(output_path, storage_key)
failed_clip_ids: list[str] = []
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
plan_id=plan_id,
output_url=output_url or "",
storage_key=storage_key,
duration=render_result.duration,
file_size=render_result.file_size,
width=render_result.width,
height=render_result.height,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
generation_task_id=generation_task_id,
output_path=output_path,
engine="unified",
)
def _render_with_legacy(
plan,
clips,
rendered_clip_ids: list[str],
failed_clip_ids: list[str],
tmpdir_path: Path,
plan_id: str,
generation_task_id: str,
plan_repo,
clip_repo,
gen_task_repo,
db,
) -> dict:
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
import os
import subprocess
from apps.api.app.services.video_compose_service import VideoComposeService
compose_svc = VideoComposeService(db)
# 校验合成条件
validation = compose_svc.validate_compose(plan_id)
if not validation.valid:
error_msg = "; ".join(validation.errors)
logger.error("合成校验失败(legacy): %s%s", plan_id, error_msg)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, f"合成校验失败: {error_msg}")
return {"status": "error", "message": error_msg}
# 构建 FFmpeg 命令
output_dir = os.environ.get("VIDEO_OUTPUT_DIR", str(tmpdir_path))
output_path = Path(output_dir) / f"{plan_id}.mp4"
compose_cmd = compose_svc.build_compose_command(plan_id, str(output_path))
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
try:
subprocess.run(
compose_cmd.command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3600,
)
except subprocess.CalledProcessError as e:
error_msg = f"FFmpeg 执行失败: {e.stderr[:500]}"
logger.error("FFmpeg 执行失败(legacy): %s%s", plan_id, error_msg)
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
return {"status": "error", "message": error_msg}
# 获取文件大小
file_size = output_path.stat().st_size if output_path.exists() else 0
duration = compose_cmd.estimated_duration or 0.0
# 上传到 OSS
storage_key = f"rendered/{plan_id}/output.mp4"
output_url = upload_to_oss(output_path, storage_key)
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
plan_id=plan_id,
output_url=output_url or "",
storage_key=storage_key,
duration=duration,
file_size=file_size,
width=OUTPUT_WIDTH,
height=OUTPUT_HEIGHT,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
generation_task_id=generation_task_id,
output_path=output_path,
engine="legacy",
)
@celery_app.task(name="worker.render_edit_plan", bind=True, max_retries=2)
def render_edit_plan(self, plan_id: str) -> dict:
"""渲染剪辑计划
流程
1. 加载 EditPlan + EditPlanClips
2. 根据 Feature Flag 选择渲染引擎legacy / unified
3. 下载素材 + 渲染
2. 下载各片段素材到临时目录
3. order 顺序拼接片段
4. 上传渲染结果到 OSS
5. 创建 GeneratedVideo 记录 + 查重
6. 更新 EditPlan completed, EditPlanClips rendered
7. 更新 GenerationTask 进度
5. 更新 EditPlan completed, EditPlanClips rendered
6. 更新 GenerationTask 进度
"""
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
generation_task_id = ""
engine = "legacy"
for repos in _get_repos():
plan_repo, clip_repo, gen_task_repo, db = repos
@@ -352,12 +230,7 @@ def render_edit_plan(self, plan_id: str) -> dict:
# 获取 generation_task_id(提前读取,确保 except 块可用)
generation_task_id = plan.config.get("generation_task_id", "")
# 2. 选择渲染引擎(Feature Flag 灰度控制
user_id = plan.created_by_user_id or ""
engine = _resolve_render_engine(user_id)
logger.info("剪辑计划渲染引擎: plan_id=%s engine=%s user_id=%s", plan_id, engine, user_id)
# 3. 加载片段列表(按 order 排序)
# 2. 加载片段列表(按 order 排序
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
logger.warning("剪辑计划没有片段: %s", plan_id)
@@ -373,22 +246,13 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.started_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
# 3. 下载素材并构建 asset_path_map
# 3. 下载素材并拼接
with tempfile.TemporaryDirectory(prefix="edit_plan_") as tmpdir:
tmpdir_path = Path(tmpdir)
asset_path_map: dict[str, Path] = {}
clip_paths: list[Path] = []
rendered_clip_ids: list[str] = []
failed_clip_ids: list[str] = []
# 预先批量查询所有素材的 storage_keyfile_url
from packages.adapters.sqlalchemy_impl.models import AssetModel
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
asset_storage_map: dict[str, str] = {}
if clip_asset_ids:
assets = db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
asset_storage_map = {a.id: a.file_url for a in assets if a.file_url}
for clip in clips:
if not clip.asset_id:
# 没有素材的片段跳过,标记为失败
@@ -397,35 +261,18 @@ def render_edit_plan(self, plan_id: str) -> dict:
failed_clip_ids.append(clip.id)
continue
if clip.asset_id in asset_path_map:
# 同一素材已下载(多个 clip 共享同一素材)
rendered_clip_ids.append(clip.id)
continue
storage_key = asset_storage_map.get(clip.asset_id)
if not storage_key:
logger.warning(
"片段素材无 storage_key,跳过: clip_id=%s asset_id=%s",
clip.id,
clip.asset_id,
)
clip.mark_failed()
clip_repo.update(clip)
failed_clip_ids.append(clip.id)
continue
# 下载素材
ext = Path(storage_key).suffix or ".mp4"
ext = Path(clip.asset_id).suffix or ".mp4"
local_path = tmpdir_path / f"clip_{clip.order:04d}{ext}"
if download_asset(storage_key, local_path):
asset_path_map[clip.asset_id] = local_path
if _download_asset(clip.asset_id, local_path):
clip_paths.append(local_path)
rendered_clip_ids.append(clip.id)
else:
clip.mark_failed()
clip_repo.update(clip)
failed_clip_ids.append(clip.id)
if not asset_path_map:
if not clip_paths:
logger.error("所有片段素材下载失败: %s", plan_id)
plan.mark_failed()
plan_repo.update(plan)
@@ -438,38 +285,65 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task_repo.update(gen_task)
return {"status": "error", "message": "所有片段素材下载失败"}
# 4. 根据引擎选择渲染方式
if engine == "unified":
result = _render_with_unified(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
tmpdir_path=tmpdir_path,
rendered_clip_ids=rendered_clip_ids,
plan_id=plan_id,
generation_task_id=generation_task_id,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
)
else:
result = _render_with_legacy(
plan=plan,
clips=clips,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
tmpdir_path=tmpdir_path,
plan_id=plan_id,
generation_task_id=generation_task_id,
plan_repo=plan_repo,
clip_repo=clip_repo,
gen_task_repo=gen_task_repo,
db=db,
)
# 4. 拼接片段
output_path = tmpdir_path / f"rendered_{plan_id}.mp4"
transition_effects = [c.transition_effect for c in clips if c.asset_id]
success = _concatenate_clips(clip_paths, output_path, transition_effects)
result["engine"] = engine
return result
if not success:
logger.error("片段拼接失败: %s", plan_id)
plan.mark_failed()
plan_repo.update(plan)
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.status = "failed"
gen_task.error_message = "片段拼接失败"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
return {"status": "error", "message": "片段拼接失败"}
# 5. 上传到 OSS
storage_key = f"rendered/{plan_id}/output.mp4"
output_url = _upload_to_oss(output_path, storage_key)
# 6. 更新片段状态为 rendered
for clip_id in rendered_clip_ids:
clip = clip_repo.get(clip_id)
if clip and clip.status.value == "ready":
clip.mark_rendered()
clip_repo.update(clip)
# 7. 更新 EditPlan 状态为 completed
plan.config["rendered_url"] = output_url or ""
plan.config["rendered_storage_key"] = storage_key
plan.mark_completed()
plan_repo.update(plan)
# 8. 更新 GenerationTask 状态为 completed
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.status = "completed"
gen_task.progress = 100.0
gen_task.result_count = len(rendered_clip_ids)
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
logger.info(
"剪辑计划渲染完成: plan_id=%s rendered=%d failed=%d",
plan_id,
len(rendered_clip_ids),
len(failed_clip_ids),
)
return {
"status": "completed",
"plan_id": plan_id,
"rendered_count": len(rendered_clip_ids),
"failed_count": len(failed_clip_ids),
"output_url": output_url,
}
except Exception as exc:
logger.exception("渲染剪辑计划异常: %s", plan_id)
+253 -898
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,6 +1,9 @@
import subprocess
from datetime import datetime, timezone
from typing import Optional
from celery import Celery
from celery.app.task import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.core.asset_types import infer_mime_type_from_storage_key
+1 -4
View File
@@ -4,7 +4,6 @@ import logging
from celery import Task
from celery.exceptions import Retry
from video_processing.oss_helpers import get_signed_download_url
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
@@ -49,9 +48,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
repo = SQLAlchemyVoiceCloneProfileRepository(session)
workflow = VoiceCloneWorkflowService(
repository=repo,
cosyvoice_service=CosyVoiceService(
audio_url_signer=lambda url: get_signed_download_url(url, expires_seconds=86400) or url
),
cosyvoice_service=CosyVoiceService(),
)
updated_profile = workflow.poll_and_process_clone(profile_id, timeout=300)
@@ -1,11 +1,14 @@
"""Voice extraction tasks - extract voice tracks and background music from videos."""
import json
import logging
import os
import subprocess
import tempfile
from typing import Optional
from celery import Task
from sqlalchemy.orm import Session
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
-235
View File
@@ -1,235 +0,0 @@
# CI 必需环境变量清单
> 本文档整理小虾 SaaS 项目中所有从环境变量读取的配置项,明确哪些是 CI 测试必须的、哪些是可选的。
> 最后更新:2026-07-09
## 目录
- [一、配置来源说明](#一配置来源说明)
- [二、CI 必需环境变量(P0](#二ci-必需环境变量p0)
- [三、可选环境变量(有默认值)](#三可选环境变量有默认值)
- [四、测试专用环境变量](#四测试专用环境变量)
- [五、Worker 服务环境变量](#五worker-服务环境变量)
- [六、当前 CI 配置对照](#六当前-ci-配置对照)
---
## 一、配置来源说明
项目的环境变量配置主要来自以下几处:
| 来源 | 文件路径 | 说明 |
|------|---------|------|
| API 主配置 | `apps/api/app/config.py` | pydantic `Settings` 类,API 服务核心配置 |
| Worker 配置 | `apps/worker/worker_app/core/config.py` | pydantic `WorkerSettings` 类,Worker 服务配置 |
| 共享配置 | `packages/shared/config.py` | pydantic `SharedSettings` 类,API + Worker 共享配置 |
| 直接读取 | 各模块中 `os.environ` / `os.getenv` | 散落在各业务模块中的直接读取 |
> **注意**pydantic-settings 配置默认 `case_sensitive=False`,即环境变量名不区分大小写,但习惯上使用大写。
---
## 二、CI 必需环境变量(P0
以下变量是 CI 运行测试**必须配置**的,缺失会导致测试启动失败或核心功能异常。
| 变量名 | 用途说明 | 默认值 | 影响范围 |
|--------|---------|--------|---------|
| `DATABASE_URL` | 数据库连接字符串 | `postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas` | 集成测试、 Alembic 迁移验证 |
| `USE_IN_MEMORY_DB` | 是否使用内存数据库(SQLite) | `false` | 单元测试(设为 `true` 可跳过 PostgreSQL 依赖) |
| `JWT_SECRET_KEY` | JWT 签名密钥,**无安全默认值**,必须显式设置 | `None`(启动校验失败) | 所有涉及认证的 API 测试 |
> **说明**
> - 单元测试通过 `USE_IN_MEMORY_DB=true` 使用 SQLite 内存数据库,无需 PostgreSQL
> - 集成测试需要真实 PostgreSQL,需设置 `DATABASE_URL`
> - `JWT_SECRET_KEY` 在测试文件中通过 `os.environ.setdefault()` 设置了测试用默认值,CI 中可不额外配置,但生产环境必须配置
---
## 三、可选环境变量(有默认值)
以下变量都有合理的默认值,CI 中可以不配置,使用默认值即可。
### 3.1 应用基础配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `APP_NAME` | 应用名称 | `xiaoxia-saas` |
| `APP_VERSION` | 应用版本号 | `0.1.61` / `unknown` |
| `ENVIRONMENT` | 运行环境标识 | `development` |
| `DEBUG` | 是否开启调试模式 | `true` |
| `APP_BASE_URL` | 应用基础 URL(用于生成邮件链接等) | `http://localhost:3000` |
| `API_HOST` | API 服务绑定地址 | `0.0.0.0` |
| `API_PORT` | API 服务端口 | `8000` |
| `API_PREFIX` | API 路由前缀 | `/api/v1` |
| `APP_ENV` | 环境标识(用于加载 .env.{env} 文件) | `development` |
| `LOG_LEVEL` | 日志级别 | `INFO` |
### 3.2 数据库连接池配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `DATABASE_POOL_SIZE` | 连接池大小 | `20` |
| `DATABASE_MAX_OVERFLOW` | 最大溢出连接数 | `10` (API) / `40` (Worker) |
| `DATABASE_POOL_TIMEOUT` | 获取连接超时时间(秒) | `30` |
| `DATABASE_POOL_RECYLE` / `DATABASE_POOL_RECYCLE` | 连接回收时间(秒) | `3600` |
| `AUTO_CREATE_SCHEMA` | 是否自动创建表结构 | `false` |
### 3.3 Redis / Celery 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
| `REDIS_MAX_CONNECTION` | Redis 最大连接数 | `50` |
| `ENABLE_REDIS_SESSIONS` | 是否启用 Redis 会话存储 | `false` |
| `CELERY_BROKER_URL` / `BROKER_URL` | Celery Broker 地址 | `redis://localhost:6379/0` |
| `CELERY_RESULT_BACKEND` / `RESULT_BACKEND` | Celery 结果后端 | `redis://localhost:6379/1` |
### 3.4 JWT 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `JWT_ALGORITHM` | JWT 签名算法 | `HS256`(隐式默认) |
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | Access Token 过期时间(分钟) | `30`(隐式默认) |
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | Refresh Token 过期时间(天) | `30`(隐式默认) |
| `JWT_SECRET_KEY_OLD` | 旧 JWT 密钥(用于密钥轮换) | `None` |
| `SECRET_ROTATION_DAYS` | 密钥轮换建议天数 | `90` |
### 3.5 邮件配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `ENABLE_EMAIL_DELIVERY` | 是否启用邮件发送 | `false` |
| `SMTP_HOST` | SMTP 服务器地址 | `smtp.gmail.com` |
| `SMTP_PORT` | SMTP 端口 | `587` |
| `SMTP_USER` | SMTP 用户名 | `""`(空) |
| `SMTP_PASSWORD` | SMTP 密码 | `""`(空) |
| `SMTP_FROM_EMAIL` | 发件人邮箱 | `""`(空) |
| `SMTP_FROM_NAME` | 发件人名称 | `小虾 SaaS` |
| `SMTP_USE_TLS` | 是否使用 TLS | `true` |
### 3.6 OSS 阿里云存储配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliyuncs.com` |
| `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) |
| `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) |
| `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` |
| `OSS_DIRECT_UPLOAD_MAX_MB` / `MAX_UPLOAD_SIZE_MB` | 直传最大文件大小(MB | `2000` |
| `OSS_DIRECT_UPLOAD_EXPIRE_SECONDS` | 直传签名过期时间(秒) | `900` |
### 3.7 CosyVoice 语音合成配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `COSYVOICE_API_KEY` | CosyVoice API Key | `""`(空) |
| `COSYVOICE_BASE_URL` | CosyVoice API 地址 | `https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio` |
| `COSYVOICE_MODEL` | CosyVoice 模型 | `cosyvoice-v1` |
| `COSYVOICE_VOICE` | 默认音色 | `longxiaochun` |
| `COSYVOICE_SAMPLE_RATE` | 采样率 | `22050` |
| `COSYVOICE_FORMAT` | 输出格式 | `mp3` |
### 3.8 CORS 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `CORS_ORIGINS_RAW` | CORS 允许的源(逗号分隔) | `http://localhost:3000,http://localhost:5173,http://localhost:8000` |
### 3.9 文件存储 / 生成文件配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `GENERATED_FILES_DIR` | 生成文件本地存储目录 | `/app/generated` |
| `GENERATED_FILES_URL_PREFIX` | 生成文件访问 URL 前缀 | `/generated-files` |
| `VIDEO_OUTPUT_DIR` | 视频输出目录 | `{tempdir}/video_output` |
| `PUBLIC_API_BASE_URL` | 公开 API 基础 URL | `https://api.xiaoxiajianji.com` |
### 3.10 监控 / 指标配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `METRICS_AUTH_TOKEN` | Prometheus 指标接口认证 Token | `""`(空,不启用认证) |
### 3.11 内部 API 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `INTERNAL_API_KEYS` | 内部 API 调用密钥列表(逗号分隔) | `""`(空) |
---
## 四、测试专用环境变量
以下变量仅在测试或冒烟测试脚本中使用。
| 变量名 | 用途说明 | 默认值 | 使用位置 |
|--------|---------|--------|---------|
| `SMOKE_TEST_PASSWORD` | 冒烟测试用的测试账号密码 | `changeme` | `scripts/smoke_*.py` |
| `MIGRATION_SINCE_REVISION` | 迁移安全检查的起始版本 | `None` | `scripts/check_migration_safety.py` |
| `MIGRATION_DIFF_AGAINST` | 迁移 diff 对比的目标分支/版本 | `None` | `scripts/check_migration_safety.py` |
---
## 五、Worker 服务环境变量
以下变量主要用于 Worker(Celery)服务,CI 的单元/集成测试通常不涉及。
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `WORKER_NAME` | Worker 名称 | `xiaoxia-saas-worker` |
| `WORKER_CONCURRENCY` | Worker 并发数 | `4` |
| `WORKER_MAX_TASKS_PER_CHILD` | 每个子进程最大任务数 | `1000` |
---
## 六、当前 CI 配置对照
当前 `.gitea/workflows/ci-cd.yml``validate` job 配置的环境变量:
| 变量名 | CI 配置值 | 是否必需 | 备注 |
|--------|----------|---------|------|
| `DATABASE_URL` | `postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas` | ✅ 是 | Job 级别配置 |
| `USE_IN_MEMORY_DB` | `"false"`Job 级) / `"true"`(单元测试 step 级) | ✅ 是 | 单元测试 step 覆盖为 `true` |
| `JWT_SECRET_KEY` | (未配置) | ⚠️ 测试内置 | 测试文件中通过 `setdefault` 设置了测试密钥 |
### 6.1 CI 环境变量现状评估
- ✅ **数据库配置完备**DATABASE_URL + USE_IN_MEMORY_DB 已正确配置
- ✅ **JWT 密钥**:测试代码内置默认值,CI 可正常运行
- ⚠️ **缺少 Redis 配置**:但当前测试不依赖 Redis,使用默认值即可
- ⚠️ **缺少邮件/OSS/语音配置**:均为可选,CI 中使用空默认值不影响核心测试
### 6.2 建议后续补充
如果未来测试覆盖到以下功能,需要在 CI 中补充对应配置:
1. **Redis 相关测试** → 配置 `REDIS_URL`
2. **邮件发送测试** → 配置 `ENABLE_EMAIL_DELIVERY` 及 SMTP 相关变量
3. **OSS 上传测试** → 配置 OSS 相关变量(或使用 mock)
4. **语音合成测试** → 配置 CosyVoice 相关变量(或使用 mock
---
## 附录:环境变量读取位置索引
### pydantic Settings 类
- `apps/api/app/config.py``Settings` 类(API 主配置)
- `apps/worker/worker_app/core/config.py``WorkerSettings` 类(Worker 配置)
- `packages/shared/config.py``SharedSettings` 类(共享配置)
### 直接 os.environ / os.getenv 读取
| 变量名 | 文件位置 |
|--------|---------|
| `VIDEO_OUTPUT_DIR` | `apps/worker/video_processing/video_compose_service.py``apps/worker/worker_app/tasks/compose_video.py` |
| `INTERNAL_API_KEYS` | `apps/api/app/api/routes/auth.py` |
| `APP_ENV` / `ENV` | `apps/api/app/api/routes/auth.py`、各 config.py 的 `get_settings()` |
| `GENERATED_FILES_DIR` | `apps/worker/worker_app/tasks/generation.py``apps/api/main.py``scripts/cleanup_generated_files.py` |
| `GENERATED_FILES_URL_PREFIX` | `apps/worker/worker_app/tasks/generation.py``apps/api/main.py``apps/api/app/core/storage.py` |
| `PUBLIC_API_BASE_URL` | `apps/worker/worker_app/tasks/generation.py` |
| `METRICS_AUTH_TOKEN` | `apps/api/app/middleware/prometheus_metrics.py` |
| `APP_VERSION` | `apps/api/app/middleware/prometheus_metrics.py` |
| `SMOKE_TEST_PASSWORD` | `scripts/smoke_*.py` |
| `MIGRATION_SINCE_REVISION` | `scripts/check_migration_safety.py` |
| `MIGRATION_DIFF_AGAINST` | `scripts/check_migration_safety.py` |
| `DATABASE_URL` | `alembic/env.py` |
+37 -53
View File
@@ -473,7 +473,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -481,7 +481,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -489,7 +489,7 @@
"name": "asset_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -783,7 +783,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -791,7 +791,7 @@
"name": "plan_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -815,7 +815,7 @@
"name": "template_clip_config_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -823,7 +823,7 @@
"name": "asset_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -939,7 +939,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -947,7 +947,7 @@
"name": "template_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -987,7 +987,7 @@
"name": "source_edit_plan_id",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -995,7 +995,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1003,7 +1003,7 @@
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1071,7 +1071,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1098,14 +1098,6 @@
"type": "VARCHAR(50)",
"unique": false
},
{
"index": false,
"name": "editing_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": false,
"name": "config",
@@ -1189,7 +1181,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1197,7 +1189,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1205,7 +1197,7 @@
"name": "generation_task_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1341,7 +1333,7 @@
"name": "duplicate_of",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
}
],
@@ -1386,7 +1378,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1394,7 +1386,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1402,7 +1394,7 @@
"name": "strategy_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1410,7 +1402,7 @@
"name": "asset_library_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1418,7 +1410,7 @@
"name": "voice_library_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1514,7 +1506,7 @@
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1522,7 +1514,7 @@
"name": "source_edit_plan_id",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1538,7 +1530,7 @@
"name": "batch_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1549,14 +1541,6 @@
"type": "JSON",
"unique": false
},
{
"index": false,
"name": "logs",
"nullable": false,
"primary_key": false,
"type": "TEXT",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -1635,7 +1619,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1643,7 +1627,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1651,7 +1635,7 @@
"name": "library_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1683,7 +1667,7 @@
"name": "result_asset_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1745,7 +1729,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1753,7 +1737,7 @@
"name": "project_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1841,7 +1825,7 @@
"name": "source_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1849,7 +1833,7 @@
"name": "created_by_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1933,7 +1917,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -1941,7 +1925,7 @@
"name": "owner_user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -2261,7 +2245,7 @@
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
@@ -2269,7 +2253,7 @@
"name": "template_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"type": "VARCHAR(32)",
"unique": false
},
{
+13 -10
View File
@@ -6,14 +6,14 @@
# 基础镜像:Python 3.12
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 构建参数:版本号(CI 传入 commit hash
ARG APP_VERSION=dev
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
@@ -21,12 +21,15 @@ WORKDIR /app
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
COPY requirements-base.txt /tmp/requirements-base.txt
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt && rm /tmp/requirements-base.txt
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# ---- 依赖分层:业务依赖(变化频繁)----
COPY requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt && rm /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# 复制应用代码
COPY apps/api/ /app/apps/api/
@@ -37,13 +40,13 @@ COPY alembic/ /app/alembic/
COPY scripts/ /app/scripts/
# 设置环境变量
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
# API 入口点
WORKDIR /app/apps/api
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
# Worker 启动脚本 — 支持 WORKER_CONCURRENCY 环境变量
# 未设置时默认 2(保持向后兼容)
set -e
CONCURRENCY="${WORKER_CONCURRENCY:-2}"
exec celery \
-A worker_app.celery_app \
worker \
--loglevel=info \
"--concurrency=${CONCURRENCY}"

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