diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 421d63385..d50dc24d2 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -90,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 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\ + 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\ \ 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 @@ -129,24 +129,51 @@ jobs: bash -n scripts/init_production_env.sh ' - - 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 + - name: Validate Alembic migrations (with isolated PG) shell: sh env: GITHUB_TOKEN: ${{ github.token }} - run: "set -eu\npython3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop\n" + 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" + - name: Job duration summary if: always() shell: sh diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index b5f6b775a..cdadd7a33 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -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 +from app.services import EditPlanService, EditTemplateService from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -64,6 +64,13 @@ class EditPlanUpdateRequest(BaseModel): ) +class CopyPlanRequest(BaseModel): + """复制剪辑计划请求体""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」") + project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目") + + class EditPlanResponse(BaseModel): """剪辑计划响应体""" @@ -457,12 +464,138 @@ def delete_plan( ) +@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED) +def copy_plan( + plan_id: str, + body: CopyPlanRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> EditPlanResponse: + """复制剪辑计划(含所有片段配置) + + 新计划状态为 editing,不含生成任务和结果记录。 + """ + svc = EditPlanService(db) + + # 源计划鉴权 + existing = svc.get_plan(plan_id) + if existing is None: + raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") + if existing.project_id: + check_project_access(existing.project_id, current_user.user.id, project_repository) + + # 目标项目鉴权(如果指定了不同的项目) + target_project_id = body.project_id if body.project_id is not None else existing.project_id + if target_project_id and target_project_id != existing.project_id: + check_project_access(target_project_id, current_user.user.id, project_repository) + + try: + new_plan = svc.copy_plan( + plan_id, + new_name=body.name, + project_id=target_project_id, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + logger.info( + "复制剪辑计划: source=%s target=%s by user=%s", + plan_id, + new_plan.id, + 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 (拆分模块) ──────────────────────────────────────────── from .edit_plans_ai import router as ai_router +from .edit_plans_clips import router as clips_router +from .edit_plans_clips_batch import router as clips_batch_router from .edit_plans_generation import router as generation_router from .edit_plans_timeline import router as timeline_router router.include_router(generation_router) router.include_router(ai_router) router.include_router(timeline_router) +router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"]) +router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"]) diff --git a/apps/api/app/api/routes/edit_plans_clips.py b/apps/api/app/api/routes/edit_plans_clips.py new file mode 100755 index 000000000..81342a132 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans_clips.py @@ -0,0 +1,278 @@ +"""剪辑计划片段(Clip)CRUD 路由。""" + +from __future__ import annotations + +import logging +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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +from packages.domain.edit_plan_clip import EditPlanClipStatus + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Schemas ────────────────────────────────────────────────────────────────── + +from pydantic import BaseModel, Field + + +class EditPlanClipResponse(BaseModel): + """剪辑片段响应体""" + + id: str + plan_id: str + clip_type: str + order: int + asset_id: str = "" + text_content: str = "" + start_time: float = 0.0 + duration: float = 0.0 + transition_effect: str = "cut" + transition_duration: float = 0.0 + playback_speed: float = 1.0 + status: str + config: dict[str, Any] = Field(default_factory=dict) + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +class EditPlanClipListResponse(BaseModel): + """剪辑片段列表响应体""" + + items: List[EditPlanClipResponse] + total: int + + +class EditPlanClipCreateRequest(BaseModel): + """创建剪辑片段请求体""" + + 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="文本内容(字幕/配音等)") + start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)") + duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)") + transition_effect: str = Field(default="cut", max_length=50, description="转场效果") + transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)") + playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率") + config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)") + + +class EditPlanClipUpdateRequest(BaseModel): + """更新剪辑片段请求体""" + + clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型") + order: Optional[int] = Field(default=None, ge=0, description="排序序号") + asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID") + text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容") + start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)") + duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)") + transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果") + transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)") + playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率") + config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)") + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +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 + + svc = EditPlanService(db) + plan = svc.get_plan(plan_id) + if plan is None: + raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") + if plan.project_id: + check_project_access(plan.project_id, user_id, project_repository) + return plan + + +def _clip_to_response(clip) -> EditPlanClipResponse: + """将领域对象转换为响应体""" + return EditPlanClipResponse( + id=clip.id, + plan_id=clip.plan_id, + clip_type=clip.clip_type, + order=clip.order, + asset_id=clip.asset_id or "", + text_content=clip.text_content or "", + start_time=clip.start_time, + duration=clip.duration, + transition_effect=clip.transition_effect or "cut", + transition_duration=clip.transition_duration or 0.0, + playback_speed=clip.playback_speed or 1.0, + status=clip.status.value if hasattr(clip.status, "value") else str(clip.status), + config=clip.config or {}, + created_at=clip.created_at.isoformat() if clip.created_at else None, + updated_at=clip.updated_at.isoformat() if clip.updated_at else None, + ) + + +def _get_svc(db: Session): + """获取 EditPlanService 实例""" + from app.services.edit_plan_service import EditPlanService + + return EditPlanService(db) + + +# ── Routes ─────────────────────────────────────────────────────────────────── + + +@router.get("", response_model=EditPlanClipListResponse) +def list_clips( + plan_id: str, + status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"), + skip: int = Query(0, ge=0, description="分页偏移"), + limit: int = Query(100, ge=1, le=500, description="每页数量"), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> EditPlanClipListResponse: + """获取剪辑计划的片段列表""" + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + status_enum = EditPlanClipStatus(status_filter) if status_filter else None + clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit) + total = svc.count_clips(plan_id, status=status_enum) + + return EditPlanClipListResponse( + items=[_clip_to_response(c) for c in clips], + total=total, + ) + + +@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED) +def create_clip( + plan_id: str, + body: EditPlanClipCreateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> EditPlanClipResponse: + """创建剪辑片段""" + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + try: + clip = svc.create_clip( + plan_id=plan_id, + clip_type=body.clip_type, + order=body.order, + asset_id=body.asset_id, + text_content=body.text_content, + start_time=body.start_time, + duration=body.duration, + transition_effect=body.transition_effect, + transition_duration=body.transition_duration, + playback_speed=body.playback_speed, + config=body.config, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id) + return _clip_to_response(clip) + + +@router.get("/{clip_id}", response_model=EditPlanClipResponse) +def get_clip( + plan_id: str, + clip_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> EditPlanClipResponse: + """获取剪辑片段详情""" + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + clip = svc.get_clip(clip_id) + if clip is None: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + if clip.plan_id != plan_id: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + + return _clip_to_response(clip) + + +@router.put("/{clip_id}", response_model=EditPlanClipResponse) +def update_clip( + plan_id: str, + clip_id: str, + body: EditPlanClipUpdateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> EditPlanClipResponse: + """更新剪辑片段""" + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + # 验证 clip 属于该 plan + clip = svc.get_clip(clip_id) + if clip is None: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + if clip.plan_id != plan_id: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + + try: + updated = svc.update_clip( + clip_id, + clip_type=body.clip_type, + order=body.order, + asset_id=body.asset_id, + text_content=body.text_content, + start_time=body.start_time, + duration=body.duration, + transition_effect=body.transition_effect, + transition_duration=body.transition_duration, + playback_speed=body.playback_speed, + config=body.config, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id) + return _clip_to_response(updated) + + +@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) +def delete_clip( + plan_id: str, + clip_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> None: + """删除剪辑片段""" + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + # 验证 clip 属于该 plan + clip = svc.get_clip(clip_id) + if clip is None: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + if clip.plan_id != plan_id: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + + deleted = svc.delete_clip(clip_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") + + logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id) + return None diff --git a/apps/api/app/api/routes/edit_plans_clips_batch.py b/apps/api/app/api/routes/edit_plans_clips_batch.py new file mode 100755 index 000000000..bb4a1fb7b --- /dev/null +++ b/apps/api/app/api/routes/edit_plans_clips_batch.py @@ -0,0 +1,241 @@ +"""剪辑计划片段批量操作 API。""" + +from __future__ import annotations + +import logging +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 fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Schemas ────────────────────────────────────────────────────────────────── + + +class ClipReorderItem(BaseModel): + """重排序条目""" + + clip_id: str + new_order: int = Field(..., ge=0, description="新的排序序号") + + +class ClipReorderRequest(BaseModel): + """片段重排序请求""" + + items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表") + + +class ClipReorderResponse(BaseModel): + """片段重排序响应""" + + success: bool + updated_count: int + message: str = "" + + +class ClipBatchDeleteRequest(BaseModel): + """批量删除片段请求""" + + clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表") + + +class ClipBatchDeleteResponse(BaseModel): + """批量删除片段响应""" + + success: bool + deleted_count: int + message: str = "" + + +class ClipsFromAssetsRequest(BaseModel): + """从素材批量创建片段请求""" + + asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") + clip_type: str = Field(default="main", description="片段类型,默认 main") + + +class ClipsFromAssetsResponse(BaseModel): + """从素材批量创建片段响应""" + + success: bool + created_count: int + message: str = "" + clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表") + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +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 + + svc = EditPlanService(db) + plan = svc.get_plan(plan_id) + if plan is None: + raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") + if plan.project_id: + check_project_access(plan.project_id, user_id, project_repository) + return plan + + +def _get_svc(db: Session): + """获取 EditPlanService 实例""" + from app.services.edit_plan_service import EditPlanService + + return EditPlanService(db) + + +# ── Routes ─────────────────────────────────────────────────────────────────── + + +@router.post("/reorder", response_model=ClipReorderResponse) +def reorder_clips( + plan_id: str, + body: ClipReorderRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ClipReorderResponse: + """批量重排序片段 + + 前端拖拽调整顺序后,一次性提交所有变更的 order。 + 自动触发编辑状态回退(从 completed/failed 切回 editing)。 + """ + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + + # 验证所有 clip 都属于该 plan + clip_ids = [item.clip_id for item in body.items] + existing_clips = svc.list_clips(plan_id, skip=0, limit=10000) + existing_ids = {c.id for c in existing_clips} + + invalid_ids = [cid for cid in clip_ids if cid not in existing_ids] + if invalid_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}", + ) + + # 执行重排序 + updated_count = 0 + for item in body.items: + try: + svc.update_clip(item.clip_id, order=item.new_order) + updated_count += 1 + except ValueError as e: + logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e) + + logger.info( + "批量重排序片段: plan_id=%s count=%d by user=%s", + plan_id, + updated_count, + current_user.user.id, + ) + + return ClipReorderResponse( + success=True, + updated_count=updated_count, + message=f"成功更新 {updated_count} 个片段的顺序", + ) + + +@router.post("/batch-delete", response_model=ClipBatchDeleteResponse) +def batch_delete_clips( + plan_id: str, + body: ClipBatchDeleteRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ClipBatchDeleteResponse: + """批量删除片段 + + 自动触发编辑状态回退(从 completed/failed 切回 editing)。 + """ + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + + # 验证所有 clip 都属于该 plan + existing_clips = svc.list_clips(plan_id, skip=0, limit=10000) + existing_ids = {c.id for c in existing_clips} + + valid_ids = [cid for cid in body.clip_ids if cid in existing_ids] + skipped = len(body.clip_ids) - len(valid_ids) + + # 执行删除 + deleted_count = 0 + for clip_id in valid_ids: + if svc.delete_clip(clip_id): + deleted_count += 1 + + message = f"成功删除 {deleted_count} 个片段" + if skipped > 0: + message += f",跳过 {skipped} 个不存在的片段" + + logger.info( + "批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s", + plan_id, + deleted_count, + skipped, + current_user.user.id, + ) + + return ClipBatchDeleteResponse( + success=True, + deleted_count=deleted_count, + message=message, + ) + + +@router.post("/from-assets", response_model=ClipsFromAssetsResponse) +def create_clips_from_assets( + plan_id: str, + body: ClipsFromAssetsRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ClipsFromAssetsResponse: + """从素材批量创建片段(追加到时间线末尾) + + 一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。 + 自动触发编辑状态回退(completed/failed → editing)。 + """ + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + + try: + clips = svc.create_clips_from_assets( + plan_id=plan_id, + asset_ids=body.asset_ids, + clip_type=body.clip_type, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + clip_ids = [c.id for c in clips] + + logger.info( + "从素材批量创建片段: plan_id=%s count=%d by user=%s", + plan_id, + len(clips), + current_user.user.id, + ) + + return ClipsFromAssetsResponse( + success=True, + created_count=len(clips), + message=f"成功创建 {len(clips)} 个片段", + clip_ids=clip_ids, + ) diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py old mode 100644 new mode 100755 index e7a51372d..ae7944a74 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from dataclasses import replace from datetime import datetime, timezone from typing import List @@ -20,6 +21,8 @@ from fastapi import APIRouter, Depends, HTTPException, status from packages.ports.user_repository import UserRepository +logger = logging.getLogger(__name__) + router = APIRouter() @@ -254,7 +257,9 @@ async def payment_callback( return {"success": True, "message": "支付成功", "record_id": record_id} except Exception as e: session.rollback() - raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e + logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}") + # 不返回原始异常信息,避免泄漏内部实现细节 + raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e finally: session.close() diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 5d3c90c90..9e9df4eca 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -447,6 +447,62 @@ class EditPlanService: logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) return count + def create_clips_from_assets( + self, + plan_id: str, + asset_ids: list[str], + *, + clip_type: str = "main", + ) -> list[EditPlanClip]: + """从素材批量创建片段(追加到时间线末尾)。 + + Args: + plan_id: 计划 ID + asset_ids: 素材 ID 列表(按顺序追加) + clip_type: 片段类型 + + Returns: + list[EditPlanClip]: 创建的片段列表 + """ + if not asset_ids: + return [] + + # 确保计划存在 + 自动回退状态 + self.get_plan_or_raise(plan_id) + self._auto_resume_editing(plan_id) + + # 查询素材信息(取 duration) + from packages.adapters.sqlalchemy_impl.models import AssetModel + + session = self._clip_repo.session # type: ignore[attr-defined] + assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all() + asset_map = {a.id: a for a in assets} + + # 从现有片段数量开始追加 + existing_count = self._clip_repo.count(plan_id=plan_id) + + # 批量创建片段 + created: list[EditPlanClip] = [] + for i, asset_id in enumerate(asset_ids): + asset = asset_map.get(asset_id) + duration = asset.duration if asset and asset.duration else 0.0 + + clip = self.create_clip( + plan_id=plan_id, + clip_type=clip_type, + order=existing_count + i, + asset_id=asset_id, + duration=duration, + ) + created.append(clip) + + logger.info( + "从素材批量创建片段: plan_id=%s count=%d", + plan_id, + len(created), + ) + return created + # ── 渲染生成流程 ──────────────────────────────────────────────────────── def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]: @@ -570,3 +626,85 @@ class EditPlanService: updated_at=plan.updated_at, ) return self._plan_repo.update(updated) + + # ── 复制计划 ──────────────────────────────────────────────────────────── + + def copy_plan( + self, + plan_id: str, + *, + new_name: Optional[str] = None, + project_id: Optional[str] = None, + ) -> EditPlan: + """复制一个剪辑计划(含所有片段配置)。 + + 新计划状态为 editing,不含生成任务和结果记录。 + + Args: + plan_id: 源计划 ID + new_name: 新计划名称,不传则为「原名 - 副本」 + project_id: 新计划的项目 ID,不传则复用源计划 + + Returns: + EditPlan: 新创建的计划 + + Raises: + ValueError: 源计划不存在 + """ + source = self.get_plan_or_raise(plan_id) + source_clips = self._clip_repo.list_by_plan(plan_id) + + # 新计划名称 + name = new_name or f"{source.name} - 副本" + new_project_id = project_id if project_id is not None else source.project_id + + # 复制 plan 配置(去除渲染结果相关字段) + new_config = dict(source.config) + new_config.pop("rendered_url", None) + new_config.pop("rendered_storage_key", None) + new_config.pop("generation_task_id", None) + + # 创建新计划 + new_plan = EditPlan.create( + template_id=source.template_id, + name=name, + config=new_config, + total_duration=source.total_duration, + project_id=new_project_id, + created_by_user_id=source.created_by_user_id, + source_edit_plan_id=plan_id, + ) + # 强制切到 editing 状态 + if new_plan.status != EditPlanStatus.EDITING: + try: + new_plan.start_editing() + except ValueError: + pass + + created_plan = self._plan_repo.create(new_plan) + logger.info( + "复制剪辑计划: source=%s target=%s name=%s clips=%d", + plan_id, + created_plan.id, + name, + len(source_clips), + ) + + # 复制所有片段 + for clip in source_clips: + new_clip = self.create_clip( + plan_id=created_plan.id, + clip_type=clip.clip_type, + order=clip.order, + asset_id=clip.asset_id or "", + text_content=clip.text_content or "", + start_time=clip.start_time, + duration=clip.duration, + transition_effect=clip.transition_effect or "cut", + transition_duration=clip.transition_duration or 0.0, + playback_speed=clip.playback_speed or 1.0, + config=dict(clip.config) if clip.config else None, + ) + logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order) + + return self.get_plan_or_raise(created_plan.id) diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py old mode 100644 new mode 100755 index e5bbc23ce..7ee940258 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -12,9 +12,13 @@ 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, @@ -37,6 +41,9 @@ 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 ────────────────────────────────────────────────────────── @@ -394,3 +401,137 @@ 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, + } diff --git a/packages/application/voice_clone/workflow.py b/packages/application/voice_clone/workflow.py index 458193609..896b43ff6 100755 --- a/packages/application/voice_clone/workflow.py +++ b/packages/application/voice_clone/workflow.py @@ -24,6 +24,7 @@ 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__) @@ -103,6 +104,15 @@ 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) @@ -243,6 +253,15 @@ 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, diff --git a/scripts/check_migration_safety.py b/scripts/check_migration_safety.py index 805180c48..17649b270 100644 --- a/scripts/check_migration_safety.py +++ b/scripts/check_migration_safety.py @@ -195,16 +195,28 @@ 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( - ["git", "diff", "--name-only", "--diff-filter=A", f"{diff_target}...HEAD"], + diff_args, capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=10, ) if result.returncode != 0: - print(f" (git diff 失败:{result.stderr.strip()})") - return None + # 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 new_migrations = [] for line in result.stdout.strip().split("\n"): diff --git a/tests/unit/test_edit_template_service.py b/tests/unit/test_edit_template_service.py old mode 100644 new mode 100755 index b2cbbbd0c..41eb14f90 --- a/tests/unit/test_edit_template_service.py +++ b/tests/unit/test_edit_template_service.py @@ -463,3 +463,291 @@ 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 diff --git a/tests/unit/test_voice_clone_workflow.py b/tests/unit/test_voice_clone_workflow.py old mode 100644 new mode 100755 index cd9b67833..7820e1adc --- a/tests/unit/test_voice_clone_workflow.py +++ b/tests/unit/test_voice_clone_workflow.py @@ -169,6 +169,69 @@ 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 ───────────────────────────────── @@ -313,6 +376,27 @@ 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 ───────────────────────────────