From f34b076dcd876c06fc03485f167ad35ab6df42d9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 31 Aug 2026 21:09:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ci):=20Web=20=E9=95=9C=E5=83=8F=20build?= =?UTF-8?q?x=20=E5=B9=BD=E7=81=B5=E7=BC=93=E5=AD=98=E9=98=B2=E6=8A=A4?= =?UTF-8?q?=E2=80=94=E2=80=94Git=20Tree=20Hash=20Cache=20Bust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:ci-builder-persist 持久化 builder 的 buildkit 层缓存偶尔出现'幽灵命中': COPY apps/web/ ./ 步骤认为自己没变(实际文件已改),导致 vite build 不执行, 打出来的 Web 镜像前端文件是旧的。 修复原理: 把 apps/web/ 目录的 git tree hash 作为 build arg 传入 Dockerfile。 buildx 把 build arg 值作为缓存键的一部分,hash 变了 → RUN 步骤缓存失效 → vite build 必须重新执行。依赖层(npm ci)不受影响,仍然正常缓存。 改动: 1. web.Dockerfile:新增 ARG SOURCE_HASH,在构建步骤写入 .cache_bust 文件 2. docker_build_push.sh:Web 镜像构建时计算 git tree hash 并传入 3. docker_build_only.sh:同上 验证: - 合并后触发 push 到 develop - 查看 Build Staging Web Image 日志,确认输出 Web cache bust: SOURCE_HASH=xxxxx - 修改 apps/web/ 下文件再次 push,确认 SOURCE_HASH 值变化且 vite build 执行 --- infra/docker/web.Dockerfile | 3 +++ scripts/ci/docker_build_only.sh | 12 ++++++++++++ scripts/ci/docker_build_push.sh | 12 ++++++++++++ 3 files changed, 27 insertions(+) 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/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 不存在,创建中..." -- 2.54.0 From 148b41a5d6f7f23be0692c35aadddc430a821da1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 31 Aug 2026 13:32:33 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + prettier [skip ci-format-check] --- ...grate_template_segments_to_clip_configs.py | 4 +- .../sqlalchemy_impl/template_repository.py | 18 +++--- tests/unit/test_unify_template_segments.py | 62 +++++++++++-------- 3 files changed, 49 insertions(+), 35 deletions(-) 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/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/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) -- 2.54.0