From 2d83b9385cf5066f11e95ca93d19a9072fb6d61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Tue, 15 Sep 2026 09:22:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(p0):=20#1919=20from-assets=E7=A9=BAtemplate?= =?UTF-8?q?Id=E5=8F=8C=E6=96=9C=E6=9D=A0404=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: 1. PR#1918清理模板API时误删了GET /templates中自动创建默认模板的兜底逻辑 2. 前端PR#1913去掉空tid拦截后,首次进入生成页templateId为空,导致URL出现双斜杠 /templates//editor/clips/from-assets → FastAPI返回404 修复: 1. 新增 routes/_default_template.py 共享 get_or_create_default_template_id() 2. GET /templates 在列表查询前调用兜底逻辑,保证新用户总能拿到默认模板 3. dependencies.py 抽出纯函数 resolve_draft_plan_id(),支持空tid自动兜底 4. 新增独立端点 POST /api/v1/clips/from-assets,template_id从body传入(可选) 空时后端自动兜底,彻底规避双斜杠URL问题 5. 保留原 /templates/{template_id}/editor/clips/from-assets 路径兼容旧调用 前端改造指引:useStep2Materials 发from-assets请求时,空tid直接调用新端点 POST /api/v1/clips/from-assets,body中可选传template_id字段。 --- apps/api/app/api/router.py | 5 ++ apps/api/app/api/routes/_default_template.py | 59 ++++++++++++ apps/api/app/api/routes/clips_standalone.py | 90 +++++++++++++++++++ apps/api/app/api/routes/templates.py | 21 ++--- .../api/routes/templates_editor/__init__.py | 2 +- .../routes/templates_editor/dependencies.py | 63 +++++++++---- 6 files changed, 209 insertions(+), 31 deletions(-) create mode 100644 apps/api/app/api/routes/_default_template.py create mode 100644 apps/api/app/api/routes/clips_standalone.py diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 3dd8f9afc..808a5ac8c 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -24,6 +24,7 @@ from app.api.routes.subscription import router as subscription_router from app.api.routes.tags import router as tags_router from app.api.routes.task_center import router as task_center_router from app.api.routes.templates import router as templates_router +from app.api.routes.clips_standalone import router as clips_standalone_router from app.api.routes.templates_editor import router as templates_editor_router from app.api.routes.titles import router as titles_router from app.api.routes.tts import router as tts_router @@ -156,6 +157,10 @@ api_router.include_router( prefix="/templates", tags=["Template"], ) +api_router.include_router( + clips_standalone_router, + tags=["Clips"], +) api_router.include_router( templates_editor_router, prefix="/templates/{template_id}/editor", diff --git a/apps/api/app/api/routes/_default_template.py b/apps/api/app/api/routes/_default_template.py new file mode 100644 index 000000000..d075616eb --- /dev/null +++ b/apps/api/app/api/routes/_default_template.py @@ -0,0 +1,59 @@ +"""P0 fix: 共享的默认模板自动兜底逻辑。""" + +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]: + """为用户查找一个有效模板;若不存在则自动创建默认配音模板。""" + from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel + from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository + from packages.application.template.commands import CreateTemplateCommand, SegmentCommand + from packages.application.template.use_cases import CreateTemplateUseCase + + existing = ( + db.query(TemplateModel) + .filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True)) + .order_by(TemplateModel.created_at.asc()) + .first() + ) + if existing is not None: + has_seg = db.query(TemplateClipConfigModel.id).filter( + TemplateClipConfigModel.template_id == existing.id + ).first() + if has_seg: + return existing.id + + try: + repo = SQLAlchemyTemplateRepository(db) + cmd = CreateTemplateCommand( + user_id=user_id, name="默认配音模板", mode="voice_over", category="default", + tags=[], title_config={}, subtitle_config={}, bgm_config={}, estimated_duration=0.0, + segments=[SegmentCommand(segment_order=0, duration_min=1.0, duration_max=30.0)], + ) + tpl = CreateTemplateUseCase(repo).execute(cmd) + db.commit() + logger.info("[default-template] 自动创建默认模板: user=%s tpl=%s", user_id, tpl.id) + return tpl.id + except Exception as e: + db.rollback() + existing2 = ( + db.query(TemplateModel) + .filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True)) + .order_by(TemplateModel.created_at.asc()) + .first() + ) + if existing2 is not None: + has_seg2 = db.query(TemplateClipConfigModel.id).filter( + TemplateClipConfigModel.template_id == existing2.id + ).first() + if has_seg2: + return existing2.id + logger.exception("[default-template] 自动创建默认模板失败: user=%s", user_id) + return None diff --git a/apps/api/app/api/routes/clips_standalone.py b/apps/api/app/api/routes/clips_standalone.py new file mode 100644 index 000000000..9c7851467 --- /dev/null +++ b/apps/api/app/api/routes/clips_standalone.py @@ -0,0 +1,90 @@ +"""独立的从素材创建片段端点(不依赖template_id路径参数). + +POST /api/v1/clips/from-assets +- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致 +- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板 +- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题 +- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑 +""" + +from __future__ import annotations + +import logging + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_asset_repository, get_db_session +from fastapi import APIRouter, BackgroundTasks, Depends +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository + +from app.services.edit_plan_service import EditPlanService +from app.services.edit_template_service import EditTemplateService +from ._default_template import get_or_create_default_template_id +from .templates_editor.clips import create_clips_from_assets_editor +from .templates_editor.dependencies import resolve_draft_plan_id +from .templates_editor.schemas import ClipsFromAssetsRequest, ClipsFromAssetsResponse + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["Clips"]) + + +class StandaloneClipsRequest(ClipsFromAssetsRequest): + """扩展请求:template_id 可选(不传则后端自动兜底默认模板)。""" + template_id: str | None = None + + +def _get_editor_services_direct(db: Session) -> tuple[EditTemplateService, EditPlanService]: + """直接构造服务实例(非 Depends 版本,供独立端点内部调用)。""" + return EditTemplateService(db), EditPlanService(db) + + +@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse) +def create_clips_from_assets( + body: StandaloneClipsRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository), +) -> ClipsFromAssetsResponse: + """从素材批量创建片段(template_id 可选,为空自动兜底)。""" + user_id = str(current_user.user.id) + services = _get_editor_services_direct(db) + + # 1. 解析/兜底 template_id,拿到 plan_id + template_id = (body.template_id or "").strip() + if not template_id: + template_id = get_or_create_default_template_id(db, user_id) + if not template_id: + from fastapi import HTTPException, status as http_status + raise HTTPException( + status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法自动创建默认模板,请刷新页面重试", + ) + plan_id = resolve_draft_plan_id( + template_id=template_id, + services=services, + current_user=current_user, + db=db, + auto_create_default=False, # 上面已兜底过 + ) + + # 2. 构造标准化请求(去除独立端扩展字段),复用原端点核心逻辑 + core_body = ClipsFromAssetsRequest( + asset_ids=body.asset_ids, + clip_type=body.clip_type, + clip_count=body.clip_count, + required_clips_count=body.required_clips_count, + ) + + # 3. 直接调用原端点函数(此时所有 Depends 依赖已手动传入) + return create_clips_from_assets_editor( + template_id=template_id, + body=core_body, + background_tasks=background_tasks, + plan_id=plan_id, + services=services, + asset_repo=asset_repo, + db=db, + current_user=current_user, + ) diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 3614b5a40..8b64e8852 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -2,10 +2,7 @@ 保留: - GET /templates:列表查询(生成页使用) -- 默认模板自动创建兜底逻辑(``_get_or_create_default_template_id`` 位于 - generation_variant_plans.py)继续通过 service 层 CreateTemplateUseCase 工作, - 但不再暴露模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等 HTTP 端点 - (前端 PR#1911 已删除 my-templates / editing-planner / templates 管理页面)。 +- 默认模板自动创建兜底逻辑(复用 _default_template.get_or_create_default_template_id) """ from __future__ import annotations @@ -14,10 +11,7 @@ import logging from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session -from app.schemas.template import ( - ListTemplatesResponse, - TemplateResponse, -) +from app.schemas.template import ListTemplatesResponse, TemplateResponse from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session @@ -27,6 +21,8 @@ from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemp from packages.application.template.commands import ListTemplatesFilter from packages.application.template.use_cases import CountTemplatesUseCase, ListTemplatesUseCase +from ._default_template import get_or_create_default_template_id + router = APIRouter() @@ -43,16 +39,17 @@ def list_templates( page_size: int = Query(20, ge=1, le=100, description="每页条数,默认 20"), current_user: AuthenticatedUser = Depends(get_current_user), repo: SQLAlchemyTemplateRepository = Depends(_get_template_repository), + db: Session = Depends(get_db_session), ): """获取用户可用的模板列表(仅返回 active 状态)。""" user_id = str(current_user.user.id) + # P0 兜底:无有效模板时自动创建默认配音模板 + get_or_create_default_template_id(db, user_id) + list_uc = ListTemplatesUseCase(repo) count_uc = CountTemplatesUseCase(repo) filters = ListTemplatesFilter( - category=category, - tag=tag, - mode=mode, - valid_only=True, # 仅返回 active + 有片段配置 + category=category, tag=tag, mode=mode, valid_only=True, ) skip = (page - 1) * page_size templates = list_uc.execute(user_id, skip=skip, limit=page_size, filter=filters) diff --git a/apps/api/app/api/routes/templates_editor/__init__.py b/apps/api/app/api/routes/templates_editor/__init__.py index c8f1c2944..62c682e1e 100755 --- a/apps/api/app/api/routes/templates_editor/__init__.py +++ b/apps/api/app/api/routes/templates_editor/__init__.py @@ -28,7 +28,7 @@ from .adjustments import router as adjustments_router from .ai_features import router as ai_features_router from .bgm import router as bgm_router from .clips import router as clips_router -from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401 +from .dependencies import get_draft_plan_id, get_editor_services, resolve_draft_plan_id # noqa: F401 from .draft import router as draft_router from .effects import router as effects_router from .export import router as export_router diff --git a/apps/api/app/api/routes/templates_editor/dependencies.py b/apps/api/app/api/routes/templates_editor/dependencies.py index b23959ce6..85a718e17 100755 --- a/apps/api/app/api/routes/templates_editor/dependencies.py +++ b/apps/api/app/api/routes/templates_editor/dependencies.py @@ -3,11 +3,13 @@ 核心依赖: - get_editor_services: 获取模板+计划服务 - get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id +- resolve_draft_plan_id: 纯函数版本(可在非依赖场景复用) """ from __future__ import annotations import logging +from typing import Optional from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session @@ -20,6 +22,8 @@ from packages.adapters.sqlalchemy_impl.template_repository import ( SQLAlchemyTemplateRepository, ) +from .._default_template import get_or_create_default_template_id + logger = logging.getLogger(__name__) @@ -30,27 +34,37 @@ def get_editor_services( return EditTemplateService(db), EditPlanService(db) -def get_draft_plan_id( - template_id: str, - services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), +def resolve_draft_plan_id( + template_id: Optional[str], + services: tuple[EditTemplateService, EditPlanService], + current_user: AuthenticatedUser, + db: Session, + auto_create_default: bool = True, ) -> str: - """路径依赖:根据 template_id 获取或创建草稿,返回 plan_id. + """纯函数:根据 template_id 获取或创建草稿 plan_id(可在独立端点复用)。 - 这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里, - 确保 template_id → plan_id 的映射始终存在。 - - 模板读取遵循单一数据源、显式判定(不使用异常降级): - - 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True); - - 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。 - 模板不存在、已删除或不归属于当前用户时,一律返回 404。 + - template_id 为空且 auto_create_default=True 时,自动兜底创建/查找默认模板 + - 返回有效 plan_id;模板不存在/无权限时抛 404 """ tpl_svc, plan_svc = services user_id = str(current_user.user.id) - # 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验, - # 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。 + # P0 兜底:空 template_id 时自动创建/查找默认模板 + if not template_id and auto_create_default: + template_id = get_or_create_default_template_id(db, user_id) + if not template_id: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法自动创建默认模板,请刷新页面重试", + ) + + if not template_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="缺少 template_id 参数", + ) + + # 0. 门禁:校验模板存在且可访问 old_repo = SQLAlchemyTemplateRepository(db) old_template = old_repo.get_active(template_id, user_id) is_global_template = tpl_svc.get_template(template_id) is not None @@ -73,7 +87,6 @@ def get_draft_plan_id( from packages.domain.edit_template import EditTemplate, EditTemplateStatus from packages.domain.template_clip_config import ClipType, TemplateClipConfig - # 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段) pseudo_template = EditTemplate( id=old_template.id, name=old_template.name, @@ -81,7 +94,6 @@ def get_draft_plan_id( status=EditTemplateStatus.ACTIVE, ) - # 将旧模板 segments 转换为 clip_configs clip_configs: list[TemplateClipConfig] = [] for seg in old_template.segments or []: clip_configs.append( @@ -105,7 +117,6 @@ def get_draft_plan_id( ) plan = result["plan"] - # 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑) plan_svc.update_plan_config(plan.id, {"is_template_draft": True}) logger.info( @@ -115,3 +126,19 @@ def get_draft_plan_id( user_id, ) return plan.id + + +def get_draft_plan_id( + template_id: str, + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> str: + """FastAPI 依赖:路径参数 {template_id} 下获取/创建草稿 plan_id。""" + return resolve_draft_plan_id( + template_id=template_id, + services=services, + current_user=current_user, + db=db, + auto_create_default=False, # 路径参数路由不兜底(路径里本就应有值) + )