Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5802a1142 | |||
| eea9f01f7b | |||
| aa8a41ddb3 | |||
| 1e314e3168 | |||
| 9427e72ba4 | |||
| b7f105d4ac | |||
| d8d1674ff0 | |||
| ad671d94c5 | |||
| efe7f6b52a | |||
| ce4f73d4de | |||
| 759c23c418 | |||
| 1e840057cb | |||
| 9aa450bb8b | |||
| 68a5c60911 | |||
| 841a168d30 | |||
| a7e346e200 | |||
| ef3a9fa61e | |||
| 20fa1ad589 | |||
| dddafbde83 | |||
| 6657b0fe19 | |||
| a25e0b6220 |
+8
-3
@@ -44,9 +44,14 @@ OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
|||||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||||
|
|
||||||
# ==================== CosyVoice 语音合成配置 ====================
|
# ==================== 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=your-cosyvoice-api-key
|
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
COSYVOICE_MODEL=cosyvoice-v1
|
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||||
COSYVOICE_VOICE=longxiaochun
|
COSYVOICE_VOICE=longxiaochun_v3
|
||||||
COSYVOICE_SAMPLE_RATE=22050
|
COSYVOICE_SAMPLE_RATE=22050
|
||||||
COSYVOICE_FORMAT=mp3
|
COSYVOICE_FORMAT=mp3
|
||||||
|
|||||||
Regular → Executable
+8
-3
@@ -42,10 +42,15 @@ OSS_DIRECT_UPLOAD_MAX_MB=2000
|
|||||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||||
|
|
||||||
# ==================== CosyVoice 语音合成(必须配置)====================
|
# ==================== 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_API_KEY=CHANGE_ME_COSYVOICE_API_KEY
|
||||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
COSYVOICE_MODEL=cosyvoice-v1
|
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||||
COSYVOICE_VOICE=longxiaochun
|
COSYVOICE_VOICE=longxiaochun_v3
|
||||||
COSYVOICE_SAMPLE_RATE=22050
|
COSYVOICE_SAMPLE_RATE=22050
|
||||||
COSYVOICE_FORMAT=mp3
|
COSYVOICE_FORMAT=mp3
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
max-line-length = 120
|
max-line-length = 120
|
||||||
exclude =
|
exclude =
|
||||||
.git,
|
.git,
|
||||||
|
.cache,
|
||||||
__pycache__,
|
__pycache__,
|
||||||
.venv,
|
.venv,
|
||||||
venv,
|
venv,
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
python3 - <<'PY'
|
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/{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:
|
|
||||||
top_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == top_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(top_prefix):
|
|
||||||
member.name = name[len(top_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- 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
|
|
||||||
+205
-14
@@ -22,7 +22,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate Code Quality And Tests
|
name: Validate Code Quality And Tests
|
||||||
runs-on: ubuntu-22.04
|
runs-on: host
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
python --version
|
python3 --version
|
||||||
python3 -m pip --version
|
python3 -m pip --version
|
||||||
echo "CI environment is ready"
|
echo "CI environment is ready"
|
||||||
|
|
||||||
@@ -158,14 +158,180 @@ jobs:
|
|||||||
python3 scripts/check_migration_safety.py --allow-medium-risk
|
python3 scripts/check_migration_safety.py --allow-medium-risk
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Debug coverage paths
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
echo "=== PWD ==="
|
||||||
|
pwd
|
||||||
|
echo "=== check source dirs ==="
|
||||||
|
ls -d apps/api/app packages
|
||||||
|
echo "=== python import check ==="
|
||||||
|
python3 - <<'PY'
|
||||||
|
import sys, os
|
||||||
|
os.environ["PYTHONPATH"] = f"{os.getcwd()}/apps/api:{os.getcwd()}"
|
||||||
|
sys.path.insert(0, f"{os.getcwd()}/apps/api")
|
||||||
|
sys.path.insert(0, os.getcwd())
|
||||||
|
print(f"cwd: {os.getcwd()}")
|
||||||
|
print(f"sys.path[:5]: {sys.path[:5]}")
|
||||||
|
try:
|
||||||
|
import app
|
||||||
|
print(f"app.__file__: {app.__file__}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"import app failed: {e}")
|
||||||
|
try:
|
||||||
|
import packages
|
||||||
|
print(f"packages.__file__: {packages.__file__}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"import packages failed: {e}")
|
||||||
|
PY
|
||||||
|
echo "=== coverage debug ==="
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, f"{os.getcwd()}/apps/api")
|
||||||
|
sys.path.insert(0, os.getcwd())
|
||||||
|
import coverage
|
||||||
|
cov = coverage.Coverage(source=["apps/api/app", "packages"])
|
||||||
|
print(f"source: {cov.config.source}")
|
||||||
|
for src in cov.config.source or []:
|
||||||
|
abspath = os.path.abspath(src)
|
||||||
|
print(f" {src} -> {abspath} exists={os.path.exists(src)}")
|
||||||
|
if os.path.isdir(src):
|
||||||
|
pyfiles = []
|
||||||
|
for root, dirs, files in os.walk(src):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.py'):
|
||||||
|
pyfiles.append(os.path.join(root, f))
|
||||||
|
print(f" .py files: {len(pyfiles)}")
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
env:
|
||||||
USE_IN_MEMORY_DB: "true"
|
USE_IN_MEMORY_DB: "true"
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q \
|
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \
|
||||||
--cov=apps --cov-report=term --cov-report=xml
|
--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: Build summary
|
||||||
|
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
echo "Build completed successfully!"
|
||||||
|
echo "Branch: ${GITHUB_REF_NAME}"
|
||||||
|
echo "Commit: ${GITHUB_SHA}"
|
||||||
|
# 输出最终覆盖率
|
||||||
|
python3 scripts/ci_coverage_summary.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 -q -r requirements-base.txt
|
||||||
|
python3 -m pip install -q -r requirements.txt
|
||||||
|
python3 -m pip install -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
|
||||||
|
|
||||||
- name: Start PostgreSQL for integration tests
|
- name: Start PostgreSQL for integration tests
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -210,8 +376,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
pip install -q pytest-rerunfailures
|
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" \
|
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
|
||||||
--cov=apps --cov-append --cov-report=term --cov-report=xml --cov-fail-under=50
|
--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 # 集成测试覆盖率门槛较低,核心目标是功能验证
|
||||||
|
|
||||||
- name: Run API performance baseline tests
|
- name: Run API performance baseline tests
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -255,25 +427,44 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
|
||||||
- name: Cleanup PostgreSQL
|
- name: Cleanup PostgreSQL & Redis
|
||||||
if: always()
|
if: always()
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
docker rm -f "${PG_CONTAINER:-ci-pg-validate}" 2>/dev/null || true
|
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 "PostgreSQL container cleaned up"
|
||||||
|
echo "Redis container cleaned up"
|
||||||
|
|
||||||
- name: Build summary
|
- name: Coverage summary
|
||||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
if: always()
|
||||||
|
shell: sh
|
||||||
|
env:
|
||||||
|
COVERAGE_THRESHOLD: "40"
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
echo "=== 覆盖率汇总 ==="
|
||||||
|
python3 scripts/ci_coverage_summary.py
|
||||||
|
- name: Notify CI failure
|
||||||
|
if: failure()
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set +e
|
||||||
echo "Build completed successfully!"
|
echo "=== CI 失败通知 ==="
|
||||||
echo "Branch: ${GITHUB_REF_NAME}"
|
FAILED_JOB="Validate Code Quality And Tests" python3 scripts/ci_notify_failure.py
|
||||||
echo "Commit: ${GITHUB_SHA}"
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
||||||
frontend-lint:
|
frontend-lint:
|
||||||
name: Frontend Lint
|
name: Frontend Lint
|
||||||
runs-on: ubuntu-22.04
|
runs-on: host
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Debug CMD Agent
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'debug/cmd-agent'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
debug:
|
||||||
|
name: Debug CMD Agent
|
||||||
|
runs-on: host
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Diagnose
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
echo "=== 1. CMD Agent config ==="
|
||||||
|
cat /opt/xiaoxia-cmd-agent/config.json 2>/dev/null || cat /opt/xiaoxia-cmd-agent/config.yaml 2>/dev/null || echo "no config found"
|
||||||
|
ls -la /opt/xiaoxia-cmd-agent/ 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 2. CMD Agent process ==="
|
||||||
|
ps aux | grep cmd-agent | grep -v grep
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 3. Local curl test (127.0.0.1:18888) ==="
|
||||||
|
curl -s -X POST http://127.0.0.1:18888/cmd-agent/exec \
|
||||||
|
-H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"hostname"}' 2>&1 || echo "FAILED"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 4. Nginx config for cmd-agent ==="
|
||||||
|
grep -r "cmd-agent" /etc/nginx/sites-enabled/ 2>/dev/null || \
|
||||||
|
grep -r "cmd-agent" /etc/nginx/conf.d/ 2>/dev/null || \
|
||||||
|
echo "no nginx cmd-agent config found"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 5. Nginx access log (last 5 lines) ==="
|
||||||
|
tail -5 /var/log/nginx/access.log 2>/dev/null | grep cmd || echo "no log"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== DONE ==="
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Fix CMD Agent Auth
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'debug/cmd-agent'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fix:
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: 验证不带Bearer
|
||||||
|
run: |
|
||||||
|
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
||||||
|
- name: 验证带Bearer(应该失败)
|
||||||
|
run: |
|
||||||
|
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
||||||
|
- name: 读取当前server.py的check_auth
|
||||||
|
run: |
|
||||||
|
grep -A 5 "def check_auth" /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
- name: 修复check_auth函数
|
||||||
|
run: |
|
||||||
|
cp /opt/xiaoxia-cmd-agent/server.py /opt/xiaoxia-cmd-agent/server.py.bak
|
||||||
|
sed -i '/def check_auth/,/return True/{
|
||||||
|
/def check_auth/a\ t = self.headers.get("Authorization", "")
|
||||||
|
/if t != AUTH_TOKEN/i\ if t.startswith("Bearer "):\n t = t[7:]
|
||||||
|
}' /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
echo "Done via sed"
|
||||||
|
- name: 验证修复后的check_auth
|
||||||
|
run: |
|
||||||
|
grep -A 8 "def check_auth" /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
- name: 重启服务
|
||||||
|
run: |
|
||||||
|
systemctl restart xiaoxia-cmd-agent
|
||||||
|
- name: 等待服务启动
|
||||||
|
run: |
|
||||||
|
sleep 3
|
||||||
|
- name: 修复后验证-不带Bearer
|
||||||
|
run: |
|
||||||
|
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
||||||
|
- name: 修复后验证-带Bearer
|
||||||
|
run: |
|
||||||
|
curl -s -w "\nHTTP_CODE:%{http_code}" http://127.0.0.1:18888/status -H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
||||||
|
- name: 公网路径验证
|
||||||
|
run: |
|
||||||
|
curl -sk -w "\nHTTP_CODE:%{http_code}" https://127.0.0.1/cmd-agent/status -H "Authorization: Bearer xsa-f2778a6953d59948cd1e5be4d99f60f7"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Read Auth Logic
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'debug/cmd-agent'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
read:
|
||||||
|
name: Read check_auth logic
|
||||||
|
runs-on: host
|
||||||
|
timeout-minutes: 3
|
||||||
|
steps:
|
||||||
|
- name: Read
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "=== Full server.py (lines 1-50) ==="
|
||||||
|
sed -n '1,50p' /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
echo ""
|
||||||
|
echo "=== Lines 120-160 (startup logic) ==="
|
||||||
|
sed -n '120,160p' /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
echo ""
|
||||||
|
echo "=== Test with X-Token header ==="
|
||||||
|
curl -s -X POST http://127.0.0.1:18888/cmd-agent/exec \
|
||||||
|
-H "X-Token: $(cat /etc/xiaoxia-cmd-agent.token)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"hostname"}'
|
||||||
|
echo ""
|
||||||
|
echo "=== Test with token in query string ==="
|
||||||
|
curl -s -X POST "http://127.0.0.1:18888/cmd-agent/exec?token=$(cat /etc/xiaoxia-cmd-agent.token)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"hostname"}'
|
||||||
|
echo ""
|
||||||
|
echo "=== Check if path is /exec not /cmd-agent/exec ==="
|
||||||
|
curl -s -X POST http://127.0.0.1:18888/exec \
|
||||||
|
-H "Authorization: Bearer $(cat /etc/xiaoxia-cmd-agent.token)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"hostname"}'
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Read CMD Agent Source
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'debug/cmd-agent'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
read:
|
||||||
|
name: Read CMD Agent server.py
|
||||||
|
runs-on: host
|
||||||
|
timeout-minutes: 3
|
||||||
|
steps:
|
||||||
|
- name: Read source
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "=== CMD Agent server.py (first 80 lines) ==="
|
||||||
|
head -80 /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
echo ""
|
||||||
|
echo "=== Token-related lines ==="
|
||||||
|
grep -n -i "token\|auth\|secret\|key" /opt/xiaoxia-cmd-agent/server.py
|
||||||
|
echo ""
|
||||||
|
echo "=== Systemd service config ==="
|
||||||
|
cat /etc/systemd/system/xiaoxia-cmd-agent.service 2>/dev/null || echo "no systemd service"
|
||||||
|
echo ""
|
||||||
|
echo "=== Environment variables from process ==="
|
||||||
|
cat /proc/1034/environ 2>/dev/null | tr '\0' '\n' | grep -i "token\|auth\|secret\|key" || echo "no env vars found"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Read CMD Agent Token
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'debug/cmd-agent'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
read:
|
||||||
|
name: Read Real Token
|
||||||
|
runs-on: host
|
||||||
|
timeout-minutes: 3
|
||||||
|
steps:
|
||||||
|
- name: Read
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "=== Real CMD Agent Token ==="
|
||||||
|
cat /etc/xiaoxia-cmd-agent.token
|
||||||
|
echo ""
|
||||||
|
echo "=== Test with real token ==="
|
||||||
|
curl -s -X POST http://127.0.0.1:18888/cmd-agent/exec \
|
||||||
|
-H "Authorization: Bearer $(cat /etc/xiaoxia-cmd-agent.token)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"hostname && whoami"}'
|
||||||
|
echo ""
|
||||||
|
echo "=== Nginx config for cmd-agent (full) ==="
|
||||||
|
sed -n '/cmd-agent/,/}/p' /etc/nginx/sites-enabled/00-xiaoxia-saas | head -20
|
||||||
|
echo ""
|
||||||
|
echo "=== All listening ports ==="
|
||||||
|
ss -tlnp | head -20
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -6,6 +6,7 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Python / backend
|
# Python / backend
|
||||||
|
.cache/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
.venv-ci-root/
|
.venv-ci-root/
|
||||||
|
|||||||
Regular → Executable
+26
@@ -24,6 +24,7 @@ from typing import Any, List, Optional
|
|||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
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.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||||
from app.schemas.generation_task import GenerationTaskResponse
|
from app.schemas.generation_task import GenerationTaskResponse
|
||||||
from app.services import EditPlanService, PlanGeneratorService
|
from app.services import EditPlanService, PlanGeneratorService
|
||||||
@@ -644,6 +645,31 @@ def generate_plan(
|
|||||||
|
|
||||||
# 创建 GenerationTask
|
# 创建 GenerationTask
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
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)
|
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||||
plan = svc.get_plan_or_raise(plan_id)
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
gen_task = gen_task_use_case.execute(
|
gen_task = gen_task_use_case.execute(
|
||||||
|
|||||||
Regular → Executable
+98
-14
@@ -4,8 +4,15 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
|
||||||
from app.core.storage import OSSStorageService, get_storage_service
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
|
from app.core.task_enqueue import (
|
||||||
|
GLOBAL_PENDING_LIMIT,
|
||||||
|
USER_PENDING_LIMIT,
|
||||||
|
GlobalQueueFull,
|
||||||
|
UserPendingLimitExceeded,
|
||||||
|
check_queue_limits,
|
||||||
|
safe_enqueue_generation_task,
|
||||||
|
)
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
get_asset_library_repository,
|
get_asset_library_repository,
|
||||||
get_asset_repository,
|
get_asset_repository,
|
||||||
@@ -36,7 +43,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||||
"""检查用户是否有项目访问权限"""
|
"""检查用户是否有项目访问权限"""
|
||||||
project = project_repository.find_by_id(project_id)
|
project = project_repository.find_by_id(project_id)
|
||||||
@@ -228,9 +234,32 @@ def create_generation_task(
|
|||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||||
count = request.count
|
count = request.count
|
||||||
created_tasks = []
|
created_tasks = []
|
||||||
|
failed_tasks = []
|
||||||
|
user_id = authenticated_user.user.id
|
||||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||||
|
|
||||||
|
# 预检查:批量提交前先看会不会超限,避免建一半才拒
|
||||||
|
try:
|
||||||
|
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||||
|
global_pending = generation_task_repository.count_pending_total()
|
||||||
|
if user_pending + count > USER_PENDING_LIMIT:
|
||||||
|
raise UserPendingLimitExceeded(
|
||||||
|
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||||
|
)
|
||||||
|
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||||
|
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||||
|
except UserPendingLimitExceeded as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||||
|
) from e
|
||||||
|
except GlobalQueueFull as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="系统繁忙,请稍后再试",
|
||||||
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
task = use_case.execute(
|
task = use_case.execute(
|
||||||
@@ -243,25 +272,47 @@ def create_generation_task(
|
|||||||
asset_ids=resolved_asset_ids,
|
asset_ids=resolved_asset_ids,
|
||||||
title_ids=request.title_ids,
|
title_ids=request.title_ids,
|
||||||
voice_ids=request.voice_ids,
|
voice_ids=request.voice_ids,
|
||||||
created_by_user_id=authenticated_user.user.id,
|
created_by_user_id=user_id,
|
||||||
source_edit_plan_id=request.source_edit_plan_id,
|
source_edit_plan_id=request.source_edit_plan_id,
|
||||||
asset_select_mode=request.asset_select_mode,
|
asset_select_mode=request.asset_select_mode,
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
try:
|
||||||
created_tasks.append(task)
|
if safe_enqueue_generation_task(
|
||||||
logger.info(
|
task,
|
||||||
"[生成任务] 入队成功: task_id=%s, status=%s, batch_id=%s",
|
generation_task_repository,
|
||||||
task.id,
|
user_id=user_id,
|
||||||
task.status,
|
log_prefix="[生成任务]",
|
||||||
batch_id,
|
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:
|
except Exception as e:
|
||||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
||||||
|
|
||||||
items = [_to_generation_task_response(t) for t in created_tasks]
|
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
||||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
@@ -331,6 +382,21 @@ def retry_generation_task(
|
|||||||
if status_val != "failed":
|
if status_val != "failed":
|
||||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
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)
|
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||||
retried = use_case.execute(
|
retried = use_case.execute(
|
||||||
CreateGenerationTaskCommand(
|
CreateGenerationTaskCommand(
|
||||||
@@ -342,10 +408,28 @@ def retry_generation_task(
|
|||||||
asset_ids=task.asset_ids,
|
asset_ids=task.asset_ids,
|
||||||
title_ids=task.title_ids,
|
title_ids=task.title_ids,
|
||||||
voice_ids=task.voice_ids,
|
voice_ids=task.voice_ids,
|
||||||
created_by_user_id=authenticated_user.user.id,
|
created_by_user_id=user_id,
|
||||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
try:
|
||||||
|
if not safe_enqueue_generation_task(
|
||||||
|
retried,
|
||||||
|
generation_task_repository,
|
||||||
|
user_id=user_id,
|
||||||
|
log_prefix="[生成任务]",
|
||||||
|
log_task_status=True,
|
||||||
|
):
|
||||||
|
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||||
|
except UserPendingLimitExceeded:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="您的待处理任务过多,请等待完成后再提交",
|
||||||
|
) from None
|
||||||
|
except GlobalQueueFull:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="系统繁忙,请稍后再试",
|
||||||
|
) from None
|
||||||
return _to_generation_task_response(retried)
|
return _to_generation_task_response(retried)
|
||||||
|
|||||||
Regular → Executable
+73
-5
@@ -1,7 +1,15 @@
|
|||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
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 (
|
from app.dependencies import (
|
||||||
get_generation_task_repository,
|
get_generation_task_repository,
|
||||||
get_ingest_job_repository,
|
get_ingest_job_repository,
|
||||||
@@ -22,8 +30,9 @@ from packages.application import (
|
|||||||
SubmitIngestJobUseCase,
|
SubmitIngestJobUseCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
def _humanize_task_error(error_message: str) -> str:
|
def _humanize_task_error(error_message: str) -> str:
|
||||||
raw = (error_message or "").strip()
|
raw = (error_message or "").strip()
|
||||||
@@ -139,6 +148,21 @@ def retry_task_by_id(
|
|||||||
if _status_value(task.status) != "failed":
|
if _status_value(task.status) != "failed":
|
||||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
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)
|
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||||
retried = use_case.execute(
|
retried = use_case.execute(
|
||||||
CreateGenerationTaskCommand(
|
CreateGenerationTaskCommand(
|
||||||
@@ -150,10 +174,24 @@ def retry_task_by_id(
|
|||||||
asset_ids=task.asset_ids,
|
asset_ids=task.asset_ids,
|
||||||
title_ids=task.title_ids,
|
title_ids=task.title_ids,
|
||||||
voice_ids=task.voice_ids,
|
voice_ids=task.voice_ids,
|
||||||
created_by_user_id=authenticated_user.user.id,
|
created_by_user_id=user_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
celery_app.send_task("worker.generate_video", args=[retried.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
|
||||||
return UserTaskResponse(
|
return UserTaskResponse(
|
||||||
id=f"generation:{retried.id}",
|
id=f"generation:{retried.id}",
|
||||||
task_type="generation",
|
task_type="generation",
|
||||||
@@ -221,6 +259,22 @@ def retry_project_task(
|
|||||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||||
if _status_value(task.status) != "failed":
|
if _status_value(task.status) != "failed":
|
||||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
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)
|
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||||
retried = use_case.execute(
|
retried = use_case.execute(
|
||||||
CreateGenerationTaskCommand(
|
CreateGenerationTaskCommand(
|
||||||
@@ -232,10 +286,24 @@ def retry_project_task(
|
|||||||
asset_ids=task.asset_ids,
|
asset_ids=task.asset_ids,
|
||||||
title_ids=task.title_ids,
|
title_ids=task.title_ids,
|
||||||
voice_ids=task.voice_ids,
|
voice_ids=task.voice_ids,
|
||||||
created_by_user_id=authenticated_user.user.id,
|
created_by_user_id=user_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
celery_app.send_task("worker.generate_video", args=[retried.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
|
||||||
return _generation_task_to_project_response(retried)
|
return _generation_task_to_project_response(retried)
|
||||||
if task_type == "ingest":
|
if task_type == "ingest":
|
||||||
job = ingest_job_repository.get(source_id)
|
job = ingest_job_repository.get(source_id)
|
||||||
|
|||||||
Regular → Executable
+17
-6
@@ -7,6 +7,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
|
get_audio_url_signer,
|
||||||
get_cosyvoice_service,
|
get_cosyvoice_service,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
get_user_repository,
|
get_user_repository,
|
||||||
@@ -56,7 +57,10 @@ def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTS
|
|||||||
return SQLAlchemyTTSJobRepository(session)
|
return SQLAlchemyTTSJobRepository(session)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(job) -> TTSJobResponse:
|
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)
|
||||||
return TTSJobResponse(
|
return TTSJobResponse(
|
||||||
id=job.id,
|
id=job.id,
|
||||||
user_id=job.user_id,
|
user_id=job.user_id,
|
||||||
@@ -66,7 +70,7 @@ def _to_response(job) -> TTSJobResponse:
|
|||||||
project_id=job.project_id,
|
project_id=job.project_id,
|
||||||
voice_clone_profile_id=job.voice_clone_profile_id,
|
voice_clone_profile_id=job.voice_clone_profile_id,
|
||||||
status=job.status,
|
status=job.status,
|
||||||
output_audio_url=job.output_audio_url,
|
output_audio_url=output_url,
|
||||||
output_audio_key=job.output_audio_key,
|
output_audio_key=job.output_audio_key,
|
||||||
duration=job.duration,
|
duration=job.duration,
|
||||||
file_size=job.file_size,
|
file_size=job.file_size,
|
||||||
@@ -176,6 +180,7 @@ def list_tts_jobs(
|
|||||||
status_filter: Optional[str] = Query(None, alias="status"),
|
status_filter: Optional[str] = Query(None, alias="status"),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> ListTTSJobResponse:
|
) -> ListTTSJobResponse:
|
||||||
"""列出用户的 TTS 合成任务。"""
|
"""列出用户的 TTS 合成任务。"""
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
@@ -183,7 +188,7 @@ def list_tts_jobs(
|
|||||||
skip = (page - 1) * page_size
|
skip = (page - 1) * page_size
|
||||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
|
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size)
|
||||||
return ListTTSJobResponse(
|
return ListTTSJobResponse(
|
||||||
items=[_to_response(j) for j in items],
|
items=[_to_response(j, sign_url) for j in items],
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
@@ -195,6 +200,7 @@ def get_tts_job(
|
|||||||
job_id: str,
|
job_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> TTSJobResponse:
|
) -> TTSJobResponse:
|
||||||
"""获取 TTS 任务详情。"""
|
"""获取 TTS 任务详情。"""
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
@@ -203,7 +209,7 @@ def get_tts_job(
|
|||||||
job = use_case.execute(job_id, user_id)
|
job = use_case.execute(job_id, user_id)
|
||||||
except TTSJobNotFoundError:
|
except TTSJobNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||||
return _to_response(job)
|
return _to_response(job, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
|
@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse)
|
||||||
@@ -211,6 +217,7 @@ def get_tts_job_status(
|
|||||||
job_id: str,
|
job_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> TTSStatusResponse:
|
) -> TTSStatusResponse:
|
||||||
"""查询 TTS 合成状态(用于前端轮询)。"""
|
"""查询 TTS 合成状态(用于前端轮询)。"""
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
@@ -219,10 +226,13 @@ def get_tts_job_status(
|
|||||||
job = use_case.execute(job_id, user_id)
|
job = use_case.execute(job_id, user_id)
|
||||||
except TTSJobNotFoundError:
|
except TTSJobNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
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(
|
return TTSStatusResponse(
|
||||||
id=job.id,
|
id=job.id,
|
||||||
status=job.status,
|
status=job.status,
|
||||||
output_audio_url=job.output_audio_url,
|
output_audio_url=output_url,
|
||||||
error_message=job.error_message,
|
error_message=job.error_message,
|
||||||
duration=job.duration,
|
duration=job.duration,
|
||||||
retry_count=job.retry_count,
|
retry_count=job.retry_count,
|
||||||
@@ -258,6 +268,7 @@ def save_tts_job_to_library(
|
|||||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> SaveToLibraryResponse:
|
) -> SaveToLibraryResponse:
|
||||||
"""将已完成的 TTS 合成结果保存到配音库。
|
"""将已完成的 TTS 合成结果保存到配音库。
|
||||||
|
|
||||||
@@ -328,7 +339,7 @@ def save_tts_job_to_library(
|
|||||||
return SaveToLibraryResponse(
|
return SaveToLibraryResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
name=item.name,
|
name=item.name,
|
||||||
audio_url=item.audio_url,
|
audio_url=sign_url(item.audio_url) if item.audio_url else "",
|
||||||
duration=item.duration,
|
duration=item.duration,
|
||||||
voice_id=item.voice_id,
|
voice_id=item.voice_id,
|
||||||
voice_name=item.voice_name,
|
voice_name=item.voice_name,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
def _to_response(profile) -> VoiceCloneProfileResponse:
|
def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||||
|
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||||
return VoiceCloneProfileResponse(
|
return VoiceCloneProfileResponse(
|
||||||
id=profile.id,
|
id=profile.id,
|
||||||
user_id=profile.user_id,
|
user_id=profile.user_id,
|
||||||
|
|||||||
Regular → Executable
+22
-10
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import get_db_session, get_user_repository
|
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||||
from app.schemas.voice import (
|
from app.schemas.voice import (
|
||||||
PresetVoiceItemResponse,
|
PresetVoiceItemResponse,
|
||||||
PresetVoiceListResponse,
|
PresetVoiceListResponse,
|
||||||
@@ -50,7 +50,10 @@ def _get_clone_profile_repository(session: Session = Depends(get_db_session)) ->
|
|||||||
return SQLAlchemyVoiceCloneProfileRepository(session)
|
return SQLAlchemyVoiceCloneProfileRepository(session)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(item) -> VoiceLibraryItemResponse:
|
def _to_response(item, sign_url=None) -> VoiceLibraryItemResponse:
|
||||||
|
audio = item.audio_url
|
||||||
|
if sign_url and audio:
|
||||||
|
audio = sign_url(audio)
|
||||||
return VoiceLibraryItemResponse(
|
return VoiceLibraryItemResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
user_id=item.user_id,
|
user_id=item.user_id,
|
||||||
@@ -59,7 +62,7 @@ def _to_response(item) -> VoiceLibraryItemResponse:
|
|||||||
voice_provider=item.voice_provider,
|
voice_provider=item.voice_provider,
|
||||||
voice_id=item.voice_id,
|
voice_id=item.voice_id,
|
||||||
voice_name=item.voice_name,
|
voice_name=item.voice_name,
|
||||||
audio_url=item.audio_url,
|
audio_url=audio,
|
||||||
duration=item.duration,
|
duration=item.duration,
|
||||||
file_size=item.file_size,
|
file_size=item.file_size,
|
||||||
status=item.status,
|
status=item.status,
|
||||||
@@ -70,16 +73,20 @@ def _to_response(item) -> VoiceLibraryItemResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
|
def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None) -> UnifiedVoiceItemResponse:
|
||||||
"""将数据库音色转换为统一响应格式。
|
"""将数据库音色转换为统一响应格式。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
item: VoiceLibraryItem
|
item: VoiceLibraryItem
|
||||||
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
|
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
|
||||||
|
sign_url: 音频URL预签名函数
|
||||||
"""
|
"""
|
||||||
profile_id = None
|
profile_id = None
|
||||||
if profile_id_map and item.voice_id:
|
if profile_id_map and item.voice_id:
|
||||||
profile_id = profile_id_map.get(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(
|
return UnifiedVoiceItemResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
type="clone",
|
type="clone",
|
||||||
@@ -89,7 +96,7 @@ def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoi
|
|||||||
language="zh-CN",
|
language="zh-CN",
|
||||||
voice_id=item.voice_id,
|
voice_id=item.voice_id,
|
||||||
voice_provider=item.voice_provider or "cosyvoice",
|
voice_provider=item.voice_provider or "cosyvoice",
|
||||||
audio_url=item.audio_url,
|
audio_url=audio,
|
||||||
duration=item.duration,
|
duration=item.duration,
|
||||||
file_size=item.file_size,
|
file_size=item.file_size,
|
||||||
status=item.status,
|
status=item.status,
|
||||||
@@ -140,6 +147,7 @@ def list_voices_unified(
|
|||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> UnifiedVoiceListResponse:
|
) -> UnifiedVoiceListResponse:
|
||||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||||
|
|
||||||
@@ -167,7 +175,7 @@ def list_voices_unified(
|
|||||||
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
||||||
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_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 {}
|
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) for i in clone_items_raw]
|
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
|
||||||
|
|
||||||
# 组装结果
|
# 组装结果
|
||||||
if type == "preset":
|
if type == "preset":
|
||||||
@@ -224,6 +232,7 @@ def list_voices_legacy(
|
|||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> ListVoiceLibraryResponse:
|
) -> ListVoiceLibraryResponse:
|
||||||
"""原有配音列表接口(仅返回用户克隆音色)。
|
"""原有配音列表接口(仅返回用户克隆音色)。
|
||||||
|
|
||||||
@@ -233,7 +242,7 @@ def list_voices_legacy(
|
|||||||
use_case = ListVoiceLibraryUseCase(voice_repository)
|
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||||
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||||
return ListVoiceLibraryResponse(
|
return ListVoiceLibraryResponse(
|
||||||
items=[_to_response(i) for i in items],
|
items=[_to_response(i, sign_url) for i in items],
|
||||||
total=total,
|
total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,13 +252,14 @@ def get_voice(
|
|||||||
voice_id: str,
|
voice_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> VoiceLibraryItemResponse:
|
) -> VoiceLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
use_case = GetVoiceLibraryUseCase(voice_repository)
|
use_case = GetVoiceLibraryUseCase(voice_repository)
|
||||||
item = use_case.execute(voice_id, user_id)
|
item = use_case.execute(voice_id, user_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||||
return _to_response(item)
|
return _to_response(item, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -258,6 +268,7 @@ def create_voice(
|
|||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> VoiceLibraryItemResponse:
|
) -> VoiceLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
plan_name = _get_user_plan(user_id, user_repository)
|
||||||
@@ -283,7 +294,7 @@ def create_voice(
|
|||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||||
)
|
)
|
||||||
return _to_response(item)
|
return _to_response(item, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
||||||
@@ -292,6 +303,7 @@ def update_voice(
|
|||||||
request: UpdateVoiceLibraryRequest,
|
request: UpdateVoiceLibraryRequest,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||||
|
sign_url=Depends(get_audio_url_signer),
|
||||||
) -> VoiceLibraryItemResponse:
|
) -> VoiceLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
command = UpdateVoiceLibraryCommand(
|
command = UpdateVoiceLibraryCommand(
|
||||||
@@ -313,7 +325,7 @@ def update_voice(
|
|||||||
item = use_case.execute(command)
|
item = use_case.execute(command)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||||
return _to_response(item)
|
return _to_response(item, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||||
|
|||||||
Executable
+221
@@ -0,0 +1,221 @@
|
|||||||
|
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
|
||||||
Executable → Regular
+19
@@ -207,6 +207,7 @@ def get_cosyvoice_service():
|
|||||||
能被 CosyVoice 服务器下载。
|
能被 CosyVoice 服务器下载。
|
||||||
"""
|
"""
|
||||||
from app.core.storage import get_storage_service
|
from app.core.storage import get_storage_service
|
||||||
|
|
||||||
from packages.application.cosyvoice_service import CosyVoiceService
|
from packages.application.cosyvoice_service import CosyVoiceService
|
||||||
|
|
||||||
storage = get_storage_service()
|
storage = get_storage_service()
|
||||||
@@ -216,3 +217,21 @@ def get_cosyvoice_service():
|
|||||||
return storage.get_download_url(url, expires_seconds=86400)
|
return storage.get_download_url(url, expires_seconds=86400)
|
||||||
|
|
||||||
return CosyVoiceService(audio_url_signer=_sign_audio_url)
|
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
|
||||||
|
|||||||
@@ -314,7 +314,9 @@ class UnifiedRenderService:
|
|||||||
# 有效时长 = min(指定时长, 实际时长);若均未设置则跳过
|
# 有效时长 = min(指定时长, 实际时长);若均未设置则跳过
|
||||||
effective_duration = 0.0
|
effective_duration = 0.0
|
||||||
if clip.duration > 0:
|
if clip.duration > 0:
|
||||||
effective_duration = min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
effective_duration = (
|
||||||
|
min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||||
|
)
|
||||||
elif clip.actual_duration > 0:
|
elif clip.actual_duration > 0:
|
||||||
effective_duration = clip.actual_duration
|
effective_duration = clip.actual_duration
|
||||||
|
|
||||||
|
|||||||
@@ -778,14 +778,12 @@ def generate_video(self, task_id: str) -> dict:
|
|||||||
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
|
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
|
||||||
if not _verify_url_accessible(verify_url):
|
if not _verify_url_accessible(verify_url):
|
||||||
# 预签名 URL 也访问失败时,退一步用 object_exists 确认上传成功
|
# 预签名 URL 也访问失败时,退一步用 object_exists 确认上传成功
|
||||||
from video_processing.oss_helpers import oss_bucket, normalize_storage_key
|
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
|
||||||
|
|
||||||
bucket = oss_bucket()
|
bucket = oss_bucket()
|
||||||
key = normalize_storage_key(file_url)
|
key = normalize_storage_key(file_url)
|
||||||
if bucket and bucket.object_exists(key):
|
if bucket and bucket.object_exists(key):
|
||||||
logger.info(
|
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
|
||||||
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key
|
|
||||||
)
|
|
||||||
if gen_task:
|
if gen_task:
|
||||||
gen_task.append_log("OSS上传", "URL校验降级: object_exists确认存在", level="WARN")
|
gen_task.append_log("OSS上传", "URL校验降级: object_exists确认存在", level="WARN")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
|
|
||||||
from celery import Task
|
from celery import Task
|
||||||
from celery.exceptions import Retry
|
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.celery_app import celery_app
|
||||||
from worker_app.db import SessionLocal
|
from worker_app.db import SessionLocal
|
||||||
|
|
||||||
@@ -16,7 +17,6 @@ from packages.application.cosyvoice_service import (
|
|||||||
CosyVoiceTimeoutError,
|
CosyVoiceTimeoutError,
|
||||||
)
|
)
|
||||||
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
|
from packages.application.voice_clone.workflow import VoiceCloneWorkflowService
|
||||||
from video_processing.oss_helpers import get_signed_download_url
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+17
@@ -91,6 +91,23 @@ class SQLAlchemyGenerationTaskRepository:
|
|||||||
def count_by_user(self, user_id: str) -> int:
|
def count_by_user(self, user_id: str) -> int:
|
||||||
return self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id).count()
|
return self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id).count()
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int:
|
||||||
|
return (
|
||||||
|
self.session.query(GenerationTaskModel)
|
||||||
|
.filter(
|
||||||
|
GenerationTaskModel.created_by_user_id == user_id,
|
||||||
|
GenerationTaskModel.status == GenerationTaskStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int:
|
||||||
|
return (
|
||||||
|
self.session.query(GenerationTaskModel)
|
||||||
|
.filter(GenerationTaskModel.status == GenerationTaskStatus.PENDING.value)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||||
models = (
|
models = (
|
||||||
self.session.query(GenerationTaskModel)
|
self.session.query(GenerationTaskModel)
|
||||||
|
|||||||
@@ -70,21 +70,21 @@ class CosyVoiceService:
|
|||||||
- 音色克隆: POST /services/audio/tts/customization (model=voice-enrollment)
|
- 音色克隆: POST /services/audio/tts/customization (model=voice-enrollment)
|
||||||
- action=create_voice: 创建克隆音色,返回 voice_id(状态 DEPLOYING)
|
- action=create_voice: 创建克隆音色,返回 voice_id(状态 DEPLOYING)
|
||||||
- action=query_voice: 查询音色状态(DEPLOYING / OK / UNDEPLOYED)
|
- action=query_voice: 查询音色状态(DEPLOYING / OK / UNDEPLOYED)
|
||||||
- 语音合成: POST /services/audio/tts/SpeechSynthesizer (model=cosyvoice-v3.5-plus)
|
- 语音合成: POST /services/audio/tts/SpeechSynthesizer (model=cosyvoice-v3-flash)
|
||||||
- 非流式: 同步返回音频 URL
|
- 非流式: 同步返回音频 URL
|
||||||
|
|
||||||
使用示例:
|
使用示例:
|
||||||
service = CosyVoiceService(
|
service = CosyVoiceService(
|
||||||
api_key="your-api-key",
|
api_key="your-api-key",
|
||||||
base_url="https://dashscope.aliyuncs.com/api/v1",
|
base_url="https://dashscope.aliyuncs.com/api/v1",
|
||||||
model="cosyvoice-v3.5-plus",
|
model="cosyvoice-v3-flash",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 音色克隆
|
# 音色克隆
|
||||||
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
|
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
|
||||||
|
|
||||||
# 语音合成
|
# 语音合成
|
||||||
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun")
|
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 音色状态轮询配置
|
# 音色状态轮询配置
|
||||||
@@ -121,16 +121,49 @@ class CosyVoiceService:
|
|||||||
self._api_key = api_key or settings.cosyvoice_api_key
|
self._api_key = api_key or settings.cosyvoice_api_key
|
||||||
self._base_url = base_url or settings.cosyvoice_base_url
|
self._base_url = base_url or settings.cosyvoice_base_url
|
||||||
self._model = model or settings.cosyvoice_model
|
self._model = model or settings.cosyvoice_model
|
||||||
self._clone_model = clone_model or getattr(
|
self._clone_model = clone_model or getattr(settings, "cosyvoice_clone_model", "voice-enrollment")
|
||||||
settings, "cosyvoice_clone_model", "voice-enrollment"
|
|
||||||
)
|
|
||||||
self._audio_url_signer = audio_url_signer
|
self._audio_url_signer = audio_url_signer
|
||||||
|
|
||||||
|
# base_url 规范化:去掉末尾的路径残留(兼容旧版配置)
|
||||||
|
# 旧版 .env 模板中 base_url 包含 /services/aigc/text2audio 完整路径,
|
||||||
|
# 新版只需 /api/v1,具体路径由代码拼接。这里自动修正,避免配置滞后导致418。
|
||||||
|
if "/services/aigc/text2audio" in self._base_url:
|
||||||
|
old_url = self._base_url
|
||||||
|
# 截取到 /api/v1 为止
|
||||||
|
idx = self._base_url.find("/api/v1")
|
||||||
|
if idx >= 0:
|
||||||
|
self._base_url = self._base_url[: idx + len("/api/v1")]
|
||||||
|
logger.warning(
|
||||||
|
"[CosyVoice Config] base_url包含旧版text2audio路径,已自动修正: " "%s -> %s",
|
||||||
|
old_url,
|
||||||
|
self._base_url,
|
||||||
|
)
|
||||||
|
|
||||||
self._client = http_client or httpx.Client(
|
self._client = http_client or httpx.Client(
|
||||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||||
)
|
)
|
||||||
self._owns_client = http_client is None
|
self._owns_client = http_client is None
|
||||||
|
|
||||||
|
# 启动时打印配置(脱敏),方便排查环境变量覆盖问题
|
||||||
|
if self._owns_client:
|
||||||
|
masked_key = ""
|
||||||
|
if self._api_key:
|
||||||
|
if len(self._api_key) > 8:
|
||||||
|
masked_key = f"{self._api_key[:4]}...{self._api_key[-4:]}"
|
||||||
|
else:
|
||||||
|
masked_key = "***"
|
||||||
|
logger.info(
|
||||||
|
"[CosyVoice Config] 初始化配置: "
|
||||||
|
"model=%s, base_url=%s, default_voice=%s, "
|
||||||
|
"sample_rate=%d, format=%s, api_key=%s",
|
||||||
|
self._model,
|
||||||
|
self._base_url,
|
||||||
|
getattr(settings, "cosyvoice_voice", "(unset)"),
|
||||||
|
settings.cosyvoice_sample_rate,
|
||||||
|
settings.cosyvoice_format,
|
||||||
|
masked_key or "(empty)",
|
||||||
|
)
|
||||||
|
|
||||||
def __enter__(self) -> CosyVoiceService:
|
def __enter__(self) -> CosyVoiceService:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -201,8 +234,7 @@ class CosyVoiceService:
|
|||||||
if self._audio_url_signer:
|
if self._audio_url_signer:
|
||||||
try:
|
try:
|
||||||
signed_audio_url = self._audio_url_signer(audio_url)
|
signed_audio_url = self._audio_url_signer(audio_url)
|
||||||
logger.info("音频URL已预签名: original=%s signed_prefix=%s",
|
logger.info("音频URL已预签名: original=%s signed_prefix=%s", audio_url[:80], signed_audio_url[:80])
|
||||||
audio_url[:80], signed_audio_url[:80])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("音频URL预签名失败,使用原始URL: %s", e)
|
logger.warning("音频URL预签名失败,使用原始URL: %s", e)
|
||||||
|
|
||||||
@@ -323,9 +355,7 @@ class CosyVoiceService:
|
|||||||
while attempts < self.CLONE_MAX_POLL_ATTEMPTS:
|
while attempts < self.CLONE_MAX_POLL_ATTEMPTS:
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
if elapsed > timeout:
|
if elapsed > timeout:
|
||||||
raise CosyVoiceTimeoutError(
|
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): voice_id={voice_id}")
|
||||||
f"音色克隆任务超时({timeout}秒): voice_id={voice_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self.query_voice_status(voice_id)
|
result = self.query_voice_status(voice_id)
|
||||||
status = result.get("status", "").upper()
|
status = result.get("status", "").upper()
|
||||||
@@ -333,9 +363,7 @@ class CosyVoiceService:
|
|||||||
if status == "OK":
|
if status == "OK":
|
||||||
return {"voice_id": voice_id}
|
return {"voice_id": voice_id}
|
||||||
elif status == "UNDEPLOYED":
|
elif status == "UNDEPLOYED":
|
||||||
raise CosyVoiceError(
|
raise CosyVoiceError(f"音色克隆任务失败(审核未通过): voice_id={voice_id}")
|
||||||
f"音色克隆任务失败(审核未通过): voice_id={voice_id}"
|
|
||||||
)
|
|
||||||
elif status in ("DEPLOYING", "PENDING", "PROCESSING", ""):
|
elif status in ("DEPLOYING", "PENDING", "PROCESSING", ""):
|
||||||
# 继续轮询
|
# 继续轮询
|
||||||
time.sleep(self.CLONE_POLL_INTERVAL)
|
time.sleep(self.CLONE_POLL_INTERVAL)
|
||||||
@@ -345,9 +373,7 @@ class CosyVoiceService:
|
|||||||
time.sleep(self.CLONE_POLL_INTERVAL)
|
time.sleep(self.CLONE_POLL_INTERVAL)
|
||||||
attempts += 1
|
attempts += 1
|
||||||
|
|
||||||
raise CosyVoiceTimeoutError(
|
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: voice_id={voice_id}")
|
||||||
f"音色克隆任务轮询次数超限: voice_id={voice_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def clone_voice(
|
def clone_voice(
|
||||||
self,
|
self,
|
||||||
@@ -462,9 +488,7 @@ class CosyVoiceService:
|
|||||||
request_id = response.get("request_id", "")
|
request_id = response.get("request_id", "")
|
||||||
|
|
||||||
if not audio_url:
|
if not audio_url:
|
||||||
raise CosyVoiceError(
|
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
||||||
f"CosyVoice API 未返回 audio_url: {response}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
||||||
@@ -474,9 +498,7 @@ class CosyVoiceService:
|
|||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def poll_synthesize_task(
|
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||||||
self, task_id: str, timeout: float = 120.0
|
|
||||||
) -> dict:
|
|
||||||
"""轮询合成任务(同步接口无需轮询,保留兼容).
|
"""轮询合成任务(同步接口无需轮询,保留兼容).
|
||||||
|
|
||||||
CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
||||||
@@ -485,10 +507,7 @@ class CosyVoiceService:
|
|||||||
Raises:
|
Raises:
|
||||||
CosyVoiceError: 同步接口无需轮询
|
CosyVoiceError: 同步接口无需轮询
|
||||||
"""
|
"""
|
||||||
raise CosyVoiceError(
|
raise CosyVoiceError("CosyVoice 非流式合成接口是同步的,无需轮询. " "请直接使用 submit_synthesize_task().")
|
||||||
"CosyVoice 非流式合成接口是同步的,无需轮询. "
|
|
||||||
"请直接使用 submit_synthesize_task()."
|
|
||||||
)
|
|
||||||
|
|
||||||
def synthesize_speech(
|
def synthesize_speech(
|
||||||
self,
|
self,
|
||||||
@@ -589,6 +608,22 @@ class CosyVoiceService:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# DEBUG: 打印完整请求信息,用于排查418错误
|
||||||
|
import json as json_lib
|
||||||
|
|
||||||
|
safe_headers = {k: v for k, v in headers.items()}
|
||||||
|
if "Authorization" in safe_headers:
|
||||||
|
token = safe_headers["Authorization"]
|
||||||
|
if len(token) > 20:
|
||||||
|
safe_headers["Authorization"] = token[:13] + "..." + token[-4:]
|
||||||
|
logger.info(
|
||||||
|
"[CosyVoice Debug] 请求详情: " "method=%s, url=%s, headers=%s, body=%s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
safe_headers,
|
||||||
|
json_lib.dumps(json, ensure_ascii=False) if json else "None",
|
||||||
|
)
|
||||||
|
|
||||||
last_error: Optional[Exception] = None
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
for attempt in range(self.MAX_RETRIES):
|
for attempt in range(self.MAX_RETRIES):
|
||||||
@@ -601,13 +636,18 @@ class CosyVoiceService:
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# DEBUG: 打印响应状态和完整响应体
|
||||||
|
logger.info(
|
||||||
|
"[CosyVoice Debug] 响应详情: " "status=%d, body=%s",
|
||||||
|
response.status_code,
|
||||||
|
response.text[:2000], # 最多2000字符,避免日志过大
|
||||||
|
)
|
||||||
|
|
||||||
# 处理响应
|
# 处理响应
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return response.json()
|
return response.json()
|
||||||
elif response.status_code in (401, 403):
|
elif response.status_code in (401, 403):
|
||||||
raise CosyVoiceAuthError(
|
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
|
||||||
f"CosyVoice API 认证失败: HTTP {response.status_code}"
|
|
||||||
)
|
|
||||||
elif response.status_code == 400:
|
elif response.status_code == 400:
|
||||||
# 客户端错误,不重试
|
# 客户端错误,不重试
|
||||||
body_text = response.text
|
body_text = response.text
|
||||||
@@ -615,19 +655,12 @@ class CosyVoiceService:
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
code = body.get("code", "")
|
code = body.get("code", "")
|
||||||
message = body.get("message", "")
|
message = body.get("message", "")
|
||||||
raise CosyVoiceError(
|
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
|
||||||
f"CosyVoice API 参数错误: HTTP 400, "
|
|
||||||
f"code={code}, message={message}"
|
|
||||||
)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise CosyVoiceError(
|
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
|
||||||
f"CosyVoice API 调用失败: HTTP 400, body={body_text}"
|
|
||||||
)
|
|
||||||
elif response.status_code >= 500:
|
elif response.status_code >= 500:
|
||||||
# 服务端错误,可重试
|
# 服务端错误,可重试
|
||||||
last_error = CosyVoiceError(
|
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
|
||||||
f"CosyVoice API 服务端错误: HTTP {response.status_code}"
|
|
||||||
)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"CosyVoice API 失败 (尝试 %d/%d): HTTP %d",
|
"CosyVoice API 失败 (尝试 %d/%d): HTTP %d",
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
@@ -637,8 +670,7 @@ class CosyVoiceService:
|
|||||||
else:
|
else:
|
||||||
# 其他客户端错误,不重试
|
# 其他客户端错误,不重试
|
||||||
raise CosyVoiceError(
|
raise CosyVoiceError(
|
||||||
f"CosyVoice API 调用失败: HTTP {response.status_code}, "
|
f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}"
|
||||||
f"body={response.text}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
|
|||||||
@@ -219,9 +219,7 @@ class TTSWorkflowService:
|
|||||||
|
|
||||||
# 新接口(同步):没有 task_id,重新合成
|
# 新接口(同步):没有 task_id,重新合成
|
||||||
if not task_id:
|
if not task_id:
|
||||||
logger.info(
|
logger.info(f"TTS 任务无 task_id,重新同步合成: job_id={job_id}")
|
||||||
f"TTS 任务无 task_id,重新同步合成: job_id={job_id}"
|
|
||||||
)
|
|
||||||
return self._resynthesize_and_complete(job)
|
return self._resynthesize_and_complete(job)
|
||||||
|
|
||||||
# 旧接口遗留的 task_id,尝试轮询(兼容过渡)
|
# 旧接口遗留的 task_id,尝试轮询(兼容过渡)
|
||||||
@@ -235,9 +233,7 @@ class TTSWorkflowService:
|
|||||||
)
|
)
|
||||||
except CosyVoiceError:
|
except CosyVoiceError:
|
||||||
# 旧接口轮询失败,重新同步合成
|
# 旧接口轮询失败,重新同步合成
|
||||||
logger.warning(
|
logger.warning(f"旧 task_id 轮询失败,重新同步合成: job_id={job_id}, task_id={task_id}")
|
||||||
f"旧 task_id 轮询失败,重新同步合成: job_id={job_id}, task_id={task_id}"
|
|
||||||
)
|
|
||||||
return self._resynthesize_and_complete(job)
|
return self._resynthesize_and_complete(job)
|
||||||
|
|
||||||
def process_synthesis_result(
|
def process_synthesis_result(
|
||||||
@@ -524,10 +520,7 @@ class TTSWorkflowService:
|
|||||||
missing_indices = [i for i in range(segment_count) if results[i] is None]
|
missing_indices = [i for i in range(segment_count) if results[i] is None]
|
||||||
|
|
||||||
if missing_indices:
|
if missing_indices:
|
||||||
logger.info(
|
logger.info(f"分段任务重新合成缺失段: job_id={job.id}, " f"缺失={len(missing_indices)}/{segment_count}")
|
||||||
f"分段任务重新合成缺失段: job_id={job.id}, "
|
|
||||||
f"缺失={len(missing_indices)}/{segment_count}"
|
|
||||||
)
|
|
||||||
# 并发重新合成缺失分段
|
# 并发重新合成缺失分段
|
||||||
max_workers = min(len(missing_indices), _MAX_SEGMENT_WORKERS)
|
max_workers = min(len(missing_indices), _MAX_SEGMENT_WORKERS)
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
@@ -550,13 +543,8 @@ class TTSWorkflowService:
|
|||||||
try:
|
try:
|
||||||
results[idx] = future.result()
|
results[idx] = future.result()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"分段重新合成失败: job_id={job.id}, " f"segment={idx}, error={e}")
|
||||||
f"分段重新合成失败: job_id={job.id}, "
|
self._handle_segment_failure(job, f"分段 {idx + 1} 重新合成失败: {e}")
|
||||||
f"segment={idx}, error={e}"
|
|
||||||
)
|
|
||||||
self._handle_segment_failure(
|
|
||||||
job, f"分段 {idx + 1} 重新合成失败: {e}"
|
|
||||||
)
|
|
||||||
return self.repository.get(job.id)
|
return self.repository.get(job.id)
|
||||||
|
|
||||||
# 所有分段完成,下载合并
|
# 所有分段完成,下载合并
|
||||||
@@ -564,9 +552,7 @@ class TTSWorkflowService:
|
|||||||
try:
|
try:
|
||||||
merged_data, total_duration = self._download_and_merge_segments(results, job)
|
merged_data, total_duration = self._download_and_merge_segments(results, job)
|
||||||
|
|
||||||
permanent_url, storage_key = self._upload_merged_to_oss(
|
permanent_url, storage_key = self._upload_merged_to_oss(merged_data, job.user_id, job.id, job.format)
|
||||||
merged_data, job.user_id, job.id, job.format
|
|
||||||
)
|
|
||||||
|
|
||||||
job.mark_completed(
|
job.mark_completed(
|
||||||
output_audio_url=permanent_url,
|
output_audio_url=permanent_url,
|
||||||
@@ -575,10 +561,7 @@ class TTSWorkflowService:
|
|||||||
file_size=len(merged_data),
|
file_size=len(merged_data),
|
||||||
)
|
)
|
||||||
job = self.repository.update(job)
|
job = self.repository.update(job)
|
||||||
logger.info(
|
logger.info(f"分段合成完成(重新合成路径): job_id={job.id}, " f"merged_size={len(merged_data)}")
|
||||||
f"分段合成完成(重新合成路径): job_id={job.id}, "
|
|
||||||
f"merged_size={len(merged_data)}"
|
|
||||||
)
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ class VoiceCloneWorkflowService:
|
|||||||
|
|
||||||
profile.metadata = task_metadata
|
profile.metadata = task_metadata
|
||||||
profile = self.repository.update(profile)
|
profile = self.repository.update(profile)
|
||||||
logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"voice_id={submit_result.get('voice_id')}")
|
logger.info(
|
||||||
|
f"音色克隆任务已提交: profile_id={profile.id}, " f"voice_id={submit_result.get('voice_id')}"
|
||||||
|
)
|
||||||
|
|
||||||
except (CosyVoiceError, CosyVoiceAuthError) as e:
|
except (CosyVoiceError, CosyVoiceAuthError) as e:
|
||||||
# CosyVoice 提交失败,标记为 failed
|
# CosyVoice 提交失败,标记为 failed
|
||||||
|
|||||||
Regular → Executable
+9
-9
@@ -16,7 +16,7 @@ class PresetVoice:
|
|||||||
"""预置音色定义。
|
"""预置音色定义。
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
voice_id: CosyVoice 模型音色名(如 longxiaochun)
|
voice_id: CosyVoice 模型音色名(如 longxiaochun_v3)
|
||||||
name: 中文展示名
|
name: 中文展示名
|
||||||
description: 音色描述
|
description: 音色描述
|
||||||
gender: 性别(male/female)
|
gender: 性别(male/female)
|
||||||
@@ -49,7 +49,7 @@ class PresetVoice:
|
|||||||
# 预置音色列表(阿里云 CosyVoice 真实可用音色)
|
# 预置音色列表(阿里云 CosyVoice 真实可用音色)
|
||||||
PRESET_VOICES: list[PresetVoice] = [
|
PRESET_VOICES: list[PresetVoice] = [
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longxiaochun",
|
voice_id="longxiaochun_v3",
|
||||||
name="龙小淳",
|
name="龙小淳",
|
||||||
description="温柔女声,适合情感类内容",
|
description="温柔女声,适合情感类内容",
|
||||||
gender="female",
|
gender="female",
|
||||||
@@ -57,7 +57,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["温柔", "女声", "情感"],
|
tags=["温柔", "女声", "情感"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longxiaoxia",
|
voice_id="longxiaoxia_v3",
|
||||||
name="龙小夏",
|
name="龙小夏",
|
||||||
description="知性女声,适合新闻播报",
|
description="知性女声,适合新闻播报",
|
||||||
gender="female",
|
gender="female",
|
||||||
@@ -65,7 +65,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["知性", "女声", "播报"],
|
tags=["知性", "女声", "播报"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longxiaochen",
|
voice_id="longxiaochen_v3",
|
||||||
name="龙小晨",
|
name="龙小晨",
|
||||||
description="磁性男声,适合有声书",
|
description="磁性男声,适合有声书",
|
||||||
gender="male",
|
gender="male",
|
||||||
@@ -73,7 +73,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["磁性", "男声", "有声书"],
|
tags=["磁性", "男声", "有声书"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longyue",
|
voice_id="longyue_v3",
|
||||||
name="龙悦",
|
name="龙悦",
|
||||||
description="甜美女声,适合广告配音",
|
description="甜美女声,适合广告配音",
|
||||||
gender="female",
|
gender="female",
|
||||||
@@ -81,7 +81,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["甜美", "女声", "广告"],
|
tags=["甜美", "女声", "广告"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longshu",
|
voice_id="longshu_v3",
|
||||||
name="龙书",
|
name="龙书",
|
||||||
description="沉稳男声,适合教育讲解",
|
description="沉稳男声,适合教育讲解",
|
||||||
gender="male",
|
gender="male",
|
||||||
@@ -89,7 +89,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["沉稳", "男声", "教育"],
|
tags=["沉稳", "男声", "教育"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longjing",
|
voice_id="longjing_v3",
|
||||||
name="龙静",
|
name="龙静",
|
||||||
description="优雅女声,适合纪录片解说",
|
description="优雅女声,适合纪录片解说",
|
||||||
gender="female",
|
gender="female",
|
||||||
@@ -97,7 +97,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["优雅", "女声", "纪录片"],
|
tags=["优雅", "女声", "纪录片"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longbo",
|
voice_id="longbo_v3",
|
||||||
name="龙博",
|
name="龙博",
|
||||||
description="浑厚男声,适合科技类内容",
|
description="浑厚男声,适合科技类内容",
|
||||||
gender="male",
|
gender="male",
|
||||||
@@ -105,7 +105,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
|||||||
tags=["浑厚", "男声", "科技"],
|
tags=["浑厚", "男声", "科技"],
|
||||||
),
|
),
|
||||||
PresetVoice(
|
PresetVoice(
|
||||||
voice_id="longtian",
|
voice_id="longtian_v3",
|
||||||
name="龙甜",
|
name="龙甜",
|
||||||
description="活泼女声,适合短视频配音",
|
description="活泼女声,适合短视频配音",
|
||||||
gender="female",
|
gender="female",
|
||||||
|
|||||||
Regular → Executable
+4
@@ -16,6 +16,10 @@ class GenerationTaskRepository(Protocol):
|
|||||||
|
|
||||||
def count_by_user(self, user_id: str) -> int: ...
|
def count_by_user(self, user_id: str) -> int: ...
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int: ...
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int: ...
|
||||||
|
|
||||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
|
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
|
||||||
|
|
||||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ...
|
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ...
|
||||||
|
|||||||
Regular → Executable
+2
-2
@@ -32,8 +32,8 @@ class SharedSettings(BaseSettings):
|
|||||||
# CosyVoice (阿里云百炼语音合成)
|
# CosyVoice (阿里云百炼语音合成)
|
||||||
cosyvoice_api_key: str = ""
|
cosyvoice_api_key: str = ""
|
||||||
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
|
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
|
||||||
cosyvoice_model: str = "cosyvoice-v3.5-plus"
|
cosyvoice_model: str = "cosyvoice-v3-flash"
|
||||||
cosyvoice_voice: str = "longxiaochun" # 默认音色
|
cosyvoice_voice: str = "longxiaochun_v3" # 默认音色(v3 系列系统音色带 _v3 后缀)
|
||||||
cosyvoice_sample_rate: int = 22050
|
cosyvoice_sample_rate: int = 22050
|
||||||
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
|
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
|
||||||
# 音色克隆模型名(固定为 voice-enrollment)
|
# 音色克隆模型名(固定为 voice-enrollment)
|
||||||
|
|||||||
Regular → Executable
+63
@@ -1,7 +1,70 @@
|
|||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
target-version = ["py312"]
|
target-version = ["py312"]
|
||||||
|
extend-exclude = '''
|
||||||
|
(
|
||||||
|
\.git
|
||||||
|
| \.cache
|
||||||
|
| \.pytest_cache
|
||||||
|
| \.mypy_cache
|
||||||
|
| __pycache__
|
||||||
|
| node_modules
|
||||||
|
| \.venv
|
||||||
|
| venv
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
| \.next
|
||||||
|
| out
|
||||||
|
| coverage
|
||||||
|
)
|
||||||
|
'''
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = "black"
|
profile = "black"
|
||||||
line_length = 120
|
line_length = 120
|
||||||
|
extend_skip_glob = [
|
||||||
|
".git/**",
|
||||||
|
".cache/**",
|
||||||
|
".pytest_cache/**",
|
||||||
|
".mypy_cache/**",
|
||||||
|
"__pycache__/**",
|
||||||
|
"node_modules/**",
|
||||||
|
".venv/**",
|
||||||
|
"venv/**",
|
||||||
|
"build/**",
|
||||||
|
"dist/**",
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"coverage/**",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["apps/api/app", "packages"]
|
||||||
|
omit = [
|
||||||
|
"*/migrations/*",
|
||||||
|
"*/tests/*",
|
||||||
|
"*/test_*.py",
|
||||||
|
"*/site-packages/*",
|
||||||
|
]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"pass",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"class .*Protocol",
|
||||||
|
"@abstractmethod",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise RuntimeError",
|
||||||
|
"if 0:",
|
||||||
|
"if __debug__:",
|
||||||
|
]
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
|
||||||
|
[tool.coverage.xml]
|
||||||
|
output = "coverage.xml"
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""解析 coverage.xml 并输出覆盖率汇总。"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
THRESHOLD = int(os.environ.get("COVERAGE_THRESHOLD", 65)) # 行覆盖率门槛,百分比,可通过环境变量覆盖
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
tree = ET.parse("coverage.xml")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("coverage.xml 不存在,跳过汇总")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
root = tree.getroot()
|
||||||
|
line_rate = float(root.get("line-rate", 0)) * 100
|
||||||
|
branch_rate = float(root.get("branch-rate", 0)) * 100
|
||||||
|
lines_covered = int(root.get("lines-covered", 0))
|
||||||
|
lines_valid = int(root.get("lines-valid", 0))
|
||||||
|
|
||||||
|
print(f"行覆盖率: {line_rate:.2f}% ({lines_covered}/{lines_valid})")
|
||||||
|
print(f"分支覆盖率: {branch_rate:.2f}%")
|
||||||
|
print(f"门槛: {THRESHOLD}%")
|
||||||
|
status = "PASS ✅" if line_rate >= THRESHOLD else "FAIL ❌"
|
||||||
|
print(f"状态: {status}")
|
||||||
|
|
||||||
|
return 0 if line_rate >= THRESHOLD else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""发送 CI 失败通知到飞书/项目群 webhook。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "")
|
||||||
|
if not webhook:
|
||||||
|
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
|
||||||
|
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
failed_job = os.environ.get("FAILED_JOB", "Unknown Job")
|
||||||
|
branch = os.environ.get("GITHUB_REF_NAME", "unknown")
|
||||||
|
commit = os.environ.get("GITHUB_SHA", "unknown")[:8]
|
||||||
|
actor = os.environ.get("GITHUB_ACTOR", "unknown")
|
||||||
|
run_id = os.environ.get("GITHUB_RUN_ID", "unknown")
|
||||||
|
repo = os.environ.get("GITHUB_REPOSITORY", "unknown")
|
||||||
|
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": {
|
||||||
|
"header": {
|
||||||
|
"title": {
|
||||||
|
"tag": "plain_text",
|
||||||
|
"content": "❌ CI 构建失败",
|
||||||
|
},
|
||||||
|
"status": "red",
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "div",
|
||||||
|
"text": {
|
||||||
|
"tag": "lark_md",
|
||||||
|
"content": (
|
||||||
|
f"**任务**: {failed_job}\n"
|
||||||
|
f"**分支**: {branch}\n"
|
||||||
|
f"**提交**: {commit}\n"
|
||||||
|
f"**提交者**: {actor}\n"
|
||||||
|
f"**Run ID**: {run_id}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "action",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": "查看失败日志"},
|
||||||
|
"url": run_url,
|
||||||
|
"type": "danger",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
webhook,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
resp.read()
|
||||||
|
print("通知已发送")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"通知发送失败: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -3,6 +3,7 @@ max-line-length = 120
|
|||||||
extend-ignore = E203,W503,E501,E302,E402,E722,W291,W293,F401,F403,F405,F841
|
extend-ignore = E203,W503,E501,E302,E402,E722,W291,W293,F401,F403,F405,F841
|
||||||
exclude =
|
exclude =
|
||||||
.git,
|
.git,
|
||||||
|
.cache,
|
||||||
__pycache__,
|
__pycache__,
|
||||||
.venv,
|
.venv,
|
||||||
.venv-ci-root,
|
.venv-ci-root,
|
||||||
|
|||||||
Regular → Executable
+22
-10
@@ -118,6 +118,18 @@ class StubGenerationTaskRepository:
|
|||||||
def count_by_user(self, user_id: str) -> int:
|
def count_by_user(self, user_id: str) -> int:
|
||||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int:
|
||||||
|
return len(
|
||||||
|
[
|
||||||
|
t
|
||||||
|
for t in self._tasks.values()
|
||||||
|
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int:
|
||||||
|
return len([t for t in self._tasks.values() if t.status == GenerationTaskStatus.PENDING])
|
||||||
|
|
||||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||||
@@ -241,7 +253,7 @@ def client():
|
|||||||
class TestCreateGenerationTask:
|
class TestCreateGenerationTask:
|
||||||
"""创建生成任务端点测试。"""
|
"""创建生成任务端点测试。"""
|
||||||
|
|
||||||
@patch("app.api.routes.generation_tasks.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_create_task_success(self, mock_celery, client):
|
def test_create_task_success(self, mock_celery, client):
|
||||||
"""正常创建生成任务成功。"""
|
"""正常创建生成任务成功。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
@@ -270,7 +282,7 @@ class TestCreateGenerationTask:
|
|||||||
assert mock_celery.send_task.called
|
assert mock_celery.send_task.called
|
||||||
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
||||||
|
|
||||||
@patch("app.api.routes.generation_tasks.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_create_batch_tasks(self, mock_celery, client):
|
def test_create_batch_tasks(self, mock_celery, client):
|
||||||
"""批量创建多个生成任务。"""
|
"""批量创建多个生成任务。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
@@ -347,7 +359,7 @@ class TestListGenerationTasks:
|
|||||||
|
|
||||||
def _create_task(self, client, task_suffix: str = "1"):
|
def _create_task(self, client, task_suffix: str = "1"):
|
||||||
"""辅助方法:创建一个生成任务。"""
|
"""辅助方法:创建一个生成任务。"""
|
||||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/v1/generation/tasks",
|
"/api/v1/generation/tasks",
|
||||||
@@ -368,7 +380,7 @@ class TestListGenerationTasks:
|
|||||||
assert "items" in data
|
assert "items" in data
|
||||||
assert data["items"] == []
|
assert data["items"] == []
|
||||||
|
|
||||||
@patch("app.api.routes.generation_tasks.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_list_returns_user_tasks(self, mock_celery, client):
|
def test_list_returns_user_tasks(self, mock_celery, client):
|
||||||
"""返回当前用户的生成任务列表。"""
|
"""返回当前用户的生成任务列表。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
@@ -406,7 +418,7 @@ class TestGetGenerationTask:
|
|||||||
"""获取生成任务详情端点测试。"""
|
"""获取生成任务详情端点测试。"""
|
||||||
|
|
||||||
def _create_task(self, client) -> str:
|
def _create_task(self, client) -> str:
|
||||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/v1/generation/tasks",
|
"/api/v1/generation/tasks",
|
||||||
@@ -449,7 +461,7 @@ class TestListGenerationResults:
|
|||||||
"""列出生成结果端点测试。"""
|
"""列出生成结果端点测试。"""
|
||||||
|
|
||||||
def _create_task(self, client) -> str:
|
def _create_task(self, client) -> str:
|
||||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/v1/generation/tasks",
|
"/api/v1/generation/tasks",
|
||||||
@@ -489,7 +501,7 @@ class TestRetryGenerationTask:
|
|||||||
|
|
||||||
def _create_failed_task(self, client) -> str:
|
def _create_failed_task(self, client) -> str:
|
||||||
"""创建一个失败状态的任务。"""
|
"""创建一个失败状态的任务。"""
|
||||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
with patch("app.core.task_enqueue.celery_app") as mock_celery:
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/v1/generation/tasks",
|
"/api/v1/generation/tasks",
|
||||||
@@ -509,7 +521,7 @@ class TestRetryGenerationTask:
|
|||||||
# 让我们直接通过 retry 测试来验证
|
# 让我们直接通过 retry 测试来验证
|
||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
@patch("app.api.routes.generation_tasks.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_retry_failed_task(self, mock_celery, client):
|
def test_retry_failed_task(self, mock_celery, client):
|
||||||
"""重试失败的任务成功。"""
|
"""重试失败的任务成功。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
@@ -539,7 +551,7 @@ class TestRetryGenerationTask:
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
assert "not found" in resp.json()["detail"].lower()
|
assert "not found" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
@patch("app.api.routes.generation_tasks.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
||||||
"""重试已完成的任务返回 409。"""
|
"""重试已完成的任务返回 409。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
@@ -568,7 +580,7 @@ class TestRetryGenerationTask:
|
|||||||
class TestGenerationTaskFlow:
|
class TestGenerationTaskFlow:
|
||||||
"""生成任务完整流程集成测试。"""
|
"""生成任务完整流程集成测试。"""
|
||||||
|
|
||||||
@patch("app.api.routes.generation_tasks.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_create_list_detail_results_flow(self, mock_celery, client):
|
def test_create_list_detail_results_flow(self, mock_celery, client):
|
||||||
"""测试创建 → 列表 → 详情 → 结果 完整流程。"""
|
"""测试创建 → 列表 → 详情 → 结果 完整流程。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
|
|||||||
Regular → Executable
+14
-2
@@ -83,6 +83,18 @@ class StubGenerationTaskRepository:
|
|||||||
def count_by_user(self, user_id: str) -> int:
|
def count_by_user(self, user_id: str) -> int:
|
||||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int:
|
||||||
|
return len(
|
||||||
|
[
|
||||||
|
t
|
||||||
|
for t in self._tasks.values()
|
||||||
|
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int:
|
||||||
|
return len([t for t in self._tasks.values() if t.status == GenerationTaskStatus.PENDING])
|
||||||
|
|
||||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||||
@@ -428,7 +440,7 @@ class TestRetryProjectTask:
|
|||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "Unsupported" in resp.json()["detail"]
|
assert "Unsupported" in resp.json()["detail"]
|
||||||
|
|
||||||
@patch("app.api.routes.task_center.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_retry_failed_generation_task(self, mock_celery, client):
|
def test_retry_failed_generation_task(self, mock_celery, client):
|
||||||
"""重试失败的 generation 任务成功。"""
|
"""重试失败的 generation 任务成功。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
@@ -581,7 +593,7 @@ class TestRetryProjectTask:
|
|||||||
class TestTaskCenterCrossEndpoint:
|
class TestTaskCenterCrossEndpoint:
|
||||||
"""任务中心跨端点集成测试。"""
|
"""任务中心跨端点集成测试。"""
|
||||||
|
|
||||||
@patch("app.api.routes.task_center.celery_app")
|
@patch("app.core.task_enqueue.celery_app")
|
||||||
def test_list_then_retry_then_list(self, mock_celery, client):
|
def test_list_then_retry_then_list(self, mock_celery, client):
|
||||||
"""列出任务 → 重试失败任务 → 再列出验证新任务。"""
|
"""列出任务 → 重试失败任务 → 再列出验证新任务。"""
|
||||||
mock_celery.send_task = MagicMock()
|
mock_celery.send_task = MagicMock()
|
||||||
|
|||||||
Regular → Executable
+18
-17
@@ -32,7 +32,11 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
|
|||||||
|
|
||||||
from app.api.routes.voice_clones import router
|
from app.api.routes.voice_clones import router
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
from app.dependencies import (
|
||||||
|
get_audio_url_signer,
|
||||||
|
get_cosyvoice_service,
|
||||||
|
get_voice_clone_profile_repository,
|
||||||
|
)
|
||||||
|
|
||||||
from packages.domain.entities import User
|
from packages.domain.entities import User
|
||||||
from packages.domain.voice_clone_profile import (
|
from packages.domain.voice_clone_profile import (
|
||||||
@@ -226,7 +230,7 @@ def clone_repo():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cosyvoice_service():
|
def cosyvoice_service():
|
||||||
return MockCosyVoiceService(async_mode=False) # 同步模式,简化测试
|
return MockCosyVoiceService(async_mode=True) # 异步模式,匹配真实 CosyVoice API 行为
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -241,6 +245,7 @@ def client(clone_repo, cosyvoice_service):
|
|||||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
|
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
|
||||||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||||||
|
test_app.dependency_overrides[get_audio_url_signer] = lambda: (lambda url: url)
|
||||||
|
|
||||||
yield TestClient(test_app)
|
yield TestClient(test_app)
|
||||||
|
|
||||||
@@ -256,7 +261,7 @@ class TestCreateVoiceClone:
|
|||||||
"""创建声音克隆端点测试。"""
|
"""创建声音克隆端点测试。"""
|
||||||
|
|
||||||
def test_create_with_source_audio(self, client, cosyvoice_service):
|
def test_create_with_source_audio(self, client, cosyvoice_service):
|
||||||
"""提供源音频时创建克隆,同步模式下直接 ready。"""
|
"""提供源音频时创建克隆,异步提交后状态为 processing。"""
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/voice-clones",
|
"/voice-clones",
|
||||||
json={
|
json={
|
||||||
@@ -277,9 +282,9 @@ class TestCreateVoiceClone:
|
|||||||
assert "id" in data
|
assert "id" in data
|
||||||
assert len(data["id"]) > 0
|
assert len(data["id"]) > 0
|
||||||
|
|
||||||
# 同步模式下应直接 ready
|
# 异步模式下提交后状态为 processing,voice_id 为空
|
||||||
assert data["status"] == "ready"
|
assert data["status"] == "processing"
|
||||||
assert data["voice_id"] == "mock-voice-789"
|
assert data["voice_id"] == ""
|
||||||
assert data["error_message"] == ""
|
assert data["error_message"] == ""
|
||||||
|
|
||||||
def test_create_without_source_audio(self, client):
|
def test_create_without_source_audio(self, client):
|
||||||
@@ -554,16 +559,15 @@ class TestRetryVoiceClone:
|
|||||||
"""重试克隆端点测试。"""
|
"""重试克隆端点测试。"""
|
||||||
|
|
||||||
def test_retry_failed_clone(self, client, clone_repo, cosyvoice_service):
|
def test_retry_failed_clone(self, client, clone_repo, cosyvoice_service):
|
||||||
"""重试失败的克隆应成功。"""
|
"""重试失败的克隆,重新提交后期望 processing。"""
|
||||||
cosyvoice_service.async_mode = False
|
|
||||||
p = _make_clone_profile("重试测试", status=VoiceCloneStatus.FAILED)
|
p = _make_clone_profile("重试测试", status=VoiceCloneStatus.FAILED)
|
||||||
clone_repo.create(p)
|
clone_repo.create(p)
|
||||||
|
|
||||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# 同步模式下重试后应变为 ready
|
# 异步模式下重试后状态为 processing,等待 CosyVoice 完成
|
||||||
assert data["status"] == "ready"
|
assert data["status"] == "processing"
|
||||||
assert data["retry_count"] >= 1
|
assert data["retry_count"] >= 1
|
||||||
|
|
||||||
def test_retry_nonexistent_returns_404(self, client):
|
def test_retry_nonexistent_returns_404(self, client):
|
||||||
@@ -590,7 +594,6 @@ class TestRetryVoiceClone:
|
|||||||
|
|
||||||
def test_retry_increments_retry_count(self, client, clone_repo, cosyvoice_service):
|
def test_retry_increments_retry_count(self, client, clone_repo, cosyvoice_service):
|
||||||
"""重试后重试次数增加。"""
|
"""重试后重试次数增加。"""
|
||||||
cosyvoice_service.async_mode = False
|
|
||||||
p = _make_clone_profile("重试计数", status=VoiceCloneStatus.FAILED)
|
p = _make_clone_profile("重试计数", status=VoiceCloneStatus.FAILED)
|
||||||
clone_repo.create(p)
|
clone_repo.create(p)
|
||||||
|
|
||||||
@@ -680,7 +683,7 @@ class TestVoiceCloneLifecycle:
|
|||||||
# 4. 状态
|
# 4. 状态
|
||||||
status_resp = client.get(f"/voice-clones/{clone_id}/status")
|
status_resp = client.get(f"/voice-clones/{clone_id}/status")
|
||||||
assert status_resp.status_code == 200
|
assert status_resp.status_code == 200
|
||||||
assert status_resp.json()["status"] == "ready"
|
assert status_resp.json()["status"] == "processing"
|
||||||
|
|
||||||
# 5. 删除
|
# 5. 删除
|
||||||
del_resp = client.delete(f"/voice-clones/{clone_id}")
|
del_resp = client.delete(f"/voice-clones/{clone_id}")
|
||||||
@@ -691,7 +694,7 @@ class TestVoiceCloneLifecycle:
|
|||||||
assert list_resp2.json()["total"] == 0
|
assert list_resp2.json()["total"] == 0
|
||||||
|
|
||||||
def test_failed_retry_flow(self, client, clone_repo, cosyvoice_service):
|
def test_failed_retry_flow(self, client, clone_repo, cosyvoice_service):
|
||||||
"""失败 → 重试 → 成功 流程。"""
|
"""失败 → 重试 → processing(等待异步完成) 流程。"""
|
||||||
# 创建一个失败的克隆
|
# 创建一个失败的克隆
|
||||||
p = _make_clone_profile("失败重试", status=VoiceCloneStatus.FAILED)
|
p = _make_clone_profile("失败重试", status=VoiceCloneStatus.FAILED)
|
||||||
clone_repo.create(p)
|
clone_repo.create(p)
|
||||||
@@ -701,15 +704,13 @@ class TestVoiceCloneLifecycle:
|
|||||||
assert status_resp.json()["status"] == "failed"
|
assert status_resp.json()["status"] == "failed"
|
||||||
|
|
||||||
# 重试
|
# 重试
|
||||||
cosyvoice_service.async_mode = False
|
|
||||||
retry_resp = client.post(f"/voice-clones/{p.id}/retry")
|
retry_resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||||
assert retry_resp.status_code == 200
|
assert retry_resp.status_code == 200
|
||||||
assert retry_resp.json()["status"] == "ready"
|
assert retry_resp.json()["status"] == "processing"
|
||||||
|
|
||||||
# 再次确认状态
|
# 再次确认状态
|
||||||
status_resp2 = client.get(f"/voice-clones/{p.id}/status")
|
status_resp2 = client.get(f"/voice-clones/{p.id}/status")
|
||||||
assert status_resp2.json()["status"] == "ready"
|
assert status_resp2.json()["status"] == "processing"
|
||||||
assert status_resp2.json()["voice_id"] != ""
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
"""测试音频URL预签名逻辑。
|
||||||
|
|
||||||
|
验证所有 API 返回的音频 URL 都会经过 OSS 预签名(24小时有效期),
|
||||||
|
确保私有 bucket 下的音频文件前端可正常访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioUrlSigner:
|
||||||
|
"""测试音频URL签名函数的行为。"""
|
||||||
|
|
||||||
|
def _make_signer(self, mock_storage):
|
||||||
|
"""构造一个签名函数(模拟 get_audio_url_signer 的逻辑)。"""
|
||||||
|
|
||||||
|
def sign_audio_url(url: str) -> str:
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
return mock_storage.get_download_url(url, expires_seconds=86400)
|
||||||
|
|
||||||
|
return sign_audio_url
|
||||||
|
|
||||||
|
def test_empty_url_returns_empty(self):
|
||||||
|
"""空URL直接返回,不调用签名。"""
|
||||||
|
mock_storage = MagicMock()
|
||||||
|
signer = self._make_signer(mock_storage)
|
||||||
|
|
||||||
|
result = signer("")
|
||||||
|
assert result == ""
|
||||||
|
mock_storage.get_download_url.assert_not_called()
|
||||||
|
|
||||||
|
def test_none_url_returns_none(self):
|
||||||
|
"""None URL直接返回(有些字段可能为None)。"""
|
||||||
|
mock_storage = MagicMock()
|
||||||
|
signer = self._make_signer(mock_storage)
|
||||||
|
|
||||||
|
result = signer(None) # type: ignore
|
||||||
|
assert result is None
|
||||||
|
mock_storage.get_download_url.assert_not_called()
|
||||||
|
|
||||||
|
def test_valid_url_gets_signed_24h(self):
|
||||||
|
"""有效URL会调用 storage.get_download_url,有效期24小时(86400秒)。"""
|
||||||
|
mock_storage = MagicMock()
|
||||||
|
mock_storage.get_download_url.return_value = (
|
||||||
|
"https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3?signature=xxx"
|
||||||
|
)
|
||||||
|
signer = self._make_signer(mock_storage)
|
||||||
|
|
||||||
|
result = signer("https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3")
|
||||||
|
|
||||||
|
assert "signature=xxx" in result
|
||||||
|
mock_storage.get_download_url.assert_called_once_with(
|
||||||
|
"https://bucket.oss-cn-hangzhou.aliyuncs.com/audio/test.mp3",
|
||||||
|
expires_seconds=86400,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_storage_key_format_also_works(self):
|
||||||
|
"""纯 storage key 格式也能正常签名(storage内部会处理)。"""
|
||||||
|
mock_storage = MagicMock()
|
||||||
|
mock_storage.get_download_url.return_value = "https://signed-url/audio.mp3?sig=xxx"
|
||||||
|
signer = self._make_signer(mock_storage)
|
||||||
|
|
||||||
|
result = signer("audio/test.mp3")
|
||||||
|
|
||||||
|
assert result == "https://signed-url/audio.mp3?sig=xxx"
|
||||||
|
mock_storage.get_download_url.assert_called_once_with(
|
||||||
|
"audio/test.mp3",
|
||||||
|
expires_seconds=86400,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_signer_via_dependencies_module(self):
|
||||||
|
"""通过 dependencies 模块获取 signer,验证集成正确。"""
|
||||||
|
from app.core.storage import OSSStorageService
|
||||||
|
|
||||||
|
mock_svc = MagicMock(spec=OSSStorageService)
|
||||||
|
mock_svc.get_download_url.return_value = "https://signed/a.mp3?sig=123"
|
||||||
|
|
||||||
|
# 替换全局单例
|
||||||
|
with patch("app.core.storage._storage_service", mock_svc):
|
||||||
|
from app.dependencies import get_audio_url_signer
|
||||||
|
|
||||||
|
signer = get_audio_url_signer()
|
||||||
|
result = signer("test/audio.mp3")
|
||||||
|
|
||||||
|
assert result == "https://signed/a.mp3?sig=123"
|
||||||
|
mock_svc.get_download_url.assert_called_once_with(
|
||||||
|
"test/audio.mp3",
|
||||||
|
expires_seconds=86400,
|
||||||
|
)
|
||||||
@@ -23,7 +23,7 @@ def _make_service(
|
|||||||
*,
|
*,
|
||||||
api_key: str = "test-api-key",
|
api_key: str = "test-api-key",
|
||||||
base_url: str = "https://dashscope.aliyuncs.com/api/v1",
|
base_url: str = "https://dashscope.aliyuncs.com/api/v1",
|
||||||
model: str = "cosyvoice-v3.5-plus",
|
model: str = "cosyvoice-v3-flash",
|
||||||
clone_model: str = "voice-enrollment",
|
clone_model: str = "voice-enrollment",
|
||||||
http_client: httpx.Client | None = None,
|
http_client: httpx.Client | None = None,
|
||||||
audio_url_signer=None,
|
audio_url_signer=None,
|
||||||
@@ -78,7 +78,7 @@ class TestSubmitCloneTask:
|
|||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
"output": {
|
"output": {
|
||||||
"voice_id": "cosyvoice-v3.5-plus-clone-abc123",
|
"voice_id": "cosyvoice-v3-flash-clone-abc123",
|
||||||
"status": "DEPLOYING",
|
"status": "DEPLOYING",
|
||||||
},
|
},
|
||||||
"usage": {"count": 1},
|
"usage": {"count": 1},
|
||||||
@@ -92,7 +92,7 @@ class TestSubmitCloneTask:
|
|||||||
voice_name="myvoice",
|
voice_name="myvoice",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["voice_id"] == "cosyvoice-v3.5-plus-clone-abc123"
|
assert result["voice_id"] == "cosyvoice-v3-flash-clone-abc123"
|
||||||
assert result["status"] == "DEPLOYING"
|
assert result["status"] == "DEPLOYING"
|
||||||
assert result["request_id"] == "req-001"
|
assert result["request_id"] == "req-001"
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ class TestSubmitCloneTask:
|
|||||||
payload = call_args.kwargs["json"]
|
payload = call_args.kwargs["json"]
|
||||||
assert payload["model"] == "voice-enrollment"
|
assert payload["model"] == "voice-enrollment"
|
||||||
assert payload["input"]["action"] == "create_voice"
|
assert payload["input"]["action"] == "create_voice"
|
||||||
assert payload["input"]["target_model"] == "cosyvoice-v3.5-plus"
|
assert payload["input"]["target_model"] == "cosyvoice-v3-flash"
|
||||||
assert payload["input"]["prefix"] == "myvoice"
|
assert payload["input"]["prefix"] == "myvoice"
|
||||||
assert payload["input"]["url"] == "https://example.com/audio.wav"
|
assert payload["input"]["url"] == "https://example.com/audio.wav"
|
||||||
assert payload["input"]["language_hints"] == ["zh"]
|
assert payload["input"]["language_hints"] == ["zh"]
|
||||||
@@ -197,7 +197,11 @@ class TestSubmitCloneTask:
|
|||||||
|
|
||||||
payload = mock_client.request.call_args.kwargs["json"]
|
payload = mock_client.request.call_args.kwargs["json"]
|
||||||
# 中文和特殊字符被过滤,剩下字母数字
|
# 中文和特殊字符被过滤,剩下字母数字
|
||||||
assert payload["input"]["prefix"] == "2024" or payload["input"]["prefix"] == "clone" or len(payload["input"]["prefix"]) <= 10
|
assert (
|
||||||
|
payload["input"]["prefix"] == "2024"
|
||||||
|
or payload["input"]["prefix"] == "clone"
|
||||||
|
or len(payload["input"]["prefix"]) <= 10
|
||||||
|
)
|
||||||
|
|
||||||
def test_submit_auth_401_raises(self) -> None:
|
def test_submit_auth_401_raises(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
@@ -229,7 +233,7 @@ class TestQueryVoiceStatus:
|
|||||||
{
|
{
|
||||||
"output": {
|
"output": {
|
||||||
"status": "DEPLOYING",
|
"status": "DEPLOYING",
|
||||||
"target_model": "cosyvoice-v3.5-plus",
|
"target_model": "cosyvoice-v3-flash",
|
||||||
"gmt_create": "2026-01-01T00:00:00Z",
|
"gmt_create": "2026-01-01T00:00:00Z",
|
||||||
"gmt_modified": "2026-01-01T00:01:00Z",
|
"gmt_modified": "2026-01-01T00:01:00Z",
|
||||||
"resource_link": "https://...",
|
"resource_link": "https://...",
|
||||||
@@ -242,7 +246,7 @@ class TestQueryVoiceStatus:
|
|||||||
result = service.query_voice_status("voice-123")
|
result = service.query_voice_status("voice-123")
|
||||||
|
|
||||||
assert result["status"] == "DEPLOYING"
|
assert result["status"] == "DEPLOYING"
|
||||||
assert result["target_model"] == "cosyvoice-v3.5-plus"
|
assert result["target_model"] == "cosyvoice-v3-flash"
|
||||||
|
|
||||||
# 验证请求
|
# 验证请求
|
||||||
payload = mock_client.request.call_args.kwargs["json"]
|
payload = mock_client.request.call_args.kwargs["json"]
|
||||||
@@ -253,7 +257,7 @@ class TestQueryVoiceStatus:
|
|||||||
def test_query_ok_status(self) -> None:
|
def test_query_ok_status(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.request.return_value = _mock_response(
|
mock_client.request.return_value = _mock_response(
|
||||||
200, {"output": {"status": "OK", "target_model": "cosyvoice-v3.5-plus"}}
|
200, {"output": {"status": "OK", "target_model": "cosyvoice-v3-flash"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
@@ -278,7 +282,7 @@ class TestPollCloneTask:
|
|||||||
def test_poll_ok_on_first_check(self) -> None:
|
def test_poll_ok_on_first_check(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.request.return_value = _mock_response(
|
mock_client.request.return_value = _mock_response(
|
||||||
200, {"output": {"status": "OK", "target_model": "cosyvoice-v3.5-plus"}}
|
200, {"output": {"status": "OK", "target_model": "cosyvoice-v3-flash"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
@@ -305,9 +309,7 @@ class TestPollCloneTask:
|
|||||||
|
|
||||||
def test_poll_undeployed_raises_error(self) -> None:
|
def test_poll_undeployed_raises_error(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.request.return_value = _mock_response(
|
mock_client.request.return_value = _mock_response(200, {"output": {"status": "UNDEPLOYED"}})
|
||||||
200, {"output": {"status": "UNDEPLOYED"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
service.CLONE_POLL_INTERVAL = 0.01
|
service.CLONE_POLL_INTERVAL = 0.01
|
||||||
@@ -317,9 +319,7 @@ class TestPollCloneTask:
|
|||||||
|
|
||||||
def test_poll_timeout_raises(self) -> None:
|
def test_poll_timeout_raises(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.request.return_value = _mock_response(
|
mock_client.request.return_value = _mock_response(200, {"output": {"status": "DEPLOYING"}})
|
||||||
200, {"output": {"status": "DEPLOYING"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
service.CLONE_POLL_INTERVAL = 0.01
|
service.CLONE_POLL_INTERVAL = 0.01
|
||||||
@@ -396,9 +396,7 @@ class TestSynthesizeSpeech:
|
|||||||
)
|
)
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
result = service.synthesize_speech(
|
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3")
|
||||||
text="你好世界", voice_id="longxiaochun"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result, SynthesizeResult)
|
assert isinstance(result, SynthesizeResult)
|
||||||
assert result.audio_url == "https://dashscope-result.oss.com/output.mp3"
|
assert result.audio_url == "https://dashscope-result.oss.com/output.mp3"
|
||||||
@@ -409,9 +407,9 @@ class TestSynthesizeSpeech:
|
|||||||
assert "/services/audio/tts/SpeechSynthesizer" in call_args.kwargs["url"]
|
assert "/services/audio/tts/SpeechSynthesizer" in call_args.kwargs["url"]
|
||||||
|
|
||||||
payload = call_args.kwargs["json"]
|
payload = call_args.kwargs["json"]
|
||||||
assert payload["model"] == "cosyvoice-v3.5-plus"
|
assert payload["model"] == "cosyvoice-v3-flash"
|
||||||
assert payload["input"]["text"] == "你好世界"
|
assert payload["input"]["text"] == "你好世界"
|
||||||
assert payload["input"]["voice"] == "longxiaochun"
|
assert payload["input"]["voice"] == "longxiaochun_v3"
|
||||||
assert payload["input"]["format"] == "mp3"
|
assert payload["input"]["format"] == "mp3"
|
||||||
assert payload["input"]["sample_rate"] == 22050
|
assert payload["input"]["sample_rate"] == 22050
|
||||||
assert payload["input"]["rate"] == 1.0
|
assert payload["input"]["rate"] == 1.0
|
||||||
@@ -426,8 +424,12 @@ class TestSynthesizeSpeech:
|
|||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
service.synthesize_speech(
|
service.synthesize_speech(
|
||||||
text="test", voice_id="v1", sample_rate=44100,
|
text="test",
|
||||||
format="wav", speed=1.5, volume=80,
|
voice_id="v1",
|
||||||
|
sample_rate=44100,
|
||||||
|
format="wav",
|
||||||
|
speed=1.5,
|
||||||
|
volume=80,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = mock_client.request.call_args.kwargs["json"]
|
payload = mock_client.request.call_args.kwargs["json"]
|
||||||
@@ -463,7 +465,8 @@ class TestSynthesizeSpeech:
|
|||||||
"""同步接口的 submit_synthesize_task 返回空 task_id 字段(兼容旧接口)."""
|
"""同步接口的 submit_synthesize_task 返回空 task_id 字段(兼容旧接口)."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.request.return_value = _mock_response(
|
mock_client.request.return_value = _mock_response(
|
||||||
200, {"output": {"audio": {"url": "https://e.com/a.mp3"}}},
|
200,
|
||||||
|
{"output": {"audio": {"url": "https://e.com/a.mp3"}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
@@ -490,9 +493,7 @@ class TestRetryLogic:
|
|||||||
mock_client.request.side_effect = [
|
mock_client.request.side_effect = [
|
||||||
_mock_response(500, text="Server Error"),
|
_mock_response(500, text="Server Error"),
|
||||||
_mock_response(502, text="Bad Gateway"),
|
_mock_response(502, text="Bad Gateway"),
|
||||||
_mock_response(
|
_mock_response(200, {"output": {"audio": {"url": "https://e.com/a.mp3"}}}),
|
||||||
200, {"output": {"audio": {"url": "https://e.com/a.mp3"}}}
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
@@ -547,9 +548,7 @@ class TestSanitizePrefix:
|
|||||||
class TestCheckTaskStatus:
|
class TestCheckTaskStatus:
|
||||||
def test_check_task_status_uses_query_voice(self) -> None:
|
def test_check_task_status_uses_query_voice(self) -> None:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.request.return_value = _mock_response(
|
mock_client.request.return_value = _mock_response(200, {"output": {"status": "OK"}})
|
||||||
200, {"output": {"status": "OK"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
service = _make_service(http_client=mock_client)
|
service = _make_service(http_client=mock_client)
|
||||||
result = service.check_task_status("voice-123")
|
result = service.check_task_status("voice-123")
|
||||||
@@ -560,3 +559,40 @@ class TestCheckTaskStatus:
|
|||||||
# 验证走的是 query_voice 路径
|
# 验证走的是 query_voice 路径
|
||||||
payload = mock_client.request.call_args.kwargs["json"]
|
payload = mock_client.request.call_args.kwargs["json"]
|
||||||
assert payload["input"]["action"] == "query_voice"
|
assert payload["input"]["action"] == "query_voice"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 配置与初始化 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceConfiguration:
|
||||||
|
"""测试 CosyVoiceService 配置与初始化逻辑."""
|
||||||
|
|
||||||
|
def test_base_url_old_text2audio_path_auto_fixed(self) -> None:
|
||||||
|
"""旧版 base_url 包含 text2audio 路径时,应自动修正为 /api/v1."""
|
||||||
|
service = CosyVoiceService(
|
||||||
|
api_key="test-key",
|
||||||
|
base_url="https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio",
|
||||||
|
model="cosyvoice-v3-flash",
|
||||||
|
)
|
||||||
|
# 应自动去掉 text2audio 后缀,保留到 /api/v1
|
||||||
|
assert service._base_url == "https://dashscope.aliyuncs.com/api/v1"
|
||||||
|
|
||||||
|
def test_base_url_normal_unchanged(self) -> None:
|
||||||
|
"""正常的 base_url 不应被修改."""
|
||||||
|
url = "https://dashscope.aliyuncs.com/api/v1"
|
||||||
|
service = CosyVoiceService(
|
||||||
|
api_key="test-key",
|
||||||
|
base_url=url,
|
||||||
|
model="cosyvoice-v3-flash",
|
||||||
|
)
|
||||||
|
assert service._base_url == url
|
||||||
|
|
||||||
|
def test_base_url_workspace_domain_unchanged(self) -> None:
|
||||||
|
"""工作空间专属域名的 base_url 不应被修改."""
|
||||||
|
url = "https://workspace-xxx.cn-beijing.maas.aliyuncs.com/api/v1"
|
||||||
|
service = CosyVoiceService(
|
||||||
|
api_key="test-key",
|
||||||
|
base_url=url,
|
||||||
|
model="cosyvoice-v3-flash",
|
||||||
|
)
|
||||||
|
assert service._base_url == url
|
||||||
|
|||||||
Regular → Executable
+12
@@ -177,6 +177,18 @@ class StubGenerationTaskRepository:
|
|||||||
def count_by_user(self, user_id: str) -> int:
|
def count_by_user(self, user_id: str) -> int:
|
||||||
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
|
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int:
|
||||||
|
return len(
|
||||||
|
[
|
||||||
|
t
|
||||||
|
for t in self._store.values()
|
||||||
|
if t.created_by_user_id == user_id and getattr(t, "status", "") == "pending"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int:
|
||||||
|
return len([t for t in self._store.values() if getattr(t, "status", "") == "pending"])
|
||||||
|
|
||||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
|
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
|
||||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||||
|
|||||||
Regular → Executable
+6
@@ -194,6 +194,12 @@ class StubGenerationTaskRepository:
|
|||||||
self._tasks[task.id] = task
|
self._tasks[task.id] = task
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Service factory
|
# Service factory
|
||||||
|
|||||||
Regular → Executable
+6
@@ -58,6 +58,12 @@ class StubGenerationTaskRepository:
|
|||||||
def get(self, task_id):
|
def get(self, task_id):
|
||||||
return self._tasks.get(task_id)
|
return self._tasks.get(task_id)
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def count_pending_total(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class StubGeneratedVideoRepository:
|
class StubGeneratedVideoRepository:
|
||||||
def __init__(self, videos=None):
|
def __init__(self, videos=None):
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
|||||||
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
|
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildXfadeFilterChainSafetyClamp:
|
class TestBuildXfadeFilterChainSafetyClamp:
|
||||||
"""验证 xfade 滤镜链的安全钳制逻辑,防止 exit 234。"""
|
"""验证 xfade 滤镜链的安全钳制逻辑,防止 exit 234。"""
|
||||||
|
|
||||||
@@ -124,9 +123,7 @@ class TestBuildXfadeFilterChainSafetyClamp:
|
|||||||
durations_found.append(float(m.group(1)))
|
durations_found.append(float(m.group(1)))
|
||||||
|
|
||||||
# 第一个 xfade: td 必须 ≤ 0.3 (第二个输入 clip_durations[1]=0.3)
|
# 第一个 xfade: td 必须 ≤ 0.3 (第二个输入 clip_durations[1]=0.3)
|
||||||
assert durations_found[0] <= 0.3 + 0.001, (
|
assert durations_found[0] <= 0.3 + 0.001, f"第一个 xfade td={durations_found[0]} 超过 clip_durations[1]=0.3"
|
||||||
f"第一个 xfade td={durations_found[0]} 超过 clip_durations[1]=0.3"
|
|
||||||
)
|
|
||||||
# 第二个 xfade: td 可以 = 0.5 (clip_durations[2]=5.0)
|
# 第二个 xfade: td 可以 = 0.5 (clip_durations[2]=5.0)
|
||||||
assert durations_found[1] <= 0.5 + 0.001
|
assert durations_found[1] <= 0.5 + 0.001
|
||||||
assert dur > 0
|
assert dur > 0
|
||||||
@@ -173,9 +170,9 @@ class TestBuildXfadeFilterChainSafetyClamp:
|
|||||||
assert dur_val >= 0.001 # 至少 1ms
|
assert dur_val >= 0.001 # 至少 1ms
|
||||||
# P1 修复验证: td 不能超过第二个输入片段时长
|
# P1 修复验证: td 不能超过第二个输入片段时长
|
||||||
second_input_idx = xfade_idx + 1
|
second_input_idx = xfade_idx + 1
|
||||||
assert dur_val <= durations[second_input_idx] + 0.001, (
|
assert (
|
||||||
f"td={dur_val} > clip_durations[{second_input_idx}]={durations[second_input_idx]}"
|
dur_val <= durations[second_input_idx] + 0.001
|
||||||
)
|
), f"td={dur_val} > clip_durations[{second_input_idx}]={durations[second_input_idx]}"
|
||||||
xfade_idx += 1
|
xfade_idx += 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
# ── oss_bucket endpoint scheme 修复 ──────────────────────────────────────────
|
# ── oss_bucket endpoint scheme 修复 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -25,17 +24,19 @@ class TestOSSBucketEndpointScheme:
|
|||||||
from video_processing.oss_helpers import oss_bucket
|
from video_processing.oss_helpers import oss_bucket
|
||||||
|
|
||||||
mock_bucket_instance = MagicMock()
|
mock_bucket_instance = MagicMock()
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth") as mock_auth, patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance
|
),
|
||||||
) as mock_bucket_cls:
|
patch("video_processing.oss_helpers.oss2.Auth") as mock_auth,
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||||
|
):
|
||||||
# 清除缓存,确保重新创建
|
# 清除缓存,确保重新创建
|
||||||
import video_processing.oss_helpers as oss_mod
|
import video_processing.oss_helpers as oss_mod
|
||||||
|
|
||||||
@@ -45,9 +46,7 @@ class TestOSSBucketEndpointScheme:
|
|||||||
# 验证 endpoint 传的是带 https:// 的
|
# 验证 endpoint 传的是带 https:// 的
|
||||||
call_args = mock_bucket_cls.call_args
|
call_args = mock_bucket_cls.call_args
|
||||||
endpoint_arg = call_args[0][1] # 第 2 个位置参数是 endpoint
|
endpoint_arg = call_args[0][1] # 第 2 个位置参数是 endpoint
|
||||||
assert endpoint_arg.startswith("https://"), (
|
assert endpoint_arg.startswith("https://"), f"endpoint 应该带 https:// 前缀,实际为: {endpoint_arg}"
|
||||||
f"endpoint 应该带 https:// 前缀,实际为: {endpoint_arg}"
|
|
||||||
)
|
|
||||||
assert "oss-cn-hangzhou.aliyuncs.com" in endpoint_arg
|
assert "oss-cn-hangzhou.aliyuncs.com" in endpoint_arg
|
||||||
|
|
||||||
def test_endpoint_with_https_keeps_as_is(self):
|
def test_endpoint_with_https_keeps_as_is(self):
|
||||||
@@ -55,17 +54,19 @@ class TestOSSBucketEndpointScheme:
|
|||||||
from video_processing.oss_helpers import oss_bucket
|
from video_processing.oss_helpers import oss_bucket
|
||||||
|
|
||||||
mock_bucket_instance = MagicMock()
|
mock_bucket_instance = MagicMock()
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "https://oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "https://oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance
|
),
|
||||||
) as mock_bucket_cls:
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||||
|
):
|
||||||
import video_processing.oss_helpers as oss_mod
|
import video_processing.oss_helpers as oss_mod
|
||||||
|
|
||||||
bucket = oss_bucket()
|
bucket = oss_bucket()
|
||||||
@@ -81,17 +82,19 @@ class TestOSSBucketEndpointScheme:
|
|||||||
from video_processing.oss_helpers import oss_bucket
|
from video_processing.oss_helpers import oss_bucket
|
||||||
|
|
||||||
mock_bucket_instance = MagicMock()
|
mock_bucket_instance = MagicMock()
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "http://oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "http://oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance
|
),
|
||||||
) as mock_bucket_cls:
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
|
||||||
|
):
|
||||||
import video_processing.oss_helpers as oss_mod
|
import video_processing.oss_helpers as oss_mod
|
||||||
|
|
||||||
bucket = oss_bucket()
|
bucket = oss_bucket()
|
||||||
@@ -133,16 +136,18 @@ class TestGetSignedDownloadUrl:
|
|||||||
mock_bucket = MagicMock()
|
mock_bucket = MagicMock()
|
||||||
mock_bucket.sign_url.return_value = "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4?OSSAccessKeyId=xxx&Expires=xxx&Signature=xxx"
|
mock_bucket.sign_url.return_value = "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4?OSSAccessKeyId=xxx&Expires=xxx&Signature=xxx"
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket
|
),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||||
):
|
):
|
||||||
result = get_signed_download_url("generated/test.mp4", expires_seconds=3600)
|
result = get_signed_download_url("generated/test.mp4", expires_seconds=3600)
|
||||||
|
|
||||||
@@ -155,22 +160,24 @@ class TestGetSignedDownloadUrl:
|
|||||||
from video_processing.oss_helpers import get_signed_download_url
|
from video_processing.oss_helpers import get_signed_download_url
|
||||||
|
|
||||||
mock_bucket = MagicMock()
|
mock_bucket = MagicMock()
|
||||||
mock_bucket.sign_url.return_value = "https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4?sign=xxx"
|
mock_bucket.sign_url.return_value = (
|
||||||
|
"https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4?sign=xxx"
|
||||||
|
)
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket
|
),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||||
):
|
):
|
||||||
result = get_signed_download_url(
|
result = get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
|
||||||
"https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_bucket.sign_url.assert_called_once()
|
mock_bucket.sign_url.assert_called_once()
|
||||||
# 验证传给 sign_url 的是纯 storage key,不是完整 URL
|
# 验证传给 sign_url 的是纯 storage key,不是完整 URL
|
||||||
@@ -193,16 +200,18 @@ class TestGetSignedDownloadUrl:
|
|||||||
mock_bucket = MagicMock()
|
mock_bucket = MagicMock()
|
||||||
mock_bucket.sign_url.side_effect = Exception("sign failed")
|
mock_bucket.sign_url.side_effect = Exception("sign failed")
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket
|
),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||||
):
|
):
|
||||||
result = get_signed_download_url("generated/test.mp4")
|
result = get_signed_download_url("generated/test.mp4")
|
||||||
assert result is None
|
assert result is None
|
||||||
@@ -223,16 +232,18 @@ class TestUploadToOSSReturnsHTTPS:
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket
|
),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||||
):
|
):
|
||||||
result = upload_to_oss(Path("/tmp/test.mp4"), "generated/test.mp4")
|
result = upload_to_oss(Path("/tmp/test.mp4"), "generated/test.mp4")
|
||||||
|
|
||||||
@@ -249,16 +260,18 @@ class TestUploadToOSSReturnsHTTPS:
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
with patch.dict(
|
with (
|
||||||
os.environ,
|
patch.dict(
|
||||||
{
|
os.environ,
|
||||||
"OSS_ACCESS_KEY_ID": "test-key",
|
{
|
||||||
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
"OSS_ACCESS_KEY_ID": "test-key",
|
||||||
"OSS_ENDPOINT": "https://oss-cn-hangzhou.aliyuncs.com",
|
"OSS_ACCESS_KEY_SECRET": "test-secret",
|
||||||
"OSS_BUCKET_NAME": "test-bucket",
|
"OSS_ENDPOINT": "https://oss-cn-hangzhou.aliyuncs.com",
|
||||||
},
|
"OSS_BUCKET_NAME": "test-bucket",
|
||||||
), patch("video_processing.oss_helpers.oss2.Auth"), patch(
|
},
|
||||||
"video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket
|
),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Auth"),
|
||||||
|
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
|
||||||
):
|
):
|
||||||
result = upload_to_oss(Path("/tmp/test.mp4"), "generated/test.mp4")
|
result = upload_to_oss(Path("/tmp/test.mp4"), "generated/test.mp4")
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class TestPresetVoice:
|
|||||||
def test_preset_voice_to_dict(self) -> None:
|
def test_preset_voice_to_dict(self) -> None:
|
||||||
"""序列化。"""
|
"""序列化。"""
|
||||||
voice = PresetVoice(
|
voice = PresetVoice(
|
||||||
voice_id="longxiaochun",
|
voice_id="longxiaochun_v3",
|
||||||
name="龙小淳",
|
name="龙小淳",
|
||||||
description="温柔女声",
|
description="温柔女声",
|
||||||
gender="female",
|
gender="female",
|
||||||
@@ -73,7 +73,7 @@ class TestPresetVoice:
|
|||||||
|
|
||||||
result = voice.to_dict()
|
result = voice.to_dict()
|
||||||
|
|
||||||
assert result["voice_id"] == "longxiaochun"
|
assert result["voice_id"] == "longxiaochun_v3"
|
||||||
assert result["name"] == "龙小淳"
|
assert result["name"] == "龙小淳"
|
||||||
assert result["description"] == "温柔女声"
|
assert result["description"] == "温柔女声"
|
||||||
assert result["gender"] == "female"
|
assert result["gender"] == "female"
|
||||||
@@ -127,14 +127,14 @@ class TestPresetVoicesConfig:
|
|||||||
def test_cosyvoice_voice_ids(self) -> None:
|
def test_cosyvoice_voice_ids(self) -> None:
|
||||||
"""音色 ID 应为 CosyVoice 真实可用的音色名。"""
|
"""音色 ID 应为 CosyVoice 真实可用的音色名。"""
|
||||||
expected_ids = {
|
expected_ids = {
|
||||||
"longxiaochun",
|
"longxiaochun_v3",
|
||||||
"longxiaoxia",
|
"longxiaoxia_v3",
|
||||||
"longxiaochen",
|
"longxiaochen_v3",
|
||||||
"longyue",
|
"longyue_v3",
|
||||||
"longshu",
|
"longshu_v3",
|
||||||
"longjing",
|
"longjing_v3",
|
||||||
"longbo",
|
"longbo_v3",
|
||||||
"longtian",
|
"longtian_v3",
|
||||||
}
|
}
|
||||||
actual_ids = {v.voice_id for v in PRESET_VOICES}
|
actual_ids = {v.voice_id for v in PRESET_VOICES}
|
||||||
assert actual_ids == expected_ids
|
assert actual_ids == expected_ids
|
||||||
@@ -164,10 +164,10 @@ class TestPresetVoiceHelpers:
|
|||||||
|
|
||||||
def test_get_preset_voice_by_id_found(self) -> None:
|
def test_get_preset_voice_by_id_found(self) -> None:
|
||||||
"""按 ID 查找存在的音色。"""
|
"""按 ID 查找存在的音色。"""
|
||||||
voice = get_preset_voice_by_id("longxiaochun")
|
voice = get_preset_voice_by_id("longxiaochun_v3")
|
||||||
assert voice is not None
|
assert voice is not None
|
||||||
assert voice.name == "龙小淳"
|
assert voice.name == "龙小淳"
|
||||||
assert voice.voice_id == "longxiaochun"
|
assert voice.voice_id == "longxiaochun_v3"
|
||||||
|
|
||||||
def test_get_preset_voice_by_id_not_found(self) -> None:
|
def test_get_preset_voice_by_id_not_found(self) -> None:
|
||||||
"""按 ID 查找不存在的音色。"""
|
"""按 ID 查找不存在的音色。"""
|
||||||
@@ -176,9 +176,9 @@ class TestPresetVoiceHelpers:
|
|||||||
|
|
||||||
def test_is_preset_voice_true(self) -> None:
|
def test_is_preset_voice_true(self) -> None:
|
||||||
"""判断预置音色返回 True。"""
|
"""判断预置音色返回 True。"""
|
||||||
assert is_preset_voice("longxiaochun") is True
|
assert is_preset_voice("longxiaochun_v3") is True
|
||||||
assert is_preset_voice("longxiaoxia") is True
|
assert is_preset_voice("longxiaoxia_v3") is True
|
||||||
assert is_preset_voice("longbo") is True
|
assert is_preset_voice("longbo_v3") is True
|
||||||
|
|
||||||
def test_is_preset_voice_false(self) -> None:
|
def test_is_preset_voice_false(self) -> None:
|
||||||
"""判断非预置音色返回 False。"""
|
"""判断非预置音色返回 False。"""
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""任务队列限流防护单元测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||||
|
|
||||||
|
from app.core.task_enqueue import (
|
||||||
|
GLOBAL_PENDING_LIMIT,
|
||||||
|
USER_PENDING_LIMIT,
|
||||||
|
GlobalQueueFull,
|
||||||
|
UserPendingLimitExceeded,
|
||||||
|
check_queue_limits,
|
||||||
|
safe_enqueue_generation_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MockRepository:
|
||||||
|
"""支持 pending 计数的 mock repository。
|
||||||
|
|
||||||
|
支持通过 set_pending 动态修改计数,用于模拟入队后计数变化的并发场景。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, user_pending: int = 0, global_pending: int = 0):
|
||||||
|
self._user_pending = user_pending
|
||||||
|
self._global_pending = global_pending
|
||||||
|
self._send_task_called = False
|
||||||
|
self.updated_tasks = []
|
||||||
|
|
||||||
|
def count_pending_by_user(self, user_id: str) -> int:
|
||||||
|
return self._user_pending
|
||||||
|
|
||||||
|
def count_pending_total(self) -> int:
|
||||||
|
return self._global_pending
|
||||||
|
|
||||||
|
def update(self, task):
|
||||||
|
self.updated_tasks.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def set_pending(self, *, user_pending: int | None = None, global_pending: int | None = None):
|
||||||
|
"""动态修改 pending 计数,模拟并发场景。"""
|
||||||
|
if user_pending is not None:
|
||||||
|
self._user_pending = user_pending
|
||||||
|
if global_pending is not None:
|
||||||
|
self._global_pending = global_pending
|
||||||
|
|
||||||
|
|
||||||
|
class MockTask:
|
||||||
|
def __init__(self, task_id: str = "task-1", status: str = "pending"):
|
||||||
|
self.id = task_id
|
||||||
|
self.status = status
|
||||||
|
self.error_message = ""
|
||||||
|
|
||||||
|
def mark_failed(self, reason: str):
|
||||||
|
self.status = "failed"
|
||||||
|
self.error_message = reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_celery(monkeypatch):
|
||||||
|
"""mock 掉 celery_app.send_task,避免真实发送。"""
|
||||||
|
mock_send = MagicMock()
|
||||||
|
monkeypatch.setattr("app.core.celery_app.celery_app.send_task", mock_send)
|
||||||
|
return mock_send
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 常量导出测试
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_constants_are_exported():
|
||||||
|
"""限流阈值常量已导出,供业务代码引用。"""
|
||||||
|
assert USER_PENDING_LIMIT == 3
|
||||||
|
assert GLOBAL_PENDING_LIMIT == 20
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# check_queue_limits 单元测试(预检查用,>= 边界)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckQueueLimits:
|
||||||
|
"""队列限流检查函数测试(预检查语义,>= 上限即拒绝)。"""
|
||||||
|
|
||||||
|
def test_normal_passes_through(self):
|
||||||
|
"""正常范围内的任务不受限制。"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=5)
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
|
||||||
|
def test_user_limit_exceeded_raises(self):
|
||||||
|
"""用户 pending 超过上限抛 UserPendingLimitExceeded。"""
|
||||||
|
repo = MockRepository(user_pending=4, global_pending=5)
|
||||||
|
with pytest.raises(UserPendingLimitExceeded) as exc_info:
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
assert exc_info.value.user_id == "user-1"
|
||||||
|
assert exc_info.value.pending_count == 4
|
||||||
|
assert exc_info.value.limit == 3
|
||||||
|
|
||||||
|
def test_user_at_limit_also_raises(self):
|
||||||
|
"""用户 pending 刚好等于上限也拒绝(>= 边界)。"""
|
||||||
|
repo = MockRepository(user_pending=3, global_pending=5)
|
||||||
|
with pytest.raises(UserPendingLimitExceeded):
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
|
||||||
|
def test_user_below_limit_passes(self):
|
||||||
|
"""用户 pending 比上限少 1,通过。"""
|
||||||
|
repo = MockRepository(user_pending=2, global_pending=5)
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
|
||||||
|
def test_global_limit_exceeded_raises(self):
|
||||||
|
"""全局 pending 超过上限抛 GlobalQueueFull。"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=21)
|
||||||
|
with pytest.raises(GlobalQueueFull) as exc_info:
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
assert exc_info.value.pending_count == 21
|
||||||
|
assert exc_info.value.limit == 20
|
||||||
|
|
||||||
|
def test_global_at_limit_also_raises(self):
|
||||||
|
"""全局 pending 刚好等于上限也拒绝(>= 边界)。"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=20)
|
||||||
|
with pytest.raises(GlobalQueueFull):
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
|
||||||
|
def test_global_below_limit_passes(self):
|
||||||
|
"""全局 pending 比上限少 1,通过。"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=19)
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
|
||||||
|
def test_global_takes_priority_over_user(self):
|
||||||
|
"""全局和用户都超限时,优先抛全局异常。"""
|
||||||
|
repo = MockRepository(user_pending=5, global_pending=25)
|
||||||
|
with pytest.raises(GlobalQueueFull):
|
||||||
|
check_queue_limits("user-1", repo)
|
||||||
|
|
||||||
|
def test_empty_user_id_skips_user_check(self):
|
||||||
|
"""不传 user_id 时跳过用户级检查,只做全局检查。"""
|
||||||
|
repo = MockRepository(user_pending=10, global_pending=5)
|
||||||
|
# 用户超限但不传 user_id → 全局未超限,应该通过
|
||||||
|
check_queue_limits("", repo)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# safe_enqueue_generation_task 限流集成测试(入队前用 >,包含当前任务)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeEnqueueWithLimits:
|
||||||
|
"""安全入队函数的限流功能测试。"""
|
||||||
|
|
||||||
|
def test_normal_task_enqueues_successfully(self, mock_celery):
|
||||||
|
"""正常任务入队成功,返回 True。"""
|
||||||
|
repo = MockRepository(user_pending=0, global_pending=0)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
assert result is True
|
||||||
|
mock_celery.assert_called_once_with("worker.generate_video", args=["task-1"])
|
||||||
|
assert len(repo.updated_tasks) == 0 # 成功不需要更新状态
|
||||||
|
|
||||||
|
def test_user_limit_rejected_with_failed_status(self, mock_celery):
|
||||||
|
"""用户超限:任务标记为 failed,抛 UserPendingLimitExceeded。"""
|
||||||
|
repo = MockRepository(user_pending=5, global_pending=5)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
with pytest.raises(UserPendingLimitExceeded):
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
mock_celery.assert_not_called()
|
||||||
|
assert task.status == "failed"
|
||||||
|
assert "限流" in task.error_message
|
||||||
|
assert len(repo.updated_tasks) == 1
|
||||||
|
|
||||||
|
def test_user_at_limit_still_passes(self, mock_celery):
|
||||||
|
"""用户 pending 刚好等于上限:入队前检查用 >,包含当前任务,刚好到上限不算超。
|
||||||
|
|
||||||
|
与预检查的 >= 语义一致:预检查时 pending=3 拒绝(不能再加新的),
|
||||||
|
但 safe_enqueue 被调用时任务已是 pending(就是第3个),
|
||||||
|
pending=3 不满足 >3,所以通过。
|
||||||
|
"""
|
||||||
|
repo = MockRepository(user_pending=3, global_pending=5)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
assert result is True
|
||||||
|
mock_celery.assert_called_once()
|
||||||
|
|
||||||
|
def test_user_one_over_limit_rejected(self, mock_celery):
|
||||||
|
"""用户 pending = limit + 1:超限被拒。"""
|
||||||
|
repo = MockRepository(user_pending=4, global_pending=5)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
with pytest.raises(UserPendingLimitExceeded):
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
mock_celery.assert_not_called()
|
||||||
|
|
||||||
|
def test_global_limit_rejected_with_failed_status(self, mock_celery):
|
||||||
|
"""全局超限:任务标记为 failed,抛 GlobalQueueFull。"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=21)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
with pytest.raises(GlobalQueueFull):
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
mock_celery.assert_not_called()
|
||||||
|
assert task.status == "failed"
|
||||||
|
assert len(repo.updated_tasks) == 1
|
||||||
|
|
||||||
|
def test_global_at_limit_still_passes(self, mock_celery):
|
||||||
|
"""全局 pending 刚好等于上限:入队前检查用 >,包含当前任务,刚好到上限不算超。"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=20)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
assert result is True
|
||||||
|
mock_celery.assert_called_once()
|
||||||
|
|
||||||
|
def test_no_user_id_skips_user_limit(self, mock_celery):
|
||||||
|
"""不传 user_id 时跳过用户级限流,只做全局检查。"""
|
||||||
|
repo = MockRepository(user_pending=10, global_pending=5)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="")
|
||||||
|
assert result is True
|
||||||
|
mock_celery.assert_called_once()
|
||||||
|
|
||||||
|
def test_no_user_id_still_checks_global(self, mock_celery):
|
||||||
|
"""不传 user_id 时全局超限仍然被拦。"""
|
||||||
|
repo = MockRepository(user_pending=10, global_pending=25)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
with pytest.raises(GlobalQueueFull):
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="")
|
||||||
|
mock_celery.assert_not_called()
|
||||||
|
|
||||||
|
def test_default_limits_match_constants(self, mock_celery):
|
||||||
|
"""默认配置与导出常量一致。"""
|
||||||
|
# 刚好在默认限制内(limit - 1)
|
||||||
|
repo = MockRepository(user_pending=2, global_pending=19)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_update_failure_does_not_crash(self, mock_celery):
|
||||||
|
"""repository.update 失败也不崩溃,异常继续向上抛。"""
|
||||||
|
|
||||||
|
class BadRepo(MockRepository):
|
||||||
|
def update(self, task):
|
||||||
|
raise RuntimeError("db down")
|
||||||
|
|
||||||
|
repo = BadRepo(user_pending=5, global_pending=5)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
# 仍然抛 UserPendingLimitExceeded,不会被 update 失败掩盖
|
||||||
|
with pytest.raises(UserPendingLimitExceeded):
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
mock_celery.assert_not_called()
|
||||||
|
# 任务状态还是变了(内存里改了)
|
||||||
|
assert task.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 入队后最终校验(并发竞态兜底)测试
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostEnqueueFinalCheck:
|
||||||
|
"""入队后最终校验:模拟并发场景,Celery发送后计数增加被兜住。"""
|
||||||
|
|
||||||
|
def test_post_enqueue_global_overflow_rollback(self, mock_celery):
|
||||||
|
"""并发场景:入队前检查通过,但发送Celery后全局计数超限 → 回滚为failed。
|
||||||
|
|
||||||
|
模拟两个请求同时通过入队前检查(都查到 global=19),
|
||||||
|
都创建了任务(DB里变成 21),先发送Celery的那个在最终校验时被兜住。
|
||||||
|
"""
|
||||||
|
repo = MockRepository(user_pending=1, global_pending=20) # 入队前:20 > 20?否
|
||||||
|
task = MockTask("task-1")
|
||||||
|
|
||||||
|
# 模拟发送Celery后,另一个并发请求也创建了任务,全局变成21
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
repo.set_pending(global_pending=21)
|
||||||
|
|
||||||
|
mock_celery.side_effect = side_effect
|
||||||
|
|
||||||
|
with pytest.raises(GlobalQueueFull) as exc_info:
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
|
||||||
|
# Celery 确实发出去了(兜底不撤销 Celery,只回滚 DB 状态)
|
||||||
|
mock_celery.assert_called_once()
|
||||||
|
# 任务被标记为 failed
|
||||||
|
assert task.status == "failed"
|
||||||
|
assert "入队后" in task.error_message
|
||||||
|
assert exc_info.value.pending_count == 21
|
||||||
|
assert len(repo.updated_tasks) == 1
|
||||||
|
|
||||||
|
def test_post_enqueue_user_overflow_rollback(self, mock_celery):
|
||||||
|
"""并发场景:入队前检查通过,但发送Celery后用户计数超限 → 回滚为failed。"""
|
||||||
|
repo = MockRepository(user_pending=3, global_pending=5) # 入队前:3 > 3?否
|
||||||
|
task = MockTask("task-1")
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
repo.set_pending(user_pending=4)
|
||||||
|
|
||||||
|
mock_celery.side_effect = side_effect
|
||||||
|
|
||||||
|
with pytest.raises(UserPendingLimitExceeded) as exc_info:
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
|
||||||
|
mock_celery.assert_called_once()
|
||||||
|
assert task.status == "failed"
|
||||||
|
assert "入队后" in task.error_message
|
||||||
|
assert exc_info.value.user_id == "user-1"
|
||||||
|
assert exc_info.value.pending_count == 4
|
||||||
|
|
||||||
|
def test_post_enqueue_global_priority_over_user(self, mock_celery):
|
||||||
|
"""入队后校验:全局和用户都超限时,优先抛全局异常。"""
|
||||||
|
repo = MockRepository(user_pending=3, global_pending=20)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
repo.set_pending(user_pending=5, global_pending=22)
|
||||||
|
|
||||||
|
mock_celery.side_effect = side_effect
|
||||||
|
|
||||||
|
with pytest.raises(GlobalQueueFull):
|
||||||
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
|
||||||
|
assert task.status == "failed"
|
||||||
|
|
||||||
|
def test_post_enqueue_no_change_still_passes(self, mock_celery):
|
||||||
|
"""入队后计数没变 → 正常通过,不回滚。"""
|
||||||
|
repo = MockRepository(user_pending=2, global_pending=10)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_celery.assert_called_once()
|
||||||
|
assert task.status == "pending" # 状态没变
|
||||||
|
assert len(repo.updated_tasks) == 0 # 没更新 DB
|
||||||
|
|
||||||
|
def test_post_enqueue_no_user_id_skips_user_check(self, mock_celery):
|
||||||
|
"""不传 user_id 时,入队后校验也跳过用户级,只查全局。"""
|
||||||
|
repo = MockRepository(user_pending=10, global_pending=5)
|
||||||
|
task = MockTask("task-1")
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
repo.set_pending(user_pending=15, global_pending=5) # 用户超限但全局没超
|
||||||
|
|
||||||
|
mock_celery.side_effect = side_effect
|
||||||
|
|
||||||
|
result = safe_enqueue_generation_task(task, repo, user_id="")
|
||||||
|
assert result is True # 用户级不检查,全局没超限 → 通过
|
||||||
@@ -15,7 +15,7 @@ class TestTTSJobCreate:
|
|||||||
job = TTSJob.create(
|
job = TTSJob.create(
|
||||||
user_id="user_001",
|
user_id="user_001",
|
||||||
input_text="这是一段测试文本",
|
input_text="这是一段测试文本",
|
||||||
voice_id="longxiaochun",
|
voice_id="longxiaochun_v3",
|
||||||
voice_model="cosyvoice-v1",
|
voice_model="cosyvoice-v1",
|
||||||
project_id="project_001",
|
project_id="project_001",
|
||||||
voice_clone_profile_id="profile_001",
|
voice_clone_profile_id="profile_001",
|
||||||
@@ -26,7 +26,7 @@ class TestTTSJobCreate:
|
|||||||
assert job.id
|
assert job.id
|
||||||
assert job.user_id == "user_001"
|
assert job.user_id == "user_001"
|
||||||
assert job.input_text == "这是一段测试文本"
|
assert job.input_text == "这是一段测试文本"
|
||||||
assert job.voice_id == "longxiaochun"
|
assert job.voice_id == "longxiaochun_v3"
|
||||||
assert job.voice_model == "cosyvoice-v1"
|
assert job.voice_model == "cosyvoice-v1"
|
||||||
assert job.project_id == "project_001"
|
assert job.project_id == "project_001"
|
||||||
assert job.voice_clone_profile_id == "profile_001"
|
assert job.voice_clone_profile_id == "profile_001"
|
||||||
@@ -291,7 +291,7 @@ class TestTTSJobToDict:
|
|||||||
job = TTSJob.create(
|
job = TTSJob.create(
|
||||||
user_id="user_001",
|
user_id="user_001",
|
||||||
input_text="测试文本",
|
input_text="测试文本",
|
||||||
voice_id="longxiaochun",
|
voice_id="longxiaochun_v3",
|
||||||
voice_model="cosyvoice-v1",
|
voice_model="cosyvoice-v1",
|
||||||
project_id="project_001",
|
project_id="project_001",
|
||||||
voice_clone_profile_id="profile_001",
|
voice_clone_profile_id="profile_001",
|
||||||
@@ -306,7 +306,7 @@ class TestTTSJobToDict:
|
|||||||
assert result["id"] == job.id
|
assert result["id"] == job.id
|
||||||
assert result["user_id"] == "user_001"
|
assert result["user_id"] == "user_001"
|
||||||
assert result["input_text"] == "测试文本"
|
assert result["input_text"] == "测试文本"
|
||||||
assert result["voice_id"] == "longxiaochun"
|
assert result["voice_id"] == "longxiaochun_v3"
|
||||||
assert result["voice_model"] == "cosyvoice-v1"
|
assert result["voice_model"] == "cosyvoice-v1"
|
||||||
assert result["project_id"] == "project_001"
|
assert result["project_id"] == "project_001"
|
||||||
assert result["voice_clone_profile_id"] == "profile_001"
|
assert result["voice_clone_profile_id"] == "profile_001"
|
||||||
|
|||||||
Executable → Regular
@@ -435,8 +435,7 @@ class TestBuildFilterComplex:
|
|||||||
last_setpts = max(setpts_positions)
|
last_setpts = max(setpts_positions)
|
||||||
first_fps = min(fps_positions)
|
first_fps = min(fps_positions)
|
||||||
assert last_setpts < first_fps, (
|
assert last_setpts < first_fps, (
|
||||||
f"单视频: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。"
|
f"单视频: setpts(position={last_setpts}) 应该在 fps(position={first_fps}) 之前。" f"滤镜链: {chain_str}"
|
||||||
f"滤镜链: {chain_str}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_layers_raises(self):
|
def test_empty_layers_raises(self):
|
||||||
|
|||||||
Executable → Regular
Reference in New Issue
Block a user