diff --git a/alembic/versions/047_template_versioning.py b/alembic/versions/047_template_versioning.py new file mode 100755 index 000000000..90101628d --- /dev/null +++ b/alembic/versions/047_template_versioning.py @@ -0,0 +1,64 @@ +"""Phase 2 - 模板发布版本化:version字段 + 发布历史表 + +Revision ID: 047 +Revises: 046 +Create Date: 2026-07-20 + +Changes: +1. edit_templates 加 version 字段(INT,默认1,每次发布+1) +2. 新建 edit_template_versions 表存发布历史快照,支持回滚 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "047_template_versioning" +down_revision = "046_task_title" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # 1. edit_templates 加 version 字段 + op.add_column( + "edit_templates", + sa.Column("version", sa.Integer, nullable=False, server_default="1"), + ) + + # 2. 新建 edit_template_versions 发布历史表 + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS edit_template_versions ( + id VARCHAR(36) PRIMARY KEY, + template_id VARCHAR(32) NOT NULL, + version INTEGER NOT NULL, + name VARCHAR(200) NOT NULL DEFAULT '', + editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take', + config JSONB NOT NULL DEFAULT '{}', + clip_configs JSONB NOT NULL DEFAULT '[]', + change_note VARCHAR(500) NOT NULL DEFAULT '', + published_by VARCHAR(36) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)" + ) + ) + conn.execute( + sa.text( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version " + "ON edit_template_versions(template_id, version)" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + + conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions")) + op.drop_column("edit_templates", "version") diff --git a/apps/api/app/api/routes/templates_editor.py b/apps/api/app/api/routes/templates_editor.py index d7c9848f0..439e8f5dc 100755 --- a/apps/api/app/api/routes/templates_editor.py +++ b/apps/api/app/api/routes/templates_editor.py @@ -186,6 +186,42 @@ class EditorPublishResponse(BaseModel): template_id: str status: str = "published" clip_count: int + version: int = 1 + + +class EditorTemplateVersionItem(BaseModel): + """模板版本历史条目""" + + version: int + name: str + editing_mode: str + clip_count: int + change_note: str + published_by: str + created_at: str + + +class EditorVersionListResponse(BaseModel): + """模板版本列表响应""" + + versions: list[EditorTemplateVersionItem] + total: int + + +class EditorRollbackRequest(BaseModel): + """回滚请求体""" + + version: int + + +class EditorRollbackResponse(BaseModel): + """回滚响应""" + + template_id: str + status: str = "rolled_back" + rollback_to_version: int + new_version: int + clip_count: int # ── Dependencies ──────────────────────────────────────────────────────────── @@ -295,6 +331,59 @@ def publish_draft_to_template( template_id=tpl.id, status="published", clip_count=len(clips), + version=tpl.version, + ) + + +@router.get("/versions", response_model=EditorVersionListResponse) +def list_template_versions( + template_id: str, + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), + limit: int = Query(default=50, ge=1, le=200), +): + """查询模板发布版本历史""" + tpl_svc, _ = services + versions = tpl_svc.list_template_versions(template_id, limit=limit) + items = [ + EditorTemplateVersionItem( + version=v.version, + name=v.name, + editing_mode=v.editing_mode, + clip_count=len(v.clip_configs), + change_note=v.change_note, + published_by=v.published_by, + created_at=v.created_at.isoformat() if hasattr(v.created_at, "isoformat") else str(v.created_at), + ) + for v in versions + ] + return EditorVersionListResponse(versions=items, total=len(items)) + + +@router.post("/rollback", response_model=EditorRollbackResponse, status_code=status.HTTP_200_OK) +def rollback_template( + template_id: str, + request: EditorRollbackRequest, + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """回滚模板到指定历史版本 + + 回滚本身也是一次发布,版本号会 +1,可以再次回滚。 + """ + tpl_svc, _ = services + try: + tpl = tpl_svc.rollback_to_version(template_id, request.version) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + clip_configs = tpl_svc.list_clip_configs(template_id) + return EditorRollbackResponse( + template_id=tpl.id, + status="rolled_back", + rollback_to_version=request.version, + new_version=tpl.version, + clip_count=len(clip_configs), ) diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index 40a3b8c8a..4fac5239a 100755 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -41,6 +41,11 @@ class EditTemplateService: self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) self._plan_repo = SQLAlchemyEditPlanRepository(db) self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db) + from packages.adapters.sqlalchemy_impl.template_version_repository import ( + SQLAlchemyTemplateVersionRepository, + ) + + self._version_repo = SQLAlchemyTemplateVersionRepository(db) self._db = db # ── 模板 CRUD ────────────────────────────────────────────────────────── @@ -649,6 +654,9 @@ class EditTemplateService: self, template_id: str, draft_plan_id: str, + *, + change_note: str = "", + published_by: str = "", ) -> Any: """将草稿剪辑计划的内容发布(同步)到模板 @@ -706,15 +714,54 @@ class EditTemplateService: # 5. 事务更新 try: + # 5.0 先保存旧版快照(发布前的状态),用于回滚 + old_version = template.version or 1 + old_clip_configs = self._clip_config_repo.list_by_template(template_id) + old_clip_snapshots = [ + { + "clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type, + "order": cfg.order, + "min_duration": cfg.min_duration, + "max_duration": cfg.max_duration, + "text_template": cfg.text_template or "", + "transition_effect": ( + cfg.transition_effect.value + if hasattr(cfg.transition_effect, "value") + else cfg.transition_effect + ), + "config": cfg.config or {}, + } + for cfg in old_clip_configs + ] + + from packages.domain.template_version import EditTemplateVersion + + old_snapshot = EditTemplateVersion.create( + template_id=template_id, + version=old_version, + name=template.name, + editing_mode=template.editing_mode, + config=dict(template.config) if template.config else {}, + clip_configs=old_clip_snapshots, + change_note=f"v{old_version} 快照(发布前)", + published_by=published_by, + ) + self._version_repo.create(old_snapshot) + # 更新模板元信息 template.config = template_config template.editing_mode = editing_mode + template.bump_version() # 版本号 +1 updated_template = self._template_repo.update(template) - # 删除旧的片段配置 - old_configs = self._clip_config_repo.list_by_template(template_id) - for cfg in old_configs: - self._clip_config_repo.delete(cfg.id) + # 批量删除旧的片段配置(N+1 → 1条DELETE,外层事务统一提交) + from packages.adapters.sqlalchemy_impl.models import ( + TemplateClipConfigModel, + ) + + self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete( + synchronize_session=False + ) # 创建新的片段配置 created_configs: list[TemplateClipConfig] = [] @@ -779,3 +826,140 @@ class EditTemplateService: exc, ) raise + + # ── 版本历史与回滚 ──────────────────────────────────────────────────── + + def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]: + """列出模板的发布版本历史(按版本号倒序)""" + self.get_template_or_raise(template_id) # 校验存在性 + return self._version_repo.list_by_template(template_id, limit=limit) + + def rollback_to_version(self, template_id: str, version: int) -> Any: + """回滚模板到指定历史版本 + + 流程: + 1. 校验目标版本存在 + 2. 保存当前状态为新版本快照(当前版本号) + 3. 用目标版本的快照覆盖模板 config + clip_configs + 4. 版本号 +1(回滚本身也是一次发布) + + Returns: + EditTemplate: 回滚后的模板 + + Raises: + ValueError: 模板/版本不存在 + """ + from packages.domain.template_clip_config import TemplateClipConfig + + template = self.get_template_or_raise(template_id) + + # 1. 读取目标版本快照 + target_version = self._version_repo.get_by_version(template_id, version) + if target_version is None: + raise ValueError(f"模板 {template_id} 不存在版本 {version}") + + current_version = template.version or 1 + + try: + # 2. 先保存当前状态快照(当前版本号),确保回滚可撤销 + old_clip_configs = self._clip_config_repo.list_by_template(template_id) + old_clip_snapshots = [ + { + "clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type, + "order": cfg.order, + "min_duration": cfg.min_duration, + "max_duration": cfg.max_duration, + "text_template": cfg.text_template or "", + "transition_effect": ( + cfg.transition_effect.value + if hasattr(cfg.transition_effect, "value") + else cfg.transition_effect + ), + "config": cfg.config or {}, + } + for cfg in old_clip_configs + ] + + from packages.domain.template_version import EditTemplateVersion + + current_snapshot = EditTemplateVersion.create( + template_id=template_id, + version=current_version, + name=template.name, + editing_mode=template.editing_mode, + config=dict(template.config) if template.config else {}, + clip_configs=old_clip_snapshots, + change_note=f"v{current_version} 快照(回滚到 v{version} 前)", + published_by="rollback", + ) + self._version_repo.create(current_snapshot) + + # 3. 覆盖模板配置 + editing_mode + name + preview_url + template.config = dict(target_version.config) + template.editing_mode = target_version.editing_mode + if target_version.name: + template.name = target_version.name + template.bump_version() # 版本号 +1 + updated_template = self._template_repo.update(template) + + # 4. 先删后插 clip_configs(批量删除避免N+1) + from packages.adapters.sqlalchemy_impl.models import ( + TemplateClipConfigModel, + ) + + self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete( + synchronize_session=False + ) + + for clip_snap in target_version.clip_configs: + # 转场效果兼容校验 + try: + from packages.domain.template_clip_config import TransitionEffect + + transition = TransitionEffect(clip_snap.get("transition_effect", "cut")) + except (ValueError, ImportError): + from packages.domain.template_clip_config import TransitionEffect + + transition = TransitionEffect.CUT + + # 片段类型兼容校验 + try: + from packages.domain.template_clip_config import ClipType + + clip_type = ClipType(clip_snap.get("clip_type", "main")) + except (ValueError, ImportError): + from packages.domain.template_clip_config import ClipType + + clip_type = ClipType.MAIN + + config_obj = TemplateClipConfig.create( + template_id=template_id, + clip_type=clip_type, + order=clip_snap.get("order", 0), + min_duration=clip_snap.get("min_duration", 0.0), + max_duration=clip_snap.get("max_duration", 0.0), + text_template=clip_snap.get("text_template", ""), + transition_effect=transition, + config=clip_snap.get("config", {}) or {}, + ) + self._clip_config_repo.create(config_obj) + + self._db.commit() + logger.info( + "模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d", + template_id, + current_version, + version, + updated_template.version, + ) + return updated_template + + except Exception as exc: + self._db.rollback() + logger.error( + "模板回滚失败: template_id=%s target_version=%d error=%s", + template_id, + version, + exc, + ) + raise diff --git a/packages/adapters/sqlalchemy_impl/edit_template_repository.py b/packages/adapters/sqlalchemy_impl/edit_template_repository.py old mode 100644 new mode 100755 index 122805f9e..79dcafdce --- a/packages/adapters/sqlalchemy_impl/edit_template_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_template_repository.py @@ -76,6 +76,7 @@ class SQLAlchemyEditTemplateRepository: preview_url=template.preview_url, sort_weight=template.sort_weight, status=template.status, + version=template.version, ) self.session.add(model) self.session.commit() @@ -95,6 +96,7 @@ class SQLAlchemyEditTemplateRepository: model.preview_url = template.preview_url model.sort_weight = template.sort_weight model.status = template.status + model.version = template.version model.updated_at = template.updated_at self.session.commit() self.session.refresh(model) @@ -135,6 +137,7 @@ class SQLAlchemyEditTemplateRepository: preview_url=model.preview_url or "", sort_weight=model.sort_weight or 0, status=EditTemplateStatus(model.status) if model.status else EditTemplateStatus.ACTIVE, + version=model.version or 1, created_at=model.created_at, updated_at=model.updated_at, ) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index c2cf17fbf..d4f209a5c 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -134,10 +134,31 @@ class EditTemplateModel(Base): preview_url = Column(String(1000), nullable=False, default="") sort_weight = Column(Integer, nullable=False, default=0, index=True) status = Column(String(20), nullable=False, default="active", index=True) + version = Column(Integer, nullable=False, default=1) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) +class EditTemplateVersionModel(Base): + """模板发布版本快照 ORM 模型 + + 每次发布保存完整快照,支持版本历史查询和回滚。 + """ + + __tablename__ = "edit_template_versions" + + id = Column(String(36), primary_key=True) + template_id = Column(String(32), nullable=False, index=True) + version = Column(Integer, nullable=False) + name = Column(String(200), nullable=False, default="") + editing_mode = Column(String(30), nullable=False, default="one_take") + config = Column(JSON, nullable=False, default=dict) + clip_configs = Column(JSON, nullable=False, default=list) + change_note = Column(String(500), nullable=False, default="") + published_by = Column(String(36), nullable=False, default="") + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + class EditPlanModel(Base): """Phase 8 剪辑计划 ORM 模型 diff --git a/packages/adapters/sqlalchemy_impl/template_version_repository.py b/packages/adapters/sqlalchemy_impl/template_version_repository.py new file mode 100755 index 000000000..7512a221d --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/template_version_repository.py @@ -0,0 +1,79 @@ +"""SQLAlchemy implementation of EditTemplateVersionRepository.""" + +from __future__ import annotations + +from typing import List + +from sqlalchemy.orm import Session + +from packages.domain.template_version import EditTemplateVersion + + +class SQLAlchemyTemplateVersionRepository: + """模板版本仓储实现(SQLAlchemy)。""" + + def __init__(self, db: Session) -> None: + self._db = db + + def create(self, version: EditTemplateVersion) -> EditTemplateVersion: + """保存新版本快照""" + from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel + + model = EditTemplateVersionModel( + id=version.id, + template_id=version.template_id, + version=version.version, + name=version.name, + editing_mode=version.editing_mode, + config=version.config, + clip_configs=version.clip_configs, + change_note=version.change_note, + published_by=version.published_by, + created_at=version.created_at, + ) + self._db.add(model) + self._db.flush() + return version + + def get_by_version(self, template_id: str, version: int) -> EditTemplateVersion | None: + """按版本号获取快照""" + from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel + + model = ( + self._db.query(EditTemplateVersionModel) + .filter( + EditTemplateVersionModel.template_id == template_id, + EditTemplateVersionModel.version == version, + ) + .first() + ) + if model is None: + return None + return self._to_entity(model) + + def list_by_template(self, template_id: str, limit: int = 50) -> List[EditTemplateVersion]: + """列出模板的所有历史版本(按版本号倒序)""" + from packages.adapters.sqlalchemy_impl.models import EditTemplateVersionModel + + models = ( + self._db.query(EditTemplateVersionModel) + .filter(EditTemplateVersionModel.template_id == template_id) + .order_by(EditTemplateVersionModel.version.desc()) + .limit(limit) + .all() + ) + return [self._to_entity(m) for m in models] + + def _to_entity(self, model) -> EditTemplateVersion: + return EditTemplateVersion( + id=model.id, + template_id=model.template_id, + version=model.version, + name=model.name or "", + editing_mode=model.editing_mode or "one_take", + config=model.config or {}, + clip_configs=model.clip_configs or [], + change_note=model.change_note or "", + published_by=model.published_by or "", + created_at=model.created_at, + ) diff --git a/packages/domain/edit_template.py b/packages/domain/edit_template.py old mode 100644 new mode 100755 index 7412f3dd0..57d0871c9 --- a/packages/domain/edit_template.py +++ b/packages/domain/edit_template.py @@ -51,6 +51,7 @@ class EditTemplate: preview_url: str = "" sort_weight: int = 0 status: EditTemplateStatus = EditTemplateStatus.ACTIVE + version: int = 1 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -66,6 +67,7 @@ class EditTemplate: preview_url: str = "", sort_weight: int = 0, status: EditTemplateStatus = EditTemplateStatus.ACTIVE, + version: int = 1, ) -> EditTemplate: """创建新模板实例""" clean_name = name.strip() @@ -86,6 +88,7 @@ class EditTemplate: preview_url=preview_url.strip(), sort_weight=sort_weight, status=status, + version=version, ) def activate(self) -> None: @@ -102,3 +105,8 @@ class EditTemplate: def is_active(self) -> bool: """模板是否处于激活状态""" return self.status == EditTemplateStatus.ACTIVE + + def bump_version(self) -> None: + """版本号+1,发布时调用""" + self.version += 1 + self.updated_at = datetime.now(timezone.utc) diff --git a/packages/domain/template_version.py b/packages/domain/template_version.py new file mode 100755 index 000000000..4a0dbfad9 --- /dev/null +++ b/packages/domain/template_version.py @@ -0,0 +1,53 @@ +"""EditTemplateVersion — 模板发布版本快照,用于回滚和版本历史.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + + +@dataclass(slots=True) +class EditTemplateVersion: + """模板发布版本快照 + + 每次发布时保存模板当时的完整状态(config + clip_configs), + 支持回滚到任意历史版本。 + """ + + id: str + template_id: str + version: int + name: str = "" + editing_mode: str = "one_take" + config: dict[str, Any] = field(default_factory=dict) + clip_configs: list[dict[str, Any]] = field(default_factory=list) + change_note: str = "" + published_by: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + template_id: str, + version: int, + *, + name: str = "", + editing_mode: str = "one_take", + config: dict[str, Any] | None = None, + clip_configs: list[dict[str, Any]] | None = None, + change_note: str = "", + published_by: str = "", + ) -> "EditTemplateVersion": + return cls( + id=uuid4().hex, + template_id=template_id, + version=version, + name=name, + editing_mode=editing_mode, + config=config or {}, + clip_configs=clip_configs or [], + change_note=change_note, + published_by=published_by, + ) diff --git a/tests/unit/test_oneclick_gen_p0_fixes.py b/tests/unit/test_oneclick_gen_p0_fixes.py index 6914fd7e6..7c382e1b7 100755 --- a/tests/unit/test_oneclick_gen_p0_fixes.py +++ b/tests/unit/test_oneclick_gen_p0_fixes.py @@ -239,6 +239,8 @@ class TestP1Validations: mock_template.id = "tmpl_001" mock_template.name = "Test Template" mock_template.is_active = True + mock_template.status = "active" + mock_template.version = 1 session = MagicMock() mock_session = MagicMock() diff --git a/tests/unit/test_templates_editor_api.py b/tests/unit/test_templates_editor_api.py old mode 100644 new mode 100755 index 02446c31d..361866831 --- a/tests/unit/test_templates_editor_api.py +++ b/tests/unit/test_templates_editor_api.py @@ -96,7 +96,16 @@ def _create_test_app(): id=TEST_TEMPLATE_ID, name="发布后的模板", status="published", + version=2, ) + mock_template_svc.list_template_versions.return_value = [] + mock_template_svc.rollback_to_version.return_value = MagicMock( + id=TEST_TEMPLATE_ID, + name="回滚后的模板", + status="active", + version=3, + ) + mock_template_svc.list_clip_configs.return_value = [] mock_plan_svc = MagicMock() mock_plan_svc.get_plan_or_raise.return_value = mock_plan @@ -449,3 +458,70 @@ class TestSubtitleRoutes: # --------------------------------------------------------------------------- # 片段调整端点测试 # --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# 版本管理端点测试 +# --------------------------------------------------------------------------- + + +class TestVersioningEndpoints: + """模板版本历史 + 回滚端点测试""" + + def test_publish_returns_version(self, client): + """发布后返回新版本号""" + c, mock_tpl_svc, _ = client + resp = c.post(BASE + "/publish") + assert resp.status_code == 200 + data = resp.json() + assert data["version"] == 2 + assert data["status"] == "published" + + def test_list_versions_empty(self, client): + """查询版本历史,空列表也正常返回""" + c, mock_tpl_svc, _ = client + resp = c.get(BASE + "/versions") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["versions"] == [] + mock_tpl_svc.list_template_versions.assert_called_once_with(TEST_TEMPLATE_ID, limit=50) + + def test_list_versions_with_limit(self, client): + """版本历史支持 limit 参数""" + c, mock_tpl_svc, _ = client + resp = c.get(BASE + "/versions?limit=10") + assert resp.status_code == 200 + mock_tpl_svc.list_template_versions.assert_called_once_with(TEST_TEMPLATE_ID, limit=10) + + def test_list_versions_limit_too_large_422(self, client): + """limit 超过上限返回 422""" + c, _, _ = client + resp = c.get(BASE + "/versions?limit=500") + assert resp.status_code == 422 + + def test_rollback_success(self, client): + """回滚到指定版本成功""" + c, mock_tpl_svc, _ = client + resp = c.post(BASE + "/rollback", json={"version": 1}) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "rolled_back" + assert data["rollback_to_version"] == 1 + assert data["new_version"] == 3 + assert data["template_id"] == TEST_TEMPLATE_ID + mock_tpl_svc.rollback_to_version.assert_called_once_with(TEST_TEMPLATE_ID, 1) + + def test_rollback_missing_version_422(self, client): + """回滚请求缺 version 返回 422""" + c, _, _ = client + resp = c.post(BASE + "/rollback", json={}) + assert resp.status_code == 422 + + def test_rollback_value_error_400(self, client): + """回滚目标版本不存在返回 400""" + c, mock_tpl_svc, _ = client + mock_tpl_svc.rollback_to_version.side_effect = ValueError("版本不存在") + resp = c.post(BASE + "/rollback", json={"version": 99}) + assert resp.status_code == 400 + assert "不存在" in resp.json()["detail"]