diff --git a/alembic/versions/060_migrate_template_segments_to_clip_configs.py b/alembic/versions/060_migrate_template_segments_to_clip_configs.py index 146c601db..5df89d2cf 100644 --- a/alembic/versions/060_migrate_template_segments_to_clip_configs.py +++ b/alembic/versions/060_migrate_template_segments_to_clip_configs.py @@ -6,6 +6,7 @@ Create Date: 2026-08-31 """ import sqlalchemy as sa + from alembic import op revision = "060_migrate_segments" @@ -41,8 +42,7 @@ def upgrade() -> None: "s.id, s.template_id, 'main', s.segment_order, " "s.duration_min, s.duration_max, " "'', " + empty_json + ", " - "'cut', " - + config_expr + ", " + "'cut', " + config_expr + ", " "s.created_at, s.updated_at " "FROM template_segments s " "WHERE NOT EXISTS (" diff --git a/infra/docker/web.Dockerfile b/infra/docker/web.Dockerfile index 6465e56fe..10cd2f15d 100755 --- a/infra/docker/web.Dockerfile +++ b/infra/docker/web.Dockerfile @@ -1,5 +1,6 @@ # Build stage FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder +ARG SOURCE_HASH="" WORKDIR /app ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com ENV VITE_API_URL=$VITE_API_URL @@ -18,8 +19,10 @@ COPY apps/web/ ./ # 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化 # node_modules直接使用镜像中已安装的(layer缓存保证完整性) +# SOURCE_HASH 变化时强制重新执行(防止 buildkit 幽灵缓存命中) RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \ mkdir -p .tscache \ + && echo "SOURCE_HASH=${SOURCE_HASH}" > .cache_bust \ && ./node_modules/.bin/tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \ && ./node_modules/.bin/vite build diff --git a/packages/adapters/sqlalchemy_impl/template_repository.py b/packages/adapters/sqlalchemy_impl/template_repository.py index 743999b2e..275d6bc3d 100755 --- a/packages/adapters/sqlalchemy_impl/template_repository.py +++ b/packages/adapters/sqlalchemy_impl/template_repository.py @@ -212,14 +212,16 @@ class SQLAlchemyTemplateRepository: # 复用 create_segments 写入 template_clip_configs new_segments: List[TemplateSegment] = [] for seg in source.segments: - new_segments.append(TemplateSegment( - id=str(uuid.uuid4()), - 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( + TemplateSegment( + id=str(uuid.uuid4()), + template_id=created.id, + segment_order=seg.segment_order, + duration_min=seg.duration_min, + duration_max=seg.duration_max, + material_type=seg.material_type, + ) + ) if new_segments: self.create_segments(new_segments) else: diff --git a/scripts/ci/docker_build_only.sh b/scripts/ci/docker_build_only.sh index aed388ba9..20070b717 100755 --- a/scripts/ci/docker_build_only.sh +++ b/scripts/ci/docker_build_only.sh @@ -19,6 +19,18 @@ for arg in "$@"; do BUILD_ARGS="$BUILD_ARGS --build-arg $arg" done +# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash +# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行 +if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then + SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "") + if [ -n "$SOURCE_HASH" ]; then + echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}" + BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}" + else + echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust" + fi +fi + BUILDER_NAME="ci-builder-persist" if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then echo "持久 builder 不存在,创建中..." diff --git a/scripts/ci/docker_build_push.sh b/scripts/ci/docker_build_push.sh index 26fbc8bb5..0f0fc018e 100755 --- a/scripts/ci/docker_build_push.sh +++ b/scripts/ci/docker_build_push.sh @@ -31,6 +31,18 @@ for arg in "$@"; do BUILD_ARGS="$BUILD_ARGS --build-arg $arg" done +# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash +# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行 +if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then + SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "") + if [ -n "$SOURCE_HASH" ]; then + echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}" + BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}" + else + echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust" + fi +fi + # 确保持久 builder 存在并使用(幂等) if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then echo "持久 builder 不存在,创建中..." diff --git a/tests/unit/test_unify_template_segments.py b/tests/unit/test_unify_template_segments.py index 5c9557275..1f6e2d4af 100644 --- a/tests/unit/test_unify_template_segments.py +++ b/tests/unit/test_unify_template_segments.py @@ -48,15 +48,27 @@ def repo(session): def _make_template(template_id=None, user_id="u1", name="测试模板", mode="one_take"): 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=[]) + id=tid, + user_id=user_id, + name=name, + mode=mode, + category="", + tags=[], + estimated_duration=30.0, + is_active=True, + segments=[], + ) 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) + 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: @@ -65,8 +77,7 @@ class TestCreateSegments: 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() + 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 @@ -77,8 +88,7 @@ class TestCreateSegments: 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() + clip = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).first() assert clip.config["material_type"] == "voiceover" @@ -96,8 +106,13 @@ class TestListSegments: 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="场景") + 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) @@ -110,8 +125,8 @@ class TestListSegments: 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) + id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0 + ) session.add(old) session.commit() result = repo.list_segments(tpl.id) @@ -134,8 +149,8 @@ class TestListByUser: 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) + id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=2.0, duration_max=6.0 + ) session.add(old) session.commit() result = repo.list_by_user("u1") @@ -152,8 +167,7 @@ class TestCopyTemplate: repo.create_segments([seg]) copied = repo.copy_template(tpl.id, "u1", "副本模板") assert copied.id != tpl.id - clips = session.query(TemplateClipConfigModel).filter( - TemplateClipConfigModel.template_id == copied.id).all() + clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == copied.id).all() assert len(clips) == 1 assert clips[0].config["material_type"] == "voiceover" @@ -171,15 +185,13 @@ class TestDelete: 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) + 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() + 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 @@ -189,8 +201,8 @@ class TestDelete: 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) + 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)