From 7939d4241cb70abc99fbd4e0d9476244795020cc Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 16 Jul 2026 11:35:11 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=AF=BC=E5=87=BA=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=20-=20=E5=88=86=E8=BE=A8=E7=8E=87/=E5=B8=A7=E7=8E=87/?= =?UTF-8?q?=E7=A0=81=E7=8E=87/=E6=A0=BC=E5=BC=8F/=E6=B0=B4=E5=8D=B0=20+=20?= =?UTF-8?q?5=E5=A5=97=E9=A2=84=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportConfig 加入计划配置schema,向后兼容 - GET /export-presets 5套导出预设(720P/1080P/1080P高帧/4K/ProRes) - GET /{plan_id}/export 获取导出配置 - PUT /{plan_id}/export 更新导出配置 - 支持分辨率/帧率/视频码率/音频码率/格式/质量预设/水印 - 分辨率格式校验、范围校验 - 16个单元测试全覆盖 --- apps/api/app/api/routes/edit_plans.py | 8 + apps/api/app/api/routes/edit_plans_export.py | 274 +++++++++++++++ packages/domain/config_schemas.py | 31 ++ tests/unit/test_edit_plan_export_api.py | 350 +++++++++++++++++++ 4 files changed, 663 insertions(+) create mode 100755 apps/api/app/api/routes/edit_plans_export.py create mode 100644 tests/unit/test_edit_plan_export_api.py diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 4285125ab..697db65b9 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -258,6 +258,14 @@ def _to_response(p: EditPlan) -> EditPlanResponse: ) +# ── Include sub-routers (拆分模块) ──────────────────────────────────────────── +# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配 + +from .edit_plans_export import router as export_router + +router.include_router(export_router) + + # ── CRUD Routes ─────────────────────────────────────────────────────────────── diff --git a/apps/api/app/api/routes/edit_plans_export.py b/apps/api/app/api/routes/edit_plans_export.py new file mode 100755 index 000000000..63f568c43 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans_export.py @@ -0,0 +1,274 @@ +"""导出设置 API. + +- GET /{plan_id}/export 获取导出配置 +- PUT /{plan_id}/export 更新导出配置 +- GET /export-presets 导出预设列表 +""" + +from __future__ import annotations + +import logging +import re +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.services import EditPlanService +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, validator +from sqlalchemy.orm import Session + +from packages.domain.config_schemas import normalize_plan_config + +from ._helpers import check_project_access + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── 导出预设 ────────────────────────────────────────────────────────────────── + +EXPORT_PRESETS = [ + { + "id": "export_1080p_30", + "name": "1080P 高清", + "resolution": "1080x1920", + "fps": 30, + "video_bitrate": 8000, + "audio_bitrate": 128, + "format": "mp4", + "quality_preset": "balanced", + "description": "竖屏高清,适合短视频平台", + "size_hint": "约 10MB/分钟", + }, + { + "id": "export_1080p_60", + "name": "1080P 高帧率", + "resolution": "1080x1920", + "fps": 60, + "video_bitrate": 12000, + "audio_bitrate": 128, + "format": "mp4", + "quality_preset": "high", + "description": "60帧高帧率,流畅运动画面", + "size_hint": "约 18MB/分钟", + }, + { + "id": "export_720p_30", + "name": "720P 流畅", + "resolution": "720x1280", + "fps": 30, + "video_bitrate": 4000, + "audio_bitrate": 128, + "format": "mp4", + "quality_preset": "fast", + "description": "快速导出,文件较小", + "size_hint": "约 5MB/分钟", + }, + { + "id": "export_4k_30", + "name": "4K 超清", + "resolution": "2160x3840", + "fps": 30, + "video_bitrate": 20000, + "audio_bitrate": 192, + "format": "mp4", + "quality_preset": "best", + "description": "4K超清画质,专业品质", + "size_hint": "约 30MB/分钟", + }, + { + "id": "export_1080p_30_mov", + "name": "1080P ProRes", + "resolution": "1080x1920", + "fps": 30, + "video_bitrate": 15000, + "audio_bitrate": 256, + "format": "mov", + "quality_preset": "high", + "description": "MOV格式,适合后期剪辑", + "size_hint": "约 25MB/分钟", + }, +] + +VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"} +VALID_FORMATS = {"mp4", "mov"} + +RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$") + + +# ── Schemas ────────────────────────────────────────────────────────────────── + + +class ExportConfigResponse(BaseModel): + """导出配置响应""" + + resolution: str + fps: int + video_bitrate: int + audio_bitrate: int + format: str + quality_preset: str + watermark_enabled: bool + watermark_text: str + + +class ExportUpdateRequest(BaseModel): + """更新导出配置请求""" + + resolution: Optional[str] = None + fps: Optional[int] = Field(default=None, ge=15, le=60) + video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000) + audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320) + format: Optional[str] = None + quality_preset: Optional[str] = None + watermark_enabled: Optional[bool] = None + watermark_text: Optional[str] = None + + @validator("resolution") + def validate_resolution(cls, v): + if v is None: + return v + if not RESOLUTION_PATTERN.match(v): + raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920") + w, h = v.split("x") + if int(w) < 100 or int(h) < 100: + raise ValueError("分辨率数值过小") + if int(w) > 4096 or int(h) > 4096: + raise ValueError("分辨率数值过大,最大 4096x4096") + return v + + @validator("format") + def validate_format(cls, v): + if v is None: + return v + if v not in VALID_FORMATS: + raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}") + return v + + @validator("quality_preset") + def validate_quality_preset(cls, v): + if v is None: + return v + if v not in VALID_QUALITY_PRESETS: + raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}") + return v + + +class ExportPresetItem(BaseModel): + """导出预设条目""" + + id: str + name: str + resolution: str + fps: int + video_bitrate: int + audio_bitrate: int + format: str + quality_preset: str + description: str + size_hint: str + + +class ExportPresetListResponse(BaseModel): + """导出预设列表响应""" + + items: List[ExportPresetItem] + total: int + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _get_export_config(plan_config: dict) -> dict: + e = plan_config.get("export", {}) + if not isinstance(e, dict): + e = {} + return { + "resolution": e.get("resolution", "1080x1920"), + "fps": e.get("fps", 30), + "video_bitrate": e.get("video_bitrate", 8000), + "audio_bitrate": e.get("audio_bitrate", 128), + "format": e.get("format", "mp4"), + "quality_preset": e.get("quality_preset", "balanced"), + "watermark_enabled": e.get("watermark_enabled", False), + "watermark_text": e.get("watermark_text", ""), + } + + +# ── Routes ─────────────────────────────────────────────────────────────────── + + +@router.get("/export-presets", response_model=ExportPresetListResponse) +def list_export_presets( + current_user: AuthenticatedUser = Depends(get_current_user), +) -> ExportPresetListResponse: + """获取导出预设列表""" + items = [ExportPresetItem(**p) for p in EXPORT_PRESETS] + return ExportPresetListResponse(items=items, total=len(items)) + + +@router.get("/{plan_id}/export", response_model=ExportConfigResponse) +def get_export_config( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ExportConfigResponse: + """获取导出配置""" + svc = EditPlanService(db) + plan = svc.get_plan(plan_id) + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + + if plan.project_id: + check_project_access(plan.project_id, current_user.user.id, project_repository) + + config = _get_export_config(plan.config or {}) + return ExportConfigResponse(**config) + + +@router.put("/{plan_id}/export", response_model=ExportConfigResponse) +def update_export_config( + plan_id: str, + body: ExportUpdateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ExportConfigResponse: + """更新导出配置""" + svc = EditPlanService(db) + plan = svc.get_plan(plan_id) + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + + if plan.project_id: + check_project_access(plan.project_id, current_user.user.id, project_repository) + + # 合并更新 + current = _get_export_config(plan.config or {}) + updates = body.model_dump(exclude_none=True) + new_export = {**current, **updates} + + # 更新到 plan.config + current_config = dict(plan.config or {}) + current_config["export"] = new_export + normalized = normalize_plan_config(current_config) + updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]}) + + result = _get_export_config(updated_plan.config or {}) + logger.info( + "更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s", + plan_id, + result["resolution"], + result["fps"], + current_user.user.id, + ) + return ExportConfigResponse(**result) diff --git a/packages/domain/config_schemas.py b/packages/domain/config_schemas.py index 32534f004..cee9f9433 100755 --- a/packages/domain/config_schemas.py +++ b/packages/domain/config_schemas.py @@ -140,6 +140,25 @@ class BGMConfig(BaseModel): sidechain_threshold: float = Field(default=-25.0, ge=-60.0, le=0.0, description="人声闪避触发阈值(dB)") +class ExportConfig(BaseModel): + """导出配置 + + 视频输出参数设置。 + """ + + resolution: str = Field(default="1080x1920", description="输出分辨率,如 1080x1920 / 720x1280 / 2160x3840") + fps: int = Field(default=30, ge=15, le=60, description="输出帧率 15~60") + video_bitrate: int = Field(default=8000, ge=1000, le=20000, description="视频码率(kbps)") + audio_bitrate: int = Field(default=128, ge=64, le=320, description="音频码率(kbps)") + format: str = Field(default="mp4", description="输出格式:mp4 / mov") + quality_preset: str = Field( + default="balanced", + description="质量预设:ultra_fast / fast / balanced / high / best", + ) + watermark_enabled: bool = Field(default=False, description="是否启用水印") + watermark_text: str = Field(default="", description="水印文字") + + # ── 完整 config 模型 ───────────────────────────────────────────────────────── @@ -155,6 +174,7 @@ class EditPlanConfigSchema(BaseModel): title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置") subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置") bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置") + export: ExportConfig = Field(default_factory=ExportConfig, description="导出配置") editing_mode: str = Field(default="one_take", description="剪辑模式") @@ -169,6 +189,7 @@ class EditTemplateConfigSchema(BaseModel): title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置") subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置") bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置") + export: ExportConfig = Field(default_factory=ExportConfig, description="导出默认配置") editing_mode: str = Field(default="one_take", description="剪辑模式") transition_enabled: bool = Field(default=True, description="是否启用转场") @@ -218,6 +239,16 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = { "sidechain_release": 0.5, "sidechain_threshold": -25.0, }, + "export": { + "resolution": "1080x1920", + "fps": 30, + "video_bitrate": 8000, + "audio_bitrate": 128, + "format": "mp4", + "quality_preset": "balanced", + "watermark_enabled": False, + "watermark_text": "", + }, "editing_mode": "one_take", } diff --git a/tests/unit/test_edit_plan_export_api.py b/tests/unit/test_edit_plan_export_api.py new file mode 100644 index 000000000..22daf910e --- /dev/null +++ b/tests/unit/test_edit_plan_export_api.py @@ -0,0 +1,350 @@ +""" +导出设置 API 单元测试 + +覆盖: +- GET /export-presets - 导出预设列表 +- GET /{plan_id}/export - 获取导出配置 +- PUT /{plan_id}/export - 更新导出配置 +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from packages.domain.config_schemas import normalize_plan_config +from packages.domain.edit_plan import EditPlan, EditPlanStatus + +# --------------------------------------------------------------------------- +# Stub Repository +# --------------------------------------------------------------------------- + + +class StubEditPlanRepository: + def __init__(self, plans: dict[str, EditPlan] | None = None): + self._plans = plans or {} + + def get(self, plan_id: str) -> Optional[EditPlan]: + return self._plans.get(plan_id) + + def create(self, plan: EditPlan) -> EditPlan: + self._plans[plan.id] = plan + return plan + + def update(self, plan: EditPlan) -> EditPlan: + self._plans[plan.id] = plan + return plan + + def list_all(self, *, status=None, skip=0, limit=50): + return list(self._plans.values())[skip : skip + limit] + + def list_by_template(self, template_id, *, status=None, skip=0, limit=50): + return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit] + + def delete(self, plan_id: str) -> bool: + return self._plans.pop(plan_id, None) is not None + + def count(self, *, status=None, template_id=None): + return len(self._plans) + + +class StubEditPlanClipRepository: + def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100): + return [] + + def count(self, plan_id, *, status=None): + return 0 + + def get(self, clip_id: str): + return None + + def create(self, clip): + return clip + + def update(self, clip): + return clip + + def delete(self, clip_id: str) -> bool: + return False + + def delete_by_plan(self, plan_id: str) -> int: + return 0 + + +# --------------------------------------------------------------------------- +# Test Fixtures +# --------------------------------------------------------------------------- + + +def _make_sample_plan(plan_id="plan-001"): + return EditPlan( + id=plan_id, + template_id="tpl-001", + name="测试计划", + status=EditPlanStatus.EDITING, + total_duration=30.0, + config=normalize_plan_config({}), + project_id="", + created_by_user_id="user-001", + created_at=datetime(2026, 7, 16, 10, 0, 0), + updated_at=datetime(2026, 7, 16, 10, 0, 0), + ) + + +def _create_test_app(): + import app.services.edit_plan_service as service_module + from app.api.routes.edit_plans import router + + plan = _make_sample_plan() + stub_plan_repo = StubEditPlanRepository({plan.id: plan}) + stub_clip_repo = StubEditPlanClipRepository() + + original_plan_repo = service_module.SQLAlchemyEditPlanRepository + original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository + original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository + service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo + service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo + service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock() + + app = FastAPI() + app.include_router(router, prefix="/api/v1/edit-plans") + + def _mock_auth(): + mock = MagicMock() + mock.user.id = "user-001" + return mock + + import app.api.routes._helpers as helpers_module + + original_check = helpers_module.check_project_access + helpers_module.check_project_access = lambda *a, **kw: None + + from app.api.routes import edit_plans as main_module + + app.dependency_overrides[main_module.get_current_user] = _mock_auth + app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock() + app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock() + + import app.api.routes.edit_plans_export as export_module + + app.dependency_overrides[export_module.get_current_user] = _mock_auth + app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock() + app.dependency_overrides[export_module.get_project_repository] = lambda: MagicMock() + + def cleanup(): + service_module.SQLAlchemyEditPlanRepository = original_plan_repo + service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo + service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo + helpers_module.check_project_access = original_check + + return app, stub_plan_repo, cleanup + + +@pytest.fixture +def export_client(): + app, plan_repo, cleanup = _create_test_app() + yield TestClient(app), plan_repo + cleanup() + + +# --------------------------------------------------------------------------- +# Export Presets 测试 +# --------------------------------------------------------------------------- + + +class TestExportPresets: + def test_list_all_presets(self, export_client): + c, _ = export_client + resp = c.get("/api/v1/edit-plans/export-presets") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 5 + assert len(data["items"]) == data["total"] + first = data["items"][0] + assert "id" in first + assert "name" in first + assert "resolution" in first + assert "fps" in first + assert "video_bitrate" in first + assert "format" in first + assert "description" in first + assert "size_hint" in first + + def test_preset_has_valid_resolution(self, export_client): + c, _ = export_client + resp = c.get("/api/v1/edit-plans/export-presets") + data = resp.json() + for item in data["items"]: + assert "x" in item["resolution"] + assert item["fps"] >= 15 + assert item["fps"] <= 60 + assert item["format"] in ("mp4", "mov") + + +# --------------------------------------------------------------------------- +# GET /{plan_id}/export 测试 +# --------------------------------------------------------------------------- + + +class TestGetExportConfig: + def test_default_export_config(self, export_client): + c, _ = export_client + resp = c.get("/api/v1/edit-plans/plan-001/export") + assert resp.status_code == 200 + data = resp.json() + assert data["resolution"] == "1080x1920" + assert data["fps"] == 30 + assert data["video_bitrate"] == 8000 + assert data["audio_bitrate"] == 128 + assert data["format"] == "mp4" + assert data["quality_preset"] == "balanced" + assert data["watermark_enabled"] is False + assert data["watermark_text"] == "" + + def test_export_not_found(self, export_client): + c, _ = export_client + resp = c.get("/api/v1/edit-plans/plan-nonexist/export") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# PUT /{plan_id}/export 测试 +# --------------------------------------------------------------------------- + + +class TestUpdateExportConfig: + def test_update_resolution_and_fps(self, export_client): + c, plan_repo = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"resolution": "720x1280", "fps": 60}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["resolution"] == "720x1280" + assert data["fps"] == 60 + + plan = plan_repo.get("plan-001") + assert plan.config["export"]["resolution"] == "720x1280" + assert plan.config["export"]["fps"] == 60 + + def test_update_bitrate(self, export_client): + c, plan_repo = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"video_bitrate": 12000, "audio_bitrate": 192}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["video_bitrate"] == 12000 + assert data["audio_bitrate"] == 192 + + def test_update_format(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"format": "mov"}, + ) + assert resp.status_code == 200 + assert resp.json()["format"] == "mov" + + def test_invalid_format(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"format": "avi"}, + ) + assert resp.status_code == 422 + + def test_update_quality_preset(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"quality_preset": "best"}, + ) + assert resp.status_code == 200 + assert resp.json()["quality_preset"] == "best" + + def test_invalid_quality_preset(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"quality_preset": "ultimate"}, + ) + assert resp.status_code == 422 + + def test_update_watermark(self, export_client): + c, plan_repo = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"watermark_enabled": True, "watermark_text": "我的视频"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["watermark_enabled"] is True + assert data["watermark_text"] == "我的视频" + + plan = plan_repo.get("plan-001") + assert plan.config["export"]["watermark_enabled"] is True + assert plan.config["export"]["watermark_text"] == "我的视频" + + def test_invalid_resolution_format(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"resolution": "1080*1920"}, + ) + assert resp.status_code == 422 + + def test_resolution_too_large(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"resolution": "8000x8000"}, + ) + assert resp.status_code == 422 + + def test_fps_out_of_range(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-001/export", + json={"fps": 120}, + ) + assert resp.status_code == 422 + + def test_export_not_found(self, export_client): + c, _ = export_client + resp = c.put( + "/api/v1/edit-plans/plan-nonexist/export", + json={"fps": 30}, + ) + assert resp.status_code == 404 + + def test_partial_update_preserves_other_fields(self, export_client): + c, _ = export_client + # 先修改一个 + c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"}) + # 再修改另一个 + resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60}) + data = resp.json() + # 分辨率应该保持 + assert data["resolution"] == "720x1280" + # fps 更新了 + assert data["fps"] == 60 + # 其他默认值不变 + assert data["format"] == "mp4" + assert data["video_bitrate"] == 8000 -- 2.54.0 From 4f8a1ab519a8090262595db72f673b577f1c354b Mon Sep 17 00:00:00 2001 From: XiaoXia Bot Date: Thu, 16 Jul 2026 14:00:38 +0800 Subject: [PATCH 2/3] style: fix import sorting with isort --- apps/api/app/api/routes/edit_plans_clips.py | 3 ++- apps/api/app/api/routes/edit_plans_clips_batch.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/app/api/routes/edit_plans_clips.py b/apps/api/app/api/routes/edit_plans_clips.py index 544ec6d46..cb33efe27 100755 --- a/apps/api/app/api/routes/edit_plans_clips.py +++ b/apps/api/app/api/routes/edit_plans_clips.py @@ -86,9 +86,10 @@ def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: """验证用户是否有权限访问该剪辑计划(通过项目关联)。 返回 plan 对象供后续使用,避免重复查询。 """ - from ._helpers import check_project_access 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: diff --git a/apps/api/app/api/routes/edit_plans_clips_batch.py b/apps/api/app/api/routes/edit_plans_clips_batch.py index 29af9cf20..bb4a1fb7b 100755 --- a/apps/api/app/api/routes/edit_plans_clips_batch.py +++ b/apps/api/app/api/routes/edit_plans_clips_batch.py @@ -75,9 +75,10 @@ class ClipsFromAssetsResponse(BaseModel): def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any: """验证用户是否有权限访问该剪辑计划,返回 plan 对象。""" - from ._helpers import check_project_access 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: -- 2.54.0 From 9f52670d151f2c57894fbfd32c753a4dca895d63 Mon Sep 17 00:00:00 2001 From: XiaoXia Bot Date: Thu, 16 Jul 2026 21:29:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(ci):=20=E4=BF=AE=E5=A4=8Dblack?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A5=E9=80=9A=E8=BF=87CI?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/edit_plans.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 897e58045..db1d86cc2 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -67,7 +67,9 @@ class EditPlanUpdateRequest(BaseModel): class CopyPlanRequest(BaseModel): """复制剪辑计划请求体""" - name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」") + name: Optional[str] = Field( + default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」" + ) project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目") @@ -514,6 +516,8 @@ def copy_plan( current_user.user.id, ) return _to_response(new_plan) + + # ── 保存为模板 ──────────────────────────────────────────────────────────────── -- 2.54.0