feat: 统一模板 segments 数据源为 template_clip_configs
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 380h17m7s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m18s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 4m20s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Failing after 4m56s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 380h51m22s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m0s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 380h54m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 380h54m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 380h20m11s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 380h25m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 380h50m18s
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
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 380h16m3s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 380h16m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 380h16m11s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 380h20m7s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 380h17m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 380h20m28s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 380h20m31s

- template_repository 读取优先 template_clip_configs,回退 template_segments
- create_segments 写入 template_clip_configs,material_type 存 config JSON
- copy_template 复用 create_segments,使用 created.id
- delete/delete_segments 清理两张表
- list_by_user 批量从 clip_configs 加载
- migration 060: 一次性迁移历史 template_segments 数据
- 11 个单元测试全部通过
- 不影响前端、不影响编辑器发布流程

Closes #1579
This commit is contained in:
xiaoxia
2026-08-31 18:57:32 +08:00
parent e052c0545e
commit 5224870f8d
3 changed files with 463 additions and 43 deletions
@@ -0,0 +1,67 @@
"""migrate template_segments data to template_clip_configs
将 template_segments 中的历史数据一次性迁移到 template_clip_configs。
只迁移 template_clip_configs 中尚不存在对应 template_id 的记录,避免覆盖编辑器已发布数据。
Revision ID: 060_migrate_segments
Revises: 059_duplicate_rate
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "060_migrate_segments"
down_revision = "059_duplicate_rate"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 从 template_segments 迁移到 template_clip_configs
# 字段映射:
# segment_order -> order
# duration_min -> min_duration
# duration_max -> max_duration
# material_type -> config JSON {"material_type": ...}
# clip_type -> "main" (默认值)
# 只迁移 template_clip_configs 中没有对应 template_id 的记录
op.execute(
sa.text(
"""
INSERT INTO template_clip_configs
(id, template_id, clip_type, "order", min_duration, max_duration,
text_template, material_requirements, transition_effect, config,
created_at, updated_at)
SELECT
s.id,
s.template_id,
'main',
s.segment_order,
s.duration_min,
s.duration_max,
'',
'{}',
'cut',
CASE
WHEN s.material_type IS NOT NULL AND s.material_type != ''
THEN JSON_OBJECT('material_type', s.material_type)
ELSE '{}'
END,
s.created_at,
s.updated_at
FROM template_segments s
WHERE NOT EXISTS (
SELECT 1 FROM template_clip_configs c
WHERE c.template_id = s.template_id
)
"""
)
)
def downgrade() -> None:
# 不可逆迁移:无法确定哪些 clip_configs 是从旧表迁移来的
# 保留空实现,不回滚
pass
@@ -1,4 +1,9 @@
"""SQLAlchemy implementation of TemplateRepository."""
"""SQLAlchemy implementation of TemplateRepository.
模板 segments 数据源已统一为 template_clip_configs 表。
读取时优先 template_clip_configs,回退 template_segments(兼容历史数据)。
写入全部走 template_clip_configs。
"""
from __future__ import annotations
@@ -10,6 +15,7 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
EditPlanModel,
TemplateCategoryModel,
TemplateClipConfigModel,
TemplateModel,
TemplateSegmentModel,
)
@@ -47,27 +53,38 @@ class SQLAlchemyTemplateRepository:
like_pattern = f"%{keyword}%"
query = query.filter(TemplateModel.name.like(like_pattern))
if tag:
# JSON 数组包含指定标签(MySQL JSON_CONTAINS / SQLite json_each 兼容写法用 LIKE)
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
models = query.order_by(TemplateModel.created_at.desc()).offset(skip).limit(limit).all()
templates = [self._model_to_entity(m) for m in models]
# 批量加载所有 segments,避免 N+1 查询
# 批量加载 segments —— 优先 template_clip_configs
if templates:
template_ids = [t.id for t in templates]
seg_models = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id.in_(template_ids))
.order_by(TemplateSegmentModel.segment_order)
clip_models = (
self.session.query(TemplateClipConfigModel)
.filter(TemplateClipConfigModel.template_id.in_(template_ids))
.order_by(TemplateClipConfigModel.order)
.all()
)
# 按 template_id 分组
seg_map: dict[str, list] = {}
for sm in seg_models:
seg_map.setdefault(sm.template_id, []).append(
self._segment_model_to_entity(sm),
clip_map: dict[str, list] = {}
for cm in clip_models:
clip_map.setdefault(cm.template_id, []).append(
self._clip_config_to_segment(cm),
)
# 对没有 clip_configs 的模板,回退读 template_segments
missing_ids = [t.id for t in templates if t.id not in clip_map]
if missing_ids:
old_models = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id.in_(missing_ids))
.order_by(TemplateSegmentModel.segment_order)
.all()
)
for om in old_models:
clip_map.setdefault(om.template_id, []).append(
self._segment_model_to_entity(om),
)
for t in templates:
t.segments = seg_map.get(t.id, [])
t.segments = clip_map.get(t.id, [])
return templates
def get(self, template_id: str, user_id: str) -> Optional[Template]:
@@ -100,7 +117,6 @@ class SQLAlchemyTemplateRepository:
is_active=template.is_active,
)
self.session.add(model)
# flush 而非 commit,让 create + create_segments 在同一事务中提交
self.session.flush()
self.session.refresh(model)
result = self._model_to_entity(model)
@@ -145,7 +161,10 @@ class SQLAlchemyTemplateRepository:
if model is None:
return False
model.is_active = False
# 级联清理关联的 segments,避免孤儿数据
# 级联清理两张表的关联数据
self.session.query(TemplateClipConfigModel).filter(
TemplateClipConfigModel.template_id == template_id,
).delete(synchronize_session=False)
self.session.query(TemplateSegmentModel).filter(
TemplateSegmentModel.template_id == template_id,
).delete(synchronize_session=False)
@@ -172,7 +191,7 @@ class SQLAlchemyTemplateRepository:
if keyword:
query = query.filter(TemplateModel.name.like(f"%{keyword}%"))
if tag:
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
return query.count()
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template:
@@ -181,9 +200,8 @@ class SQLAlchemyTemplateRepository:
if source is None:
raise ValueError(f"Template {template_id} not found")
new_id = str(uuid.uuid4())
new_template = Template(
id=new_id,
id=str(uuid.uuid4()),
user_id=user_id,
name=new_name,
mode=source.mode,
@@ -197,28 +215,20 @@ class SQLAlchemyTemplateRepository:
)
created = self.create(new_template)
# 复制 segments
# 复用 create_segments 写入 template_clip_configs
new_segments: List[TemplateSegment] = []
for seg in source.segments:
new_seg = TemplateSegment(
new_segments.append(TemplateSegment(
id=str(uuid.uuid4()),
template_id=new_id,
template_id=created.id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
new_segments.append(new_seg)
model = TemplateSegmentModel(
id=new_seg.id,
template_id=new_seg.template_id,
segment_order=new_seg.segment_order,
duration_min=new_seg.duration_min,
duration_max=new_seg.duration_max,
material_type=new_seg.material_type,
)
self.session.add(model)
))
if new_segments:
self.create_segments(new_segments)
else:
self.session.commit()
created.segments = new_segments
@@ -227,34 +237,58 @@ class SQLAlchemyTemplateRepository:
# ── Segments ──
def list_segments(self, template_id: str) -> List[TemplateSegment]:
models = (
"""优先从 template_clip_configs 读取,回退读 template_segments。"""
clips = (
self.session.query(TemplateClipConfigModel)
.filter(TemplateClipConfigModel.template_id == template_id)
.order_by(TemplateClipConfigModel.order)
.all()
)
if clips:
return [self._clip_config_to_segment(m) for m in clips]
# 回退:旧表
old = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id == template_id)
.order_by(TemplateSegmentModel.segment_order)
.all()
)
return [self._segment_model_to_entity(m) for m in models]
return [self._segment_model_to_entity(m) for m in old]
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
"""写入 template_clip_configs 表。material_type 存入 config JSON。"""
for seg in segments:
model = TemplateSegmentModel(
config = {"material_type": seg.material_type} if seg.material_type else {}
model = TemplateClipConfigModel(
id=seg.id,
template_id=seg.template_id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
clip_type="main",
order=seg.segment_order,
min_duration=seg.duration_min,
max_duration=seg.duration_max,
text_template="",
material_requirements={},
transition_effect="cut",
config=config,
)
self.session.add(model)
self.session.commit()
return segments
def delete_segments_by_template(self, template_id: str) -> int:
count = (
self.session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == template_id).delete()
"""删除两张表中的 segments 数据,返回删除总数。"""
c1 = (
self.session.query(TemplateClipConfigModel)
.filter(TemplateClipConfigModel.template_id == template_id)
.delete()
)
c2 = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id == template_id)
.delete()
)
self.session.commit()
return count
return c1 + c2
# ── Categories ──
@@ -366,6 +400,23 @@ class SQLAlchemyTemplateRepository:
updated_at=model.updated_at,
)
@staticmethod
def _clip_config_to_segment(model: TemplateClipConfigModel) -> TemplateSegment:
"""将 TemplateClipConfigModel 转换为 TemplateSegment 域实体。"""
material_type = None
if model.config and isinstance(model.config, dict):
material_type = model.config.get("material_type")
return TemplateSegment(
id=model.id,
template_id=model.template_id,
segment_order=model.order,
duration_min=model.min_duration,
duration_max=model.max_duration,
material_type=material_type,
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
return TemplateCategory(
+302
View File
@@ -0,0 +1,302 @@
"""统一模板 segments 数据源单元测试。
验证 template_repository 从 template_clip_configs 读取 segments,
写入走 template_clip_configs,回退兼容 template_segments。
"""
from __future__ import annotations
import sys
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.models import (
Base,
TemplateClipConfigModel,
TemplateModel,
TemplateSegmentModel,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.template import Template, TemplateSegment
@pytest.fixture()
def session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
s = Session()
try:
yield s
finally:
s.close()
@pytest.fixture()
def repo(session):
return SQLAlchemyTemplateRepository(session)
def _make_template(
template_id=None,
user_id="u1",
name="测试模板",
mode="one_take",
segments=None,
):
tid = template_id or str(uuid.uuid4())
return Template(
id=tid,
user_id=user_id,
name=name,
mode=mode,
category="",
tags=[],
estimated_duration=30.0,
is_active=True,
segments=segments or [],
)
def _make_segment(template_id, order=1, material_type=None):
return TemplateSegment(
id=str(uuid.uuid4()),
template_id=template_id,
segment_order=order,
duration_min=5.0,
duration_max=10.0,
material_type=material_type,
)
# ── 写入测试 ─────────────────────────────────────────────────────────────
class TestCreateSegments:
def test_writes_to_clip_configs(self, repo, session):
"""create_segments 写入 template_clip_configs 表"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1)
repo.create_segments([seg])
clips = session.query(TemplateClipConfigModel).filter(
TemplateClipConfigModel.template_id == tpl.id
).all()
assert len(clips) == 1
assert clips[0].clip_type == "main"
assert clips[0].order == 1
assert clips[0].min_duration == 5.0
assert clips[0].max_duration == 10.0
assert clips[0].config.get("material_type") is None
def test_material_type_stored_in_config(self, repo, session):
"""material_type 存入 config JSON"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
repo.create_segments([seg])
clip = session.query(TemplateClipConfigModel).filter(
TemplateClipConfigModel.template_id == tpl.id
).first()
assert clip.config["material_type"] == "voiceover"
# ── 读取测试 ─────────────────────────────────────────────────────────────
class TestListSegments:
def test_reads_from_clip_configs(self, repo, session):
"""list_segments 优先从 template_clip_configs 读取"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
repo.create_segments([seg])
result = repo.list_segments(tpl.id)
assert len(result) == 1
assert result[0].segment_order == 1
assert result[0].duration_min == 5.0
assert result[0].duration_max == 10.0
assert result[0].material_type == "voiceover"
def test_fallback_to_old_table(self, repo, session):
"""clip_configs 为空时回退读 template_segments"""
tpl = _make_template()
repo.create(tpl)
# 直接往旧表写入
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=tpl.id,
segment_order=1,
duration_min=3.0,
duration_max=8.0,
material_type="场景",
)
session.add(old)
session.commit()
result = repo.list_segments(tpl.id)
assert len(result) == 1
assert result[0].material_type == "场景"
assert result[0].duration_min == 3.0
def test_clip_configs_takes_priority(self, repo, session):
"""两张表都有数据时,clip_configs 优先"""
tpl = _make_template()
repo.create(tpl)
# 写入 clip_configs
seg = _make_segment(tpl.id, order=1)
repo.create_segments([seg])
# 也写入旧表
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=tpl.id,
segment_order=1,
duration_min=1.0,
duration_max=2.0,
material_type=None,
)
session.add(old)
session.commit()
result = repo.list_segments(tpl.id)
assert len(result) == 1
# 应该返回 clip_configs 的数据(min_duration=5.0),不是旧表的(1.0)
assert result[0].duration_min == 5.0
# ── list_by_user 批量加载 ────────────────────────────────────────────────
class TestListByUser:
def test_batch_loads_from_clip_configs(self, repo, session):
"""list_by_user 批量从 clip_configs 加载 segments"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1, material_type="人物")
repo.create_segments([seg])
result = repo.list_by_user("u1")
assert len(result) == 1
assert len(result[0].segments) == 1
assert result[0].segments[0].material_type == "人物"
def test_fallback_for_old_data(self, repo, session):
"""list_by_user 对无 clip_configs 的模板回退读旧表"""
tpl = _make_template()
repo.create(tpl)
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=tpl.id,
segment_order=1,
duration_min=2.0,
duration_max=6.0,
material_type=None,
)
session.add(old)
session.commit()
result = repo.list_by_user("u1")
assert len(result) == 1
assert len(result[0].segments) == 1
assert result[0].segments[0].duration_min == 2.0
# ── copy_template ────────────────────────────────────────────────────────
class TestCopyTemplate:
def test_copy_writes_to_clip_configs(self, repo, session):
"""复制模板的 segments 写入 clip_configs"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
repo.create_segments([seg])
copied = repo.copy_template(tpl.id, "u1", "副本模板")
assert copied.id != tpl.id
assert len(copied.segments) == 1
# 验证写入的是 clip_configs 表
clips = session.query(TemplateClipConfigModel).filter(
TemplateClipConfigModel.template_id == copied.id
).all()
assert len(clips) == 1
assert clips[0].config["material_type"] == "voiceover"
def test_copy_empty_segments(self, repo, session):
"""复制无 segments 的模板不报错"""
tpl = _make_template()
repo.create(tpl)
copied = repo.copy_template(tpl.id, "u1", "空副本")
assert len(copied.segments) == 0
# ── delete ───────────────────────────────────────────────────────────────
class TestDelete:
def test_delete_cleans_both_tables(self, repo, session):
"""删除模板时清理两张表的关联数据"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1)
repo.create_segments([seg])
# 也写入旧表
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=tpl.id,
segment_order=1,
duration_min=1.0,
duration_max=2.0,
)
session.add(old)
session.commit()
repo.delete(tpl.id, "u1")
# 两张表都应该被清理
c1 = session.query(TemplateClipConfigModel).filter(
TemplateClipConfigModel.template_id == tpl.id
).count()
c2 = session.query(TemplateSegmentModel).filter(
TemplateSegmentModel.template_id == tpl.id
).count()
assert c1 == 0
assert c2 == 0
def test_delete_segments_by_template(self, repo, session):
"""delete_segments_by_template 清理两张表并返回总数"""
tpl = _make_template()
repo.create(tpl)
seg = _make_segment(tpl.id, order=1)
repo.create_segments([seg])
old = TemplateSegmentModel(
id=str(uuid.uuid4()),
template_id=tpl.id,
segment_order=2,
duration_min=1.0,
duration_max=2.0,
)
session.add(old)
session.commit()
count = repo.delete_segments_by_template(tpl.id)
assert count == 2 # 1 from clip_configs + 1 from old table