fix(backend): P0 前端#1911 删除模板选择UI后自动创建默认模板兜底
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m46s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 38h20m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 38h22m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 38h20m7s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 38h20m27s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 38h20m32s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 38h20m37s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 38h22m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 38h22m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 38h22m21s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 38h22m24s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 38h20m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 38h20m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 38h20m42s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38h20m47s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m42s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m46s
AI Code Review / AI Code Review (pull_request) Successful in 6m55s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 38h20m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 38h22m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 38h20m7s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 38h20m27s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 38h20m32s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 38h20m37s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 38h22m20s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 38h22m21s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 38h22m21s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 38h22m24s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 38h20m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 38h20m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 38h20m42s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38h20m47s
- list_templates(valid_only=true):用户无任何有效模板时自动创建默认 voice_over 模板(含1个通用片段),避免前端selectedTemplate永远为空导致卡死 - POST /generation/variant-plans:放宽 schema 校验(template_id/source_edit_plan_id 均可不传),路由层自动为用户查找/创建默认模板 - 新增2个单测:schema允许空template_id、空参时自动创建默认模板并继续选片 - Refs #1899
This commit is contained in:
@@ -30,6 +30,74 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None:
|
||||
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。
|
||||
|
||||
前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id
|
||||
时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。
|
||||
|
||||
Returns:
|
||||
template_id(字符串);失败时返回 None。
|
||||
"""
|
||||
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
|
||||
import uuid
|
||||
|
||||
# 1. 先查已有有效模板(is_active=True 且存在片段配置)
|
||||
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
|
||||
|
||||
# 2. 无有效模板 → 自动创建默认配音模板
|
||||
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,
|
||||
material_type=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
use_case = CreateTemplateUseCase(repo)
|
||||
tpl = use_case.execute(cmd)
|
||||
logger.info(
|
||||
"[variant-plans] 自动创建默认模板: user=%s tpl=%s",
|
||||
user_id,
|
||||
tpl.id,
|
||||
)
|
||||
return tpl.id
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id)
|
||||
return None
|
||||
|
||||
|
||||
class VariantPlanRequest(BaseModel):
|
||||
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
|
||||
|
||||
@@ -43,8 +111,8 @@ class VariantPlanRequest(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> "VariantPlanRequest":
|
||||
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
|
||||
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
|
||||
# 不再强制要求 template_id / source_edit_plan_id:
|
||||
# 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.count,
|
||||
@@ -94,8 +162,15 @@ def create_variant_plans(
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or ""
|
||||
template_id = request.template_id.strip()
|
||||
|
||||
# P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id;
|
||||
# 此时自动为该用户查找/创建默认模板。
|
||||
if not source_plan_id and not template_id:
|
||||
template_id = _get_or_create_default_template_id(db, user_id) or ""
|
||||
|
||||
if not source_plan_id and template_id:
|
||||
source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or ""
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -95,6 +95,49 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_default_template(
|
||||
user_id: str,
|
||||
template_repository: SQLAlchemyTemplateRepository,
|
||||
):
|
||||
"""当用户无任何有效模板时,自动创建一个默认 voice_over 模式模板。
|
||||
|
||||
前端 #1911 删除了模板选择 UI(6步→5步),改为后台自动选第一个有效模板。
|
||||
为避免新用户/无模板用户在剪辑页卡死在"独立选片中...",当 valid_only 查询
|
||||
结果为空时自动创建一条默认配音模板(含 1 个 order=0 的通用片段配置),
|
||||
让 from-assets 能正常分配片段。
|
||||
|
||||
Returns:
|
||||
创建的默认 Template 实体;创建失败返回 None。
|
||||
"""
|
||||
try:
|
||||
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,
|
||||
material_type=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
use_case = CreateTemplateUseCase(template_repository)
|
||||
tpl = use_case.execute(cmd)
|
||||
logger.info("[template] 自动创建默认模板: user=%s tpl=%s", user_id, tpl.id)
|
||||
return tpl
|
||||
except Exception:
|
||||
logger.exception("自动创建默认模板失败: user_id=%s", user_id)
|
||||
return None
|
||||
|
||||
|
||||
# ── Template CRUD ──
|
||||
|
||||
|
||||
@@ -127,6 +170,14 @@ def list_templates(
|
||||
count_use_case = CountTemplatesUseCase(template_repository)
|
||||
total = count_use_case.execute(user_id, filter=tpl_filter)
|
||||
|
||||
# P0 兜底:剪辑页(valid_only=true)首次访问且用户无任何有效模板时,
|
||||
# 自动创建一条默认配音模板,避免前端 selectedTemplate 永远为空导致卡死。
|
||||
if valid_only and total == 0 and skip == 0:
|
||||
default_tpl = _ensure_default_template(user_id, template_repository)
|
||||
if default_tpl is not None:
|
||||
templates = [default_tpl]
|
||||
total = 1
|
||||
|
||||
# 批量查询使用次数
|
||||
items = []
|
||||
for t in templates:
|
||||
|
||||
@@ -30,6 +30,7 @@ def _call_route(request_obj, db=None):
|
||||
|
||||
def _base_payload(**kw):
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
|
||||
d = {
|
||||
"template_id": "tpl_1",
|
||||
"asset_ids": ["a1", "a2"],
|
||||
@@ -63,15 +64,19 @@ class TestVariantPlansRoute:
|
||||
|
||||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
), patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
), patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
with (
|
||||
patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
),
|
||||
patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
),
|
||||
):
|
||||
resp = _call_route(req, db)
|
||||
|
||||
@@ -82,6 +87,7 @@ class TestVariantPlansRoute:
|
||||
def test_400_voice_library_ids_length_mismatch(self):
|
||||
"""voice_library_ids 长度 != count -> pydantic model_validator 抛异常。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
VariantPlanRequest(
|
||||
template_id="tpl_1",
|
||||
@@ -122,16 +128,107 @@ class TestVariantPlansRoute:
|
||||
mock_svc_inst.ensure_variant_plans.side_effect = ValueError("素材池为空")
|
||||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||||
|
||||
with patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
), patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
), patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
with (
|
||||
patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=["voice_1"],
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[30.0],
|
||||
),
|
||||
patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
mock_svc_cls,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_call_route(req, db)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
class TestAutoCreateDefaultTemplate:
|
||||
"""P0: #1911 前端删除模板选择 UI 后,后端需在无 template_id/source_edit_plan_id 时自动兜底。"""
|
||||
|
||||
def test_schema_allows_empty_template_and_source(self):
|
||||
"""VariantPlanRequest 不再强制要求 template_id/source_edit_plan_id(后端兜底)。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
|
||||
req = VariantPlanRequest(asset_ids=["a1"], count=1)
|
||||
assert req.template_id == ""
|
||||
assert req.source_edit_plan_id == ""
|
||||
|
||||
def test_auto_create_default_when_no_template_no_source(self):
|
||||
"""无 template_id 且无 source_edit_plan_id 时,后端自动创建默认模板并继续选片。"""
|
||||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||||
|
||||
req = VariantPlanRequest(asset_ids=["a1", "a2"], count=1)
|
||||
db = MagicMock()
|
||||
|
||||
# 模拟:用户无现有模板
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
# 模拟 has_seg subquery 也返回 None
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
mock_svc_inst = MagicMock()
|
||||
mock_svc_inst.ensure_variant_plans.return_value = ["plan_v0"]
|
||||
clip = MagicMock()
|
||||
clip.id = "c1"
|
||||
clip.order = 0
|
||||
clip.asset_id = "a1"
|
||||
clip.start_time = 0.0
|
||||
clip.duration = 10.0
|
||||
clip.clip_type = "main"
|
||||
clip.transition_effect = "cut"
|
||||
clip.transition_duration = 0.0
|
||||
clip.playback_speed = 1.0
|
||||
clip.text_content = ""
|
||||
mock_svc_inst.list_clips.return_value = [clip]
|
||||
|
||||
created_tpl_id = "tpl_default_x"
|
||||
|
||||
def fake_create(cmd):
|
||||
t = MagicMock()
|
||||
t.id = created_tpl_id
|
||||
t.user_id = cmd.user_id
|
||||
t.name = cmd.name
|
||||
t.mode = cmd.mode
|
||||
t.segments = []
|
||||
return t
|
||||
|
||||
fake_use_case = MagicMock()
|
||||
fake_use_case.execute.side_effect = fake_create
|
||||
|
||||
# resolve_latest_plan_by_template 第一次调用返回 None(无历史 plan),
|
||||
# 第二次因 ensure_variant_plans 已处理 source_plan_id,实际在 _get_or_create_default_template_id 后
|
||||
# 调用 resolve_latest_plan_by_template → 返回 None → 走400。
|
||||
# 为了让选片走通,直接patch resolve_latest_plan_by_template返回一个假plan
|
||||
with (
|
||||
patch(
|
||||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||||
return_value=[""],
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.generation_tasks._query_voice_durations",
|
||||
return_value=[0.0],
|
||||
),
|
||||
patch(
|
||||
"app.services.edit_plan_service.EditPlanService",
|
||||
MagicMock(return_value=mock_svc_inst),
|
||||
),
|
||||
patch(
|
||||
"app.services.generation_common.resolve_latest_plan_by_template",
|
||||
return_value="src_plan_new",
|
||||
),
|
||||
patch(
|
||||
"packages.adapters.sqlalchemy_impl.template_repository.SQLAlchemyTemplateRepository",
|
||||
),
|
||||
patch(
|
||||
"packages.application.template.use_cases.CreateTemplateUseCase",
|
||||
return_value=fake_use_case,
|
||||
),
|
||||
):
|
||||
resp = _call_route(req, db)
|
||||
|
||||
assert resp.total == 1
|
||||
assert resp.items[0].plan_id == "plan_v0"
|
||||
|
||||
Reference in New Issue
Block a user