Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57ce480aee | |||
| f0f9a38d27 | |||
| 986fc92da3 | |||
| 4a1b348906 |
@@ -35,7 +35,7 @@ concurrency:
|
||||
jobs:
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: ci-l2
|
||||
runs-on: host
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
strategy:
|
||||
@@ -157,11 +157,8 @@ jobs:
|
||||
'
|
||||
deploy-staging:
|
||||
name: Deploy Staging (Watchtower auto-deploy)
|
||||
runs-on: ci-l3
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: deploy-staging-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
needs:
|
||||
- build-staging
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
@@ -311,7 +308,7 @@ jobs:
|
||||
'
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
runs-on: ci-l3
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
@@ -391,7 +388,7 @@ jobs:
|
||||
'
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: ci-l2
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
@@ -471,7 +468,7 @@ jobs:
|
||||
'
|
||||
build-production:
|
||||
name: Build Production ${{ matrix.service_display }} Image
|
||||
runs-on: ci-l2
|
||||
runs-on: host
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -595,11 +592,8 @@ jobs:
|
||||
'
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: ci-l3
|
||||
runs-on: saas
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: deploy-production-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs:
|
||||
- build-production
|
||||
@@ -741,7 +735,7 @@ jobs:
|
||||
'
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: ci-l3
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
+23
-90
@@ -30,48 +30,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
else
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "🔧 包含后端/公共变更,运行完整CI"
|
||||
fi
|
||||
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ci-l1
|
||||
runs-on: host
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
@@ -126,7 +90,7 @@ jobs:
|
||||
'
|
||||
- name: Secret detection (detect-secrets)
|
||||
shell: sh
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n > /tmp/secrets-scan.json 2>&1\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n 2>&1 | tee /tmp/secrets-scan.json\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
|
||||
- name: Calculate changed Python files (incremental scan)
|
||||
shell: sh
|
||||
@@ -165,51 +129,24 @@ jobs:
|
||||
bash -n scripts/init_production_env.sh
|
||||
|
||||
'
|
||||
- name: Validate Alembic migrations (with isolated PG)
|
||||
- name: Validate Alembic migrations
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
|
||||
python3 scripts/check_schema_metadata.py
|
||||
|
||||
'
|
||||
- name: Check migration safety
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$}
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 3s \
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "PostgreSQL is ready on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
python3 scripts/check_schema_metadata.py
|
||||
# Initialize git for migration safety diff (CI checkout is tar.gz without .git)
|
||||
git init > /dev/null 2>&1
|
||||
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
||||
git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1
|
||||
git add -A > /dev/null 2>&1
|
||||
git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1
|
||||
python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
echo "PostgreSQL container cleaned up"
|
||||
|
||||
run: "set -eu\npython3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop\n"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -226,10 +163,8 @@ jobs:
|
||||
|
||||
'
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Unit Tests
|
||||
runs-on: ci-l2
|
||||
runs-on: host
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
USE_IN_MEMORY_DB: 'true'
|
||||
@@ -300,12 +235,10 @@ jobs:
|
||||
'
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: ci-l2
|
||||
runs-on: host
|
||||
timeout-minutes: 30
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate
|
||||
if: always()
|
||||
needs: validate
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -419,7 +352,7 @@ jobs:
|
||||
'
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l1
|
||||
runs-on: host
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -24,7 +24,7 @@ from typing import Any, List, Optional
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService, EditTemplateService
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -506,84 +506,6 @@ def copy_plan(
|
||||
current_user.user.id,
|
||||
)
|
||||
return _to_response(new_plan)
|
||||
# ── 保存为模板 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SaveAsTemplateRequest(BaseModel):
|
||||
"""保存为模板请求体"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
description: str = Field(default="", max_length=500, description="模板描述")
|
||||
template_type: str = Field(default="custom", max_length=50, description="模板类型")
|
||||
preview_url: str = Field(default="", max_length=500, description="预览图 URL")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/save-as-template",
|
||||
response_model=dict[str, Any],
|
||||
summary="将剪辑计划保存为模板",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def save_plan_as_template(
|
||||
plan_id: str,
|
||||
body: SaveAsTemplateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将指定剪辑计划的配置和片段结构保存为一个新模板。
|
||||
|
||||
新模板会复制计划的所有片段配置(类型、时长、转场、文案等),
|
||||
但不绑定具体素材,可重复用于创建新的剪辑计划。
|
||||
"""
|
||||
# 校验计划存在性和项目权限
|
||||
plan_service = EditPlanService(db)
|
||||
plan = plan_service.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
if plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
template_service = EditTemplateService(db)
|
||||
try:
|
||||
result = template_service.save_plan_as_template(
|
||||
plan_id=plan_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
preview_url=body.preview_url,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
template = result["template"]
|
||||
clip_configs = result["clip_configs"]
|
||||
|
||||
logger.info(
|
||||
"保存计划为模板: plan_id=%s template_id=%s name=%s by user=%s",
|
||||
plan_id,
|
||||
template.id,
|
||||
body.name,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"template_type": template.template_type,
|
||||
"editing_mode": template.editing_mode,
|
||||
"preview_url": template.preview_url,
|
||||
"status": template.status.value,
|
||||
"clip_count": len(clip_configs),
|
||||
"created_at": template.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||
|
||||
@@ -52,9 +52,7 @@ class EditPlanClipListResponse(BaseModel):
|
||||
class EditPlanClipCreateRequest(BaseModel):
|
||||
"""创建剪辑片段请求体"""
|
||||
|
||||
clip_type: str = Field(
|
||||
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
|
||||
)
|
||||
clip_type: str = Field(..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等")
|
||||
order: int = Field(..., ge=0, description="排序序号")
|
||||
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
|
||||
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
|
||||
@@ -88,9 +86,8 @@ def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db:
|
||||
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
|
||||
返回 plan 对象供后续使用,避免重复查询。
|
||||
"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
|
||||
@@ -75,9 +75,8 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
|
||||
Executable → Regular
+1
-6
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
@@ -21,8 +20,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -257,9 +254,7 @@ async def payment_callback(
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
|
||||
# 不返回原始异常信息,避免泄漏内部实现细节
|
||||
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
|
||||
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
Executable → Regular
-141
@@ -12,13 +12,9 @@ from typing import Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
SQLAlchemyEditPlanRepository,
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
@@ -41,9 +37,6 @@ class EditTemplateService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._template_repo = SQLAlchemyEditTemplateRepository(db)
|
||||
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
self._db = db
|
||||
|
||||
# ── 模板 CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -401,137 +394,3 @@ class EditTemplateService:
|
||||
"template": template,
|
||||
"clip_configs": clip_configs,
|
||||
}
|
||||
|
||||
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
|
||||
|
||||
def save_plan_as_template(
|
||||
self,
|
||||
plan_id: str,
|
||||
name: str,
|
||||
*,
|
||||
description: str = "",
|
||||
template_type: str = "custom",
|
||||
preview_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""将剪辑计划保存为模板
|
||||
|
||||
将指定剪辑计划的配置和片段结构另存为一个新模板,
|
||||
方便后续基于该模板快速创建新的剪辑计划。
|
||||
|
||||
转换规则:
|
||||
- 计划名称 → 模板名称(调用方传入,支持自定义)
|
||||
- 计划 config → 模板 config(整体迁移)
|
||||
- 计划 editing_mode 从 config 中提取,默认 one_take
|
||||
- 每个片段转换为模板片段配置:
|
||||
- clip_type 直接映射
|
||||
- order 保持不变
|
||||
- duration → min_duration = max_duration = duration(固定时长)
|
||||
- text_content → text_template
|
||||
- transition_effect 直接映射
|
||||
- playback_speed 等播放参数存入 config
|
||||
- 不保留 asset_id(模板不绑定具体素材)
|
||||
|
||||
Args:
|
||||
plan_id: 源剪辑计划 ID
|
||||
name: 新模板名称
|
||||
description: 模板描述
|
||||
template_type: 模板类型,默认 custom(用户自定义)
|
||||
preview_url: 预览图 URL
|
||||
|
||||
Returns:
|
||||
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
|
||||
|
||||
Raises:
|
||||
ValueError: 计划不存在或名称为空/重复
|
||||
"""
|
||||
# 1. 读取源计划
|
||||
plan = self._plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
raise ValueError(f"剪辑计划不存在: {plan_id}")
|
||||
|
||||
# 2. 读取所有片段(按 order 排序)
|
||||
clips = self._plan_clip_repo.list_by_plan(plan_id)
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
# 3. 提取 editing_mode
|
||||
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
|
||||
|
||||
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
|
||||
# 名称重复检查
|
||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||
for t in existing:
|
||||
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
|
||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
||||
|
||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
||||
plan_config = plan.config or {}
|
||||
template_config: dict[str, Any] = {}
|
||||
for key, value in plan_config.items():
|
||||
# 跳过明显的运行时/实例字段,保留风格/模式类配置
|
||||
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
|
||||
template_config[key] = value
|
||||
|
||||
template = EditTemplate.create(
|
||||
name=clean_name,
|
||||
description=description,
|
||||
template_type=template_type,
|
||||
editing_mode=editing_mode,
|
||||
config=template_config,
|
||||
preview_url=preview_url,
|
||||
)
|
||||
created_template = self._template_repo.create(template)
|
||||
logger.info(
|
||||
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
|
||||
plan_id,
|
||||
created_template.id,
|
||||
clean_name,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
# 5. 转换每个片段为模板片段配置
|
||||
created_configs: List[TemplateClipConfig] = []
|
||||
for clip in clips:
|
||||
clip_config: dict[str, Any] = {}
|
||||
# 播放速度存入 config
|
||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||
clip_config["playback_speed"] = clip.playback_speed
|
||||
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
|
||||
if clip.config:
|
||||
clip_config.update(clip.config)
|
||||
# 去掉素材相关字段
|
||||
clip_config.pop("asset_info", None)
|
||||
clip_config.pop("source_asset_id", None)
|
||||
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
transition = TransitionEffect(clip.transition_effect)
|
||||
except ValueError:
|
||||
transition = TransitionEffect.CUT
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
clip_type = ClipType(clip.clip_type)
|
||||
except ValueError:
|
||||
clip_type = ClipType.MAIN
|
||||
|
||||
clip_config_obj = TemplateClipConfig.create(
|
||||
template_id=created_template.id,
|
||||
clip_type=clip_type,
|
||||
order=clip.order,
|
||||
min_duration=clip.duration,
|
||||
max_duration=clip.duration,
|
||||
text_template=clip.text_content or "",
|
||||
transition_effect=transition,
|
||||
config=clip_config,
|
||||
)
|
||||
created = self._clip_config_repo.create(clip_config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
return {
|
||||
"template": created_template,
|
||||
"clip_configs": created_configs,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ from packages.application.voice_clone.use_cases import (
|
||||
)
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
||||
from packages.shared.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -104,15 +103,6 @@ class VoiceCloneWorkflowService:
|
||||
|
||||
# 2. 提交 CosyVoice 克隆任务(仅有音频 URL 时才标记 processing)
|
||||
if source_audio_url:
|
||||
# SSRF 防护:校验音频 URL 安全性
|
||||
try:
|
||||
source_audio_url = validate_url_safety(source_audio_url, purpose="download")
|
||||
except UrlSecurityError as e:
|
||||
profile.mark_failed(f"音频URL安全校验失败: {e}")
|
||||
profile = self.repository.update(profile)
|
||||
logger.warning(f"音色克隆音频URL安全校验失败: profile_id={profile.id}, error={e}")
|
||||
return profile
|
||||
|
||||
# 标记为 processing
|
||||
profile.mark_processing()
|
||||
profile = self.repository.update(profile)
|
||||
@@ -253,15 +243,6 @@ class VoiceCloneWorkflowService:
|
||||
|
||||
# 3. 重新提交 CosyVoice
|
||||
if profile.source_audio_url:
|
||||
# SSRF 防护:重新校验音频 URL 安全性
|
||||
try:
|
||||
validate_url_safety(profile.source_audio_url, purpose="download")
|
||||
except UrlSecurityError as e:
|
||||
profile.mark_failed(f"音频URL安全校验失败: {e}")
|
||||
profile = self.repository.update(profile)
|
||||
logger.warning(f"音色克隆重试音频URL安全校验失败: profile_id={clone_id}, error={e}")
|
||||
return profile
|
||||
|
||||
try:
|
||||
submit_result = self.cosyvoice_service.submit_clone_task(
|
||||
audio_url=profile.source_audio_url,
|
||||
|
||||
@@ -195,28 +195,16 @@ def get_new_migrations_via_git(diff_target: str) -> List[Path] | None:
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 优先使用三点diff(找合并基线),失败时回退到两点diff(兼容tar.gz checkout + git init的CI环境)
|
||||
diff_args = ["git", "diff", "--name-only", "--diff-filter=A", f"{diff_target}...HEAD"]
|
||||
result = subprocess.run(
|
||||
diff_args,
|
||||
["git", "diff", "--name-only", "--diff-filter=A", f"{diff_target}...HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# fallback: 两点diff(无需共同祖先)
|
||||
diff_args_2 = ["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD"]
|
||||
result = subprocess.run(
|
||||
diff_args_2,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" (git diff 失败:{result.stderr.strip()})")
|
||||
return None
|
||||
print(f" (git diff 失败:{result.stderr.strip()})")
|
||||
return None
|
||||
|
||||
new_migrations = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
|
||||
+19
-208
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式,支持自动回滚)
|
||||
# Staging 部署脚本(SSH 模式)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 staging 服务器上执行
|
||||
#
|
||||
@@ -12,44 +12,9 @@
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
# ---- 重试工具函数 ----
|
||||
retry_cmd() {
|
||||
local max_attempts=$1
|
||||
local backoff=$2
|
||||
shift 2
|
||||
local attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
echo " attempt $attempt/$max_attempts failed, retrying in ${backoff}s..."
|
||||
sleep $backoff
|
||||
backoff=$((backoff * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo " ERROR: failed after $max_attempts retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_docker_login() {
|
||||
echo "Logging in to registry (up to 3 retries)"
|
||||
if retry_cmd 3 5 sh -c "printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin"; then
|
||||
return 0
|
||||
fi
|
||||
echo "WARN: docker login failed after retries, will try pull anyway"
|
||||
return 0
|
||||
}
|
||||
|
||||
retry_docker_pull() {
|
||||
local image=$1
|
||||
echo "Pulling $image (up to 3 retries)"
|
||||
retry_cmd 3 10 docker pull "$image"
|
||||
}
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
|
||||
@@ -60,7 +25,6 @@ GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
@@ -71,165 +35,17 @@ test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "==========================================="
|
||||
echo "=========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG"
|
||||
echo "==========================================="
|
||||
|
||||
# ---- 记录当前运行的镜像版本(用于回滚) ----
|
||||
echo "Recording current image versions for rollback..."
|
||||
PREV_API_IMAGE=""
|
||||
PREV_WORKER_IMAGE=""
|
||||
PREV_WEB_IMAGE=""
|
||||
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
if docker inspect "$c" >/dev/null 2>&1; then
|
||||
img=$(docker inspect -f '{{.Config.Image}}' "$c")
|
||||
case "$c" in
|
||||
xiaoxia-api-staging) PREV_API_IMAGE="$img" ;;
|
||||
xiaoxia-worker-staging) PREV_WORKER_IMAGE="$img" ;;
|
||||
xiaoxia-web-staging) PREV_WEB_IMAGE="$img" ;;
|
||||
esac
|
||||
echo " $c -> $img"
|
||||
else
|
||||
echo " $c -> (not running)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 回滚函数 ----
|
||||
rollback() {
|
||||
echo ""
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo " 部署失败,正在自动回滚到上一版本..."
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo ""
|
||||
|
||||
if [ "$SKIP_ROLLBACK" = "true" ]; then
|
||||
echo "SKIP_ROLLBACK=true,跳过自动回滚"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止当前(失败的)新容器
|
||||
echo "Stopping new containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 恢复 API
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Rolling back API to: $PREV_API_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_API_IMAGE"
|
||||
else
|
||||
echo "No previous API image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Worker
|
||||
if [ -n "$PREV_WORKER_IMAGE" ]; then
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WORKER_IMAGE"
|
||||
else
|
||||
echo "No previous Worker image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Web
|
||||
if [ -n "$PREV_WEB_IMAGE" ]; then
|
||||
echo "Rolling back Web to: $PREV_WEB_IMAGE"
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WEB_IMAGE"
|
||||
else
|
||||
echo "No previous Web image to roll back to"
|
||||
fi
|
||||
|
||||
# 等待 API 回滚后恢复健康
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Waiting for rolled-back API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "Rolled-back API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "WARN: Rolled-back API did not become healthy within 120s"
|
||||
docker logs --tail 30 xiaoxia-api-staging
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " 回滚完成"
|
||||
echo "==========================================="
|
||||
echo "Previous API: ${PREV_API_IMAGE:-none}"
|
||||
echo "Previous Worker: ${PREV_WORKER_IMAGE:-none}"
|
||||
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
|
||||
echo ""
|
||||
echo "部署失败,已自动回滚到上一版本"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
exit 1
|
||||
}
|
||||
echo "=========================================="
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "=========================================="
|
||||
echo " Login to Registry (with retries)"
|
||||
echo "=========================================="
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
retry_docker_login
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 新版本镜像 ----
|
||||
@@ -241,12 +57,12 @@ LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Pull images (with retries)"
|
||||
echo "=========================================="
|
||||
retry_docker_pull "$REGISTRY_API"
|
||||
retry_docker_pull "$REGISTRY_WORKER"
|
||||
retry_docker_pull "$REGISTRY_WEB"
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# Re-tag 成本地名
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
@@ -301,12 +117,7 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head" || {
|
||||
echo "ERROR: Database migration failed"
|
||||
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
|
||||
echo "Please manually check and fix the migration, then redeploy"
|
||||
exit 1
|
||||
}
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
@@ -340,7 +151,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API" || rollback
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
@@ -363,7 +174,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER" || rollback
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
@@ -386,7 +197,7 @@ docker run -d \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB" || rollback
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
@@ -404,7 +215,7 @@ done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
@@ -423,7 +234,7 @@ done
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
rollback
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
|
||||
Executable → Regular
-288
@@ -463,291 +463,3 @@ class TestCompositeQueries:
|
||||
t = svc.create_template(name="空模板")
|
||||
result = svc.get_template_with_configs(t.id)
|
||||
assert len(result["clip_configs"]) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stub Repositories for EditPlan (save_as_template 测试用)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
"""内存中的 EditPlan 仓储 stub"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plans: dict[str, EditPlan] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
if not plan.id:
|
||||
plan.id = self._next_id()
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
"""内存中的 EditPlanClip 仓储 stub"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clips: dict[str, EditPlanClip] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-{self._counter:03d}"
|
||||
|
||||
def list_by_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[EditPlanClip]:
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status:
|
||||
items = [c for c in items if c.status.value == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
|
||||
def _make_service_with_plan_stubs():
|
||||
"""创建使用 stub 仓储的 EditTemplateService(含 plan 相关 stub)"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
db = MagicMock()
|
||||
svc = EditTemplateService(db)
|
||||
svc._template_repo = StubEditTemplateRepository()
|
||||
svc._clip_config_repo = StubTemplateClipConfigRepository()
|
||||
svc._plan_repo = StubEditPlanRepository()
|
||||
svc._plan_clip_repo = StubEditPlanClipRepository()
|
||||
return svc
|
||||
|
||||
|
||||
def _make_test_plan_with_clips(svc, *, clip_count: int = 3, plan_config=None):
|
||||
"""辅助方法:创建一个带片段的测试计划,返回 plan 对象"""
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
plan = EditPlan.create(
|
||||
template_id="tpl-source",
|
||||
name="我的剪辑计划",
|
||||
config=plan_config or {"editing_mode": "one_take", "theme": "minimal"},
|
||||
project_id="proj-001",
|
||||
created_by_user_id="user-001",
|
||||
)
|
||||
plan.id = "plan-test-001"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
for i in range(clip_count):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan.id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=i,
|
||||
asset_id=f"asset-{i:03d}",
|
||||
text_content=f"片段{i}的文案",
|
||||
duration=10.0 + i * 5,
|
||||
transition_effect="cut" if i == 0 else "fade",
|
||||
playback_speed=1.0 if i == 0 else 1.5,
|
||||
config={"filter": "vivid"} if i == 1 else {},
|
||||
)
|
||||
svc._plan_clip_repo.create(clip)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 保存为模板测试
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSavePlanAsTemplate:
|
||||
"""从剪辑计划保存为模板测试"""
|
||||
|
||||
def test_basic_save_as_template(self):
|
||||
"""基础场景:将有3个片段的计划保存为模板"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=3)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="我的自定义模板")
|
||||
|
||||
assert result["template"].name == "我的自定义模板"
|
||||
assert result["template"].template_type == "custom"
|
||||
assert result["template"].editing_mode == "one_take"
|
||||
assert result["template"].status == EditTemplateStatus.ACTIVE
|
||||
assert len(result["clip_configs"]) == 3
|
||||
|
||||
def test_clip_configs_correctly_converted(self):
|
||||
"""片段正确转换为模板片段配置"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=2)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="转换测试模板")
|
||||
configs = result["clip_configs"]
|
||||
configs.sort(key=lambda c: c.order)
|
||||
|
||||
# 第0个片段
|
||||
assert configs[0].clip_type == ClipType.MAIN
|
||||
assert configs[0].order == 0
|
||||
assert configs[0].min_duration == 10.0
|
||||
assert configs[0].max_duration == 10.0
|
||||
assert configs[0].text_template == "片段0的文案"
|
||||
assert configs[0].transition_effect.value == "cut"
|
||||
# playback_speed=1.0 不存
|
||||
assert "playback_speed" not in configs[0].config
|
||||
|
||||
# 第1个片段
|
||||
assert configs[1].order == 1
|
||||
assert configs[1].min_duration == 15.0
|
||||
assert configs[1].max_duration == 15.0
|
||||
assert configs[1].transition_effect.value == "fade"
|
||||
# playback_speed=1.5 存入config
|
||||
assert configs[1].config.get("playback_speed") == 1.5
|
||||
# config 中的 filter 保留
|
||||
assert configs[1].config.get("filter") == "vivid"
|
||||
|
||||
def test_no_asset_id_in_template(self):
|
||||
"""模板不保留具体素材ID"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=2)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="素材剥离测试")
|
||||
|
||||
for cfg in result["clip_configs"]:
|
||||
# 模板片段配置没有 asset_id 字段
|
||||
assert not hasattr(cfg, "asset_id") or not getattr(cfg, "asset_id", "")
|
||||
# config 中也不应有素材相关字段
|
||||
assert "asset_info" not in cfg.config
|
||||
assert "source_asset_id" not in cfg.config
|
||||
|
||||
def test_template_config_stripped_of_runtime_fields(self):
|
||||
"""模板config剥离运行时字段"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan_config = {
|
||||
"editing_mode": "one_take",
|
||||
"theme": "cinematic",
|
||||
"asset_ids": ["a1", "a2"],
|
||||
"source_edit_plan_id": "old-plan",
|
||||
"generation_task_id": "task-123",
|
||||
}
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1, plan_config=plan_config)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="配置剥离测试")
|
||||
|
||||
tpl_config = result["template"].config
|
||||
assert tpl_config.get("theme") == "cinematic"
|
||||
assert "asset_ids" not in tpl_config
|
||||
assert "source_edit_plan_id" not in tpl_config
|
||||
assert "generation_task_id" not in tpl_config
|
||||
|
||||
def test_plan_not_found_raises_error(self):
|
||||
"""计划不存在时报错"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
|
||||
with pytest.raises(ValueError, match="剪辑计划不存在"):
|
||||
svc.save_plan_as_template("nonexistent-plan", name="不存在的计划")
|
||||
|
||||
def test_empty_name_raises_error(self):
|
||||
"""模板名称为空时报错"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||||
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
svc.save_plan_as_template(plan.id, name=" ")
|
||||
|
||||
def test_duplicate_name_raises_error(self):
|
||||
"""模板名称重复时报错"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
svc.create_template(name="重名模板")
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||||
|
||||
with pytest.raises(ValueError, match="模板名称已存在"):
|
||||
svc.save_plan_as_template(plan.id, name="重名模板")
|
||||
|
||||
def test_save_zero_clip_plan(self):
|
||||
"""零片段计划也能保存为模板"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
|
||||
plan = EditPlan.create(
|
||||
template_id="tpl-source",
|
||||
name="空计划",
|
||||
config={"editing_mode": "one_take"},
|
||||
)
|
||||
plan.id = "plan-empty"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="空模板")
|
||||
|
||||
assert result["template"].name == "空模板"
|
||||
assert len(result["clip_configs"]) == 0
|
||||
|
||||
def test_custom_description_and_type(self):
|
||||
"""自定义描述和模板类型"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||||
|
||||
result = svc.save_plan_as_template(
|
||||
plan.id,
|
||||
name="自定义模板",
|
||||
description="这是一个测试模板",
|
||||
template_type="vlog",
|
||||
)
|
||||
|
||||
assert result["template"].description == "这是一个测试模板"
|
||||
assert result["template"].template_type == "vlog"
|
||||
|
||||
def test_unknown_transition_effect_falls_back_to_cut(self):
|
||||
"""未知转场效果回退到cut"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
plan = EditPlan.create(template_id="tpl-src", name="转场测试计划")
|
||||
plan.id = "plan-transition-test"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan.id,
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=10.0,
|
||||
transition_effect="weird_effect_that_does_not_exist",
|
||||
)
|
||||
svc._plan_clip_repo.create(clip)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="转场兼容模板")
|
||||
assert result["clip_configs"][0].transition_effect.value == "cut"
|
||||
|
||||
def test_unknown_clip_type_falls_back_to_main(self):
|
||||
"""未知片段类型回退到main"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
plan = EditPlan.create(template_id="tpl-src", name="类型测试计划")
|
||||
plan.id = "plan-type-test"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan.id,
|
||||
clip_type="unknown_clip_type",
|
||||
order=0,
|
||||
duration=10.0,
|
||||
)
|
||||
svc._plan_clip_repo.create(clip)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="类型兼容模板")
|
||||
assert result["clip_configs"][0].clip_type == ClipType.MAIN
|
||||
|
||||
Executable → Regular
-84
@@ -169,69 +169,6 @@ class TestStartClone:
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_internal_url_rejected(self) -> None:
|
||||
"""SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="http://127.0.0.1/audio.wav",
|
||||
)
|
||||
|
||||
# 内网 IP 应该被拒绝,标记为 failed
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in profile.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_private_ip_rejected(self) -> None:
|
||||
"""SSRF 防护:私有网段 IP 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="http://192.168.1.100/audio.wav",
|
||||
)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in profile.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_public_url_passes(self) -> None:
|
||||
"""SSRF 防护:正常公网 URL 应该通过校验。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-ssrf-test",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-ssrf",
|
||||
}
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
)
|
||||
|
||||
# 公网 URL 应该正常通过
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
mock_cosyvoice.submit_clone_task.assert_called_once()
|
||||
|
||||
|
||||
# ── process_clone_result ─────────────────────────────────
|
||||
|
||||
@@ -376,27 +313,6 @@ class TestRetryClone:
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert "重试失败" in result.error_message
|
||||
|
||||
def test_retry_clone_ssrf_internal_url_rejected(self) -> None:
|
||||
"""重试时 SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(
|
||||
status=VoiceCloneStatus.FAILED,
|
||||
source_audio_url="http://10.0.0.1/secret.wav",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.retry_clone(profile.id, "user-123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in result.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
|
||||
# ── poll_and_process_clone ───────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user