fix(p0): #1919 from-assets空templateId双斜杠404兜底
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 8s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m13s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m54s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 29h56m48s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 29h59m14s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 29h58m34s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 29h58m35s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 29h58m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 29h58m38s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 29h58m42s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 29h55m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 29h55m52s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 29h58m34s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 29h55m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 29h55m50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 29h56m13s

根因:
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字段。
This commit is contained in:
灵应
2026-09-15 09:22:01 +08:00
parent 2b12c055dc
commit 2d83b9385c
6 changed files with 209 additions and 31 deletions
+5
View File
@@ -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",
@@ -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
@@ -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,
)
+9 -12
View File
@@ -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)
@@ -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
@@ -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, # 路径参数路由不兜底(路径里本就应有值)
)