Compare commits

...

4 Commits

Author SHA1 Message Date
灵应 89e9e24a5e test(p0): add unit tests for default template fallback (diff cov gate)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m25s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m45s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m57s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m11s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m56s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 39s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 57s
- 覆盖 resolve_draft_plan_id 空tid兜底/400/404/草稿复用/全局模板分支
- 满足 diff coverage ≥40% 门槛
2026-09-15 10:31:37 +08:00
灵应 c243c4dd58 style: black+isort format p0 fix files [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 58s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m3s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m29s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m48s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m14s
2026-09-15 10:08:40 +08:00
CI Bot a314a42405 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m38s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m56s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m31s
AI Code Review / AI Code Review (pull_request) Successful in 6m45s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m23s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 16m8s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 16m9s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
2026-09-15 01:29:14 +00:00
灵应 2d83b9385c fix(p0): #1919 from-assets空templateId双斜杠404兜底
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 8s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
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
根因:
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字段。
2026-09-15 09:22:01 +08:00
7 changed files with 365 additions and 28 deletions
+5
View File
@@ -6,6 +6,7 @@ from app.api.routes.assets import router as assets_router
from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.clips_standalone import router as clips_standalone_router
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_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,66 @@
"""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:
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,93 @@
"""独立的从素材创建片段端点(不依赖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 app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
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
from fastapi import 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 -9
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,20 @@ 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 + 有片段配置
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_idis_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, # 路径参数路由不兜底(路径里本就应有值)
)
@@ -0,0 +1,146 @@
"""P0 #1922 默认模板自动兜底单元测试。
覆盖 resolve_draft_plan_id 的核心路径(使用可mock的外部依赖):
- 空 tid 时调用 get_or_create_default_template_id 兜底
- auto_create_default=False 时空tid抛400
- 模板不存在/无权访问抛404
- 已有草稿直接返回 plan_id
- 旧模板(templates表)走 generate_from_template 创建草稿
_get_or_create_default_template_id 是薄SQL封装,其逻辑分支通过依赖注入路径间接覆盖;
直接SQL分支通过integration test/容器验证,不做单元级mock(避免内部import脆弱mock)。
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.api.routes.templates_editor.dependencies import resolve_draft_plan_id
class TestResolveDraftPlanId:
"""resolve_draft_plan_id 行为。"""
def _make_services(self, global_tpl=None, draft=None):
tpl_svc = MagicMock()
plan_svc = MagicMock()
tpl_svc.get_template.return_value = global_tpl
tpl_svc.get_template_draft.return_value = draft
return (tpl_svc, plan_svc)
def _make_user(self, uid: str = "user-1"):
u = MagicMock()
u.user.id = uid
return u
def test_empty_tid_auto_fallback_creates_default_then_draft(self):
"""template_id 为空 + auto_create_default=True:先兜底拿到tid,再走旧模板草稿创建。"""
db = MagicMock()
services = self._make_services()
user = self._make_user()
old_tpl = MagicMock()
old_tpl.id = "tpl-auto"
old_tpl.name = "默认配音模板"
old_tpl.mode = "voice_over"
old_tpl.segments = [MagicMock(id="seg-1", segment_order=0, duration_min=1.0, duration_max=30.0)]
mock_repo = MagicMock()
mock_repo.get_active.return_value = old_tpl
mock_plan = MagicMock()
mock_plan.id = "plan-new"
mock_generator = MagicMock()
mock_generator.generate_from_template.return_value = {"plan": mock_plan}
with (
patch("app.api.routes.templates_editor.dependencies.SQLAlchemyTemplateRepository", return_value=mock_repo),
patch(
"app.api.routes.templates_editor.dependencies.get_or_create_default_template_id",
return_value="tpl-auto",
) as mock_fb,
patch("app.services.plan_generator_service.PlanGeneratorService", return_value=mock_generator),
):
plan_id = resolve_draft_plan_id(
template_id="",
services=services,
current_user=user,
db=db,
auto_create_default=True,
)
assert plan_id == "plan-new"
mock_fb.assert_called_once_with(db, "user-1")
def test_empty_tid_without_auto_raises_400(self):
"""auto_create_default=False 且 tid 为空 → 400。"""
from fastapi import HTTPException
db = MagicMock()
services = self._make_services()
with pytest.raises(HTTPException) as exc:
resolve_draft_plan_id(
template_id="",
services=services,
current_user=self._make_user(),
db=db,
auto_create_default=False,
)
assert exc.value.status_code == 400
def test_missing_template_raises_404(self):
"""tid 存在但模板找不到/无权访问 → 404。"""
from fastapi import HTTPException
db = MagicMock()
services = self._make_services() # get_template returns None
mock_repo = MagicMock()
mock_repo.get_active.return_value = None
with patch("app.api.routes.templates_editor.dependencies.SQLAlchemyTemplateRepository", return_value=mock_repo):
with pytest.raises(HTTPException) as exc:
resolve_draft_plan_id(
template_id="tpl-ghost",
services=services,
current_user=self._make_user(),
db=db,
auto_create_default=False,
)
assert exc.value.status_code == 404
def test_existing_draft_returned_directly(self):
"""已有模板草稿直接返回 plan_id,不重复创建。"""
db = MagicMock()
draft = MagicMock()
draft.id = "plan-existing"
services = self._make_services(draft=draft)
old_tpl = MagicMock()
old_tpl.id = "tpl-1"
old_tpl.segments = []
mock_repo = MagicMock()
mock_repo.get_active.return_value = old_tpl
with patch("app.api.routes.templates_editor.dependencies.SQLAlchemyTemplateRepository", return_value=mock_repo):
plan_id = resolve_draft_plan_id(
template_id="tpl-1",
services=services,
current_user=self._make_user(),
db=db,
auto_create_default=False,
)
assert plan_id == "plan-existing"
# 没触发generate
services[1].update_plan_config.assert_not_called()
def test_global_template_creates_draft_via_new_service(self):
"""全局模板(新系统edit_templates表存在)走tpl_svc.create_template_draft。"""
db = MagicMock()
services = self._make_services(global_tpl=MagicMock(), draft=None)
new_draft = MagicMock()
new_draft.id = "plan-global"
services[0].create_template_draft.return_value = new_draft
plan_id = resolve_draft_plan_id(
template_id="tpl-global",
services=services,
current_user=self._make_user(),
db=db,
auto_create_default=False,
)
assert plan_id == "plan-global"
services[0].create_template_draft.assert_called_once_with("tpl-global", user_id="user-1")