feat: create端点兜底复用预览产物 + confirm同步标题
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 / 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 / Check if frontend-only change (pull_request) Successful in 39s
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 Worker Image (pull_request) Successful in 37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m19s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
AI Code Review / AI Code Review (pull_request) Failing after 2m30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration 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

- create_generation_task: source_edit_plan_id非空且is_preview=False时,
  查询该plan下已完成的预览任务,分辨率一致则直接mark_confirmed返回(秒出),
  不创建新任务、不入队
- confirm_generation: ConfirmGenerationRequest增加custom_title字段,
  mark_confirmed时同步更新title_config,并回写EditPlan.config
- GenerationTask.mark_confirmed: 增加title_config参数
- 8个回归测试覆盖兜底复用/分辨率不匹配/预览请求跳过/标题同步
This commit is contained in:
xiaoxia
2026-08-24 22:34:33 +08:00
parent ccb5ed816a
commit 96bf8207de
4 changed files with 502 additions and 0 deletions
+101
View File
@@ -293,6 +293,89 @@ def create_generation_task(
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
)
# ── 兜底复用预览产物 ──
# 前端刷新后 previewTaskId 丢失,降级调 create 接口时,
# 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。
if request.source_edit_plan_id and not request.is_preview:
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_preview_model = (
db.query(GenerationTaskModel)
.filter(
GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id,
GenerationTaskModel.is_preview.is_(True),
GenerationTaskModel.status == "completed",
GenerationTaskModel.created_by_user_id == authenticated_user.user.id,
)
.order_by(GenerationTaskModel.created_at.desc())
.first()
)
if _preview_model is not None:
# 校验分辨率一致性(与 confirm 端点逻辑相同)
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(_preview_model, "output_width", 0) or 0
src_h = getattr(_preview_model, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
_to_domain,
)
preview_task = _to_domain(_preview_model)
# 如果传了标题,更新 title_config
fallback_title_config = None
if request.title_config and request.title_config.get("text", "").strip():
fallback_title_config = dict(preview_task.title_config or {})
fallback_title_config.update(request.title_config)
preview_task.mark_confirmed(
cover_url=request.cover_url or preview_task.cover_url,
output_width=request.output_width or preview_task.output_width,
output_height=request.output_height or preview_task.output_height,
title_config=fallback_title_config,
)
generation_task_repository.update(preview_task)
# 同步标题到 EditPlan.config
if fallback_title_config:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=preview_task.id,
title_config=fallback_title_config,
db=db,
)
logger.info(
"[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s",
preview_task.id,
request.source_edit_plan_id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(preview_task)],
total=1,
)
else:
logger.info(
"[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s",
request.source_edit_plan_id,
src_w,
src_h,
req_w,
req_h,
)
except Exception:
logger.warning(
"[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s",
request.source_edit_plan_id,
exc_info=True,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count
created_tasks = []
@@ -445,6 +528,7 @@ def confirm_generation(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
project_repository: Any = Depends(get_project_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse:
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
@@ -473,12 +557,29 @@ def confirm_generation(
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
# 如果用户传了 custom_title,同步更新 title_config
confirmed_title_config = None
if request.custom_title and request.custom_title.strip():
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
source_task.mark_confirmed(
cover_url=request.cover_url,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
generation_task_repository.update(source_task)
# 同步标题到 EditPlan.config
if confirmed_title_config and source_task.source_edit_plan_id:
_writeback_edit_plan_config(
plan_id=source_task.source_edit_plan_id,
task_id=source_task.id,
title_config=confirmed_title_config,
db=db,
)
logger.info(
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
+1
View File
@@ -10,6 +10,7 @@ class ConfirmGenerationRequest(BaseModel):
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
cover_url: str = Field(default="", description="自定义封面图片 URL")
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
class CreateGenerationTaskRequest(BaseModel):
+3
View File
@@ -306,6 +306,7 @@ class GenerationTask:
extra_meta: dict | None = None,
output_width: int = 0,
output_height: int = 0,
title_config: dict | None = None,
) -> None:
"""将预览任务确认为正式产出。
@@ -319,6 +320,8 @@ class GenerationTask:
self.output_width = output_width
if output_height > 0:
self.output_height = output_height
if title_config:
self.title_config = dict(title_config)
if extra_meta:
self.extra_meta.update(extra_meta)
self.updated_at = datetime.now(timezone.utc)
@@ -0,0 +1,397 @@
"""create 端点兜底复用预览产物 + confirm 标题同步 — 单元测试."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ── Stubs ────────────────────────────────────────────────────────────────────
class StubGenerationTaskRepository:
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def create(self, task: Any) -> Any:
self._store[task.id] = task
return task
def get(self, task_id: str) -> Optional[Any]:
return self._store.get(task_id)
def update(self, task: Any) -> Any:
self._store[task.id] = task
return task
def list_by_user(self, user_id: str) -> list[Any]:
return [t for t in self._store.values() if t.created_by_user_id == user_id]
def count_pending_by_user(self, user_id: str) -> int:
return len([
t for t in self._store.values()
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
])
def count_pending_total(self) -> int:
return len([t for t in self._store.values() if t.status == GenerationTaskStatus.PENDING])
def count_by_user(self, user_id: str) -> int:
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
@dataclass
class FakeProject:
id: str = "project-001"
owner_user_id: str = "user-001"
shared_users: list[str] = field(default_factory=list)
name: str = "Test Project"
def can_access(self, user_id: str) -> bool:
return user_id == self.owner_user_id or user_id in self.shared_users
class StubProjectRepository:
def __init__(self) -> None:
self._projects: dict[str, FakeProject] = {}
def add(self, project: FakeProject) -> None:
self._projects[project.id] = project
def find_by_id(self, project_id: str) -> Optional[FakeProject]:
return self._projects.get(project_id)
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
def _make_preview_task(**kwargs: Any) -> GenerationTask:
defaults = dict(
id="preview-task-001",
project_id="project-001",
asset_library_id="library-001",
strategy_id="one_take",
voice_library_id="",
template_id="tmpl-001",
asset_ids=["asset-1"],
title_ids=[],
voice_ids=[],
status=GenerationTaskStatus.COMPLETED,
progress=100.0,
result_count=1,
error_message="",
created_by_user_id="user-001",
source_edit_plan_id="plan-001",
asset_select_mode="all",
is_preview=True,
source_task_id="",
output_width=1080,
output_height=1920,
cover_url="",
video_title="",
resolution="",
bgm_config={},
title_config={"text": "预览标题"},
)
defaults.update(kwargs)
return GenerationTask(**defaults)
def _make_db_with_preview(preview: GenerationTask):
"""Create a mock DB that returns a model-like object for the preview."""
db = MagicMock()
mock_model = MagicMock()
mock_model.id = preview.id
mock_model.output_width = preview.output_width
mock_model.output_height = preview.output_height
# Set up chain: db.query(...).filter(...).order_by(...).first()
chain = db.query.return_value
chain.filter.return_value = chain
chain.order_by.return_value = chain
chain.first.return_value = mock_model
return db, mock_model
def _make_db_empty():
"""Create a mock DB that returns None (no preview found)."""
db = MagicMock()
chain = db.query.return_value
chain.filter.return_value = chain
chain.order_by.return_value = chain
chain.first.return_value = None
return db
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
return StubGenerationTaskRepository()
@pytest.fixture
def project_repo() -> StubProjectRepository:
repo = StubProjectRepository()
repo.add(FakeProject())
return repo
@pytest.fixture
def app(
gen_task_repo: StubGenerationTaskRepository,
project_repo: StubProjectRepository,
) -> FastAPI:
from app.api.routes.generation_tasks import router
from app.auth import get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
get_project_repository,
)
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/generation")
test_app.dependency_overrides[get_current_user] = lambda: FakeAuthenticatedUser()
test_app.dependency_overrides[get_generation_task_repository] = lambda: gen_task_repo
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
test_app.dependency_overrides[get_asset_library_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
# db_session will be overridden per-test
yield test_app
test_app.dependency_overrides.clear()
def _make_client(app: FastAPI, db: MagicMock) -> TestClient:
from app.dependencies import get_db_session
app.dependency_overrides[get_db_session] = lambda: db
return TestClient(app)
# ── Domain: mark_confirmed with title_config ─────────────────────────────────
class TestMarkConfirmedTitleConfig:
def test_mark_confirmed_sets_title_config(self):
task = _make_preview_task()
task.mark_confirmed(title_config={"text": "新标题"})
assert task.is_preview is False
assert task.title_config["text"] == "新标题"
def test_mark_confirmed_without_title_config_preserves_existing(self):
task = _make_preview_task(title_config={"text": "原标题"})
task.mark_confirmed()
assert task.title_config["text"] == "原标题"
# ── Create endpoint fallback ─────────────────────────────────────────────────
class TestCreateEndpointFallback:
"""create 端点兜底复用预览产物。"""
def test_reuse_completed_preview(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""带 source_edit_plan_id + is_preview=False → 复用已完成预览"""
preview = _make_preview_task()
gen_task_repo.create(preview)
db, _ = _make_db_with_preview(preview)
with patch(
"packages.adapters.sqlalchemy_impl.generation_task_repository._to_domain",
return_value=preview,
), patch(
"app.api.routes.generation_tasks._writeback_edit_plan_config",
):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": False,
"output_width": 1080,
"output_height": 1920,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] == preview.id
assert data["items"][0]["is_preview"] is False
def test_no_preview_found_creates_new_task(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""没有已完成预览 → 正常创建新任务"""
db = _make_db_empty()
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-002",
"is_preview": False,
},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["id"] != "preview-task-001"
assert data["items"][0]["is_preview"] is False
def test_preview_request_does_not_use_fallback(
self,
app: FastAPI,
):
"""is_preview=True → 不走兜底,正常创建预览任务"""
db = _make_db_empty()
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": True,
},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["is_preview"] is True
# 兜底查询 GenerationTaskModel 不应被调用(只可能查 EditPlanModel 做自动关联)
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
for call_args in db.query.call_args_list:
assert call_args[0][0] is not GenerationTaskModel, "fallback should not query GenerationTaskModel for preview requests"
def test_fallback_resolution_mismatch_creates_new(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""兜底找到预览但分辨率不一致 → 跳过复用,创建新任务"""
preview = _make_preview_task(output_width=1080, output_height=1920)
gen_task_repo.create(preview)
db, _ = _make_db_with_preview(preview)
with patch(
"packages.adapters.sqlalchemy_impl.generation_task_repository._to_domain",
return_value=preview,
), patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
client = _make_client(app, db)
resp = client.post(
"/api/v1/generation/tasks",
json={
"project_id": "project-001",
"template_id": "tmpl-001",
"asset_ids": ["asset-1"],
"source_edit_plan_id": "plan-001",
"is_preview": False,
"output_width": 1920,
"output_height": 1080,
},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["id"] != preview.id
# ── Confirm endpoint title sync ──────────────────────────────────────────────
class TestConfirmTitleSync:
"""confirm 端点 custom_title 同步到 title_config。"""
def test_confirm_with_custom_title_updates_title_config(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""传了 custom_title → title_config.text 被更新"""
preview = _make_preview_task(
title_config={"text": "旧标题", "font_size": 32},
source_edit_plan_id="plan-001",
)
gen_task_repo.create(preview)
db = _make_db_empty()
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
client = _make_client(app, db)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={
"output_width": 1080,
"output_height": 1920,
"custom_title": "新标题",
},
)
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["title_config"]["text"] == "新标题"
assert item["title_config"]["font_size"] == 32
def test_confirm_without_custom_title_preserves_title(
self,
app: FastAPI,
gen_task_repo: StubGenerationTaskRepository,
):
"""没传 custom_title → title_config 不变"""
preview = _make_preview_task(title_config={"text": "原标题"})
gen_task_repo.create(preview)
db = _make_db_empty()
client = _make_client(app, db)
resp = client.post(
f"/api/v1/generation/tasks/{preview.id}/confirm",
json={"output_width": 1080, "output_height": 1920},
)
assert resp.status_code == 200
assert resp.json()["items"][0]["title_config"]["text"] == "原标题"