From c3dfb5eddbf41687bf5744d8446a26688fe483ba Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Jul 2026 12:21:02 +0800 Subject: [PATCH] fix(web): retain prior hashed assets across releases --- Dockerfile.ops | 22 ++++++++ Dockerfile.tenant | 32 ++++++++++-- Jenkinsfile | 5 +- Jenkinsfile.ops-web | 5 +- Jenkinsfile.tenant-web | 5 +- docs-site/docs/.vitepress/config.ts | 6 +++ docs/SDK_PLATFORM_V2_HANDOFF.md | 13 ++++- nginx/default.conf | 20 ++++++++ nginx/ops.conf | 8 +++ nginx/tenant.conf | 19 +++++++ ops-platform/vite.config.ts | 5 ++ package.json | 4 +- scripts/nginx-test.conf | 10 ++++ scripts/verify-web-cache-contract.mjs | 74 +++++++++++++++++++++++++++ tenant-platform/vite.config.ts | 6 +++ 15 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 scripts/nginx-test.conf create mode 100644 scripts/verify-web-cache-contract.mjs diff --git a/Dockerfile.ops b/Dockerfile.ops index 668422a..7a9250f 100644 --- a/Dockerfile.ops +++ b/Dockerfile.ops @@ -1,6 +1,24 @@ # syntax=docker/dockerfile:1.7 +ARG PREVIOUS_IMAGE=nginx:1.27-alpine +ARG SERVICE_VERSION=0.0.0 + +FROM ${PREVIOUS_IMAGE} AS previous +RUN set -eu; \ + mkdir -p /xuqm-previous; \ + if [ -f /etc/xuqm_asset_layout_v2 ] && [ -s /etc/service_version ]; then \ + previous_version="$(cat /etc/service_version)"; \ + source_dir="/usr/share/nginx/html/releases/${previous_version}"; \ + if [ -d "${source_dir}" ]; then \ + mkdir -p /xuqm-previous/releases; \ + cp -a "${source_dir}" /xuqm-previous/releases/; \ + fi; \ + elif [ -d /usr/share/nginx/html/assets ]; then \ + cp -a /usr/share/nginx/html/assets /xuqm-previous/; \ + fi + FROM --platform=linux/amd64 node:22-alpine AS build WORKDIR /workspace +ARG SERVICE_VERSION COPY package.json ./package.json COPY yarn.lock ./yarn.lock @@ -22,15 +40,19 @@ ARG OPS_API_BASE_URL=/api RUN cd ops-platform && \ VITE_APP_BASE=${OPS_APP_BASE} \ VITE_API_BASE_URL=${OPS_API_BASE_URL} \ + VITE_RELEASE_ID=${SERVICE_VERSION} \ yarn build FROM --platform=linux/amd64 nginx:1.27-alpine ARG SERVICE_VERSION=0.0.0 +RUN echo "${SERVICE_VERSION}" > /etc/service_version LABEL com.xuqm.version="${SERVICE_VERSION}" LABEL com.xuqm.service="ops-web" COPY nginx/ops.conf /etc/nginx/conf.d/default.conf +COPY --from=previous /xuqm-previous/ /usr/share/nginx/html/ COPY --from=build /workspace/ops-platform/dist /usr/share/nginx/html +RUN touch /etc/xuqm_asset_layout_v2 EXPOSE 80 diff --git a/Dockerfile.tenant b/Dockerfile.tenant index e84e9dc..ea45d13 100644 --- a/Dockerfile.tenant +++ b/Dockerfile.tenant @@ -1,6 +1,31 @@ # syntax=docker/dockerfile:1.7 +ARG PREVIOUS_IMAGE=nginx:1.27-alpine +ARG SERVICE_VERSION=0.0.0 + +# 只抽取上一镜像的“当前版本”资源。首次切换新目录协议时额外保留一次旧 /assets, +# 下一次构建会因 layout 标记存在而自动淘汰,避免历史资源无限累积。 +FROM ${PREVIOUS_IMAGE} AS previous +RUN set -eu; \ + mkdir -p /xuqm-previous/tenant /xuqm-previous/docs; \ + if [ -f /etc/xuqm_asset_layout_v2 ] && [ -s /etc/service_version ]; then \ + previous_version="$(cat /etc/service_version)"; \ + for app in tenant docs; do \ + source_dir="/usr/share/nginx/html/${app}/releases/${previous_version}"; \ + if [ -d "${source_dir}" ]; then \ + mkdir -p "/xuqm-previous/${app}/releases"; \ + cp -a "${source_dir}" "/xuqm-previous/${app}/releases/"; \ + fi; \ + done; \ + else \ + for app in tenant docs; do \ + source_dir="/usr/share/nginx/html/${app}/assets"; \ + if [ -d "${source_dir}" ]; then cp -a "${source_dir}" "/xuqm-previous/${app}/"; fi; \ + done; \ + fi + FROM --platform=linux/amd64 node:22-alpine AS build WORKDIR /workspace +ARG SERVICE_VERSION COPY package.json ./package.json COPY yarn.lock ./yarn.lock @@ -23,20 +48,21 @@ ARG TENANT_API_BASE_URL=/api RUN cd tenant-platform && \ VITE_APP_BASE=${TENANT_APP_BASE} \ VITE_API_BASE_URL=${TENANT_API_BASE_URL} \ + VITE_RELEASE_ID=${SERVICE_VERSION} \ yarn build -RUN cd docs-site && yarn build +RUN cd docs-site && VITE_RELEASE_ID=${SERVICE_VERSION} yarn build FROM --platform=linux/amd64 nginx:1.27-alpine ARG SERVICE_VERSION=0.0.0 -# RUN 写文件确保 SERVICE_VERSION 变化时 BuildKit inline cache 失效(LABEL 单独不可靠) -RUN echo "${SERVICE_VERSION}" > /etc/service_version LABEL com.xuqm.version="${SERVICE_VERSION}" LABEL com.xuqm.service="tenant-web" COPY nginx/tenant.conf /etc/nginx/conf.d/default.conf +COPY --from=previous /xuqm-previous/ /usr/share/nginx/html/ COPY --from=build /workspace/tenant-platform/dist /usr/share/nginx/html/tenant COPY --from=build /workspace/docs-site/docs/.vitepress/dist /usr/share/nginx/html/docs +RUN echo "${SERVICE_VERSION}" > /etc/service_version && touch /etc/xuqm_asset_layout_v2 EXPOSE 80 diff --git a/Jenkinsfile b/Jenkinsfile index 095bedf..b906b57 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -88,8 +88,9 @@ pipeline { chcp 65001 >nul docker login ${env.ACR_REGISTRY} -u ${env.ACR_USERNAME} -p %ACR_PASS% if %errorlevel% neq 0 exit /b 1 - docker pull --platform=linux/amd64 ${latestImage} || echo Pull failed, will build fresh - docker build --platform=linux/amd64 -f ${env.DOCKERFILE} ${env.BUILD_ARGS} --build-arg GIT_COMMIT=${gitCommit} --build-arg SERVICE_VERSION=${svcVersion} ${cacheArgs} -t ${versionedImage} -t ${latestImage} . + set "PREVIOUS_IMAGE=nginx:1.27-alpine" + docker pull --platform=linux/amd64 ${latestImage} && set "PREVIOUS_IMAGE=${latestImage}" || echo Previous image unavailable, first release will not retain assets + docker build --platform=linux/amd64 -f ${env.DOCKERFILE} ${env.BUILD_ARGS} --build-arg PREVIOUS_IMAGE=%PREVIOUS_IMAGE% --build-arg GIT_COMMIT=${gitCommit} --build-arg SERVICE_VERSION=${svcVersion} ${cacheArgs} -t ${versionedImage} -t ${latestImage} . if %errorlevel% neq 0 exit /b 1 docker push ${versionedImage} if %errorlevel% neq 0 exit /b 1 diff --git a/Jenkinsfile.ops-web b/Jenkinsfile.ops-web index 09dd4c3..2f871cc 100644 --- a/Jenkinsfile.ops-web +++ b/Jenkinsfile.ops-web @@ -60,8 +60,9 @@ pipeline { bat """ docker login ${env.ACR_REGISTRY} -u ${env.ACR_USERNAME} -p %ACR_PASS% if %errorlevel% neq 0 exit /b 1 - docker pull --platform=linux/amd64 ${latestImage} || echo Pull failed, will build fresh - docker build --platform=linux/amd64 -f ${env.DOCKERFILE} ${env.BUILD_ARGS} --build-arg GIT_COMMIT=${gitCommit} --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${latestImage} -t ${versionedImage} -t ${latestImage} . + set "PREVIOUS_IMAGE=nginx:1.27-alpine" + docker pull --platform=linux/amd64 ${latestImage} && set "PREVIOUS_IMAGE=${latestImage}" || echo Previous image unavailable, first release will not retain assets + docker build --platform=linux/amd64 -f ${env.DOCKERFILE} ${env.BUILD_ARGS} --build-arg PREVIOUS_IMAGE=%PREVIOUS_IMAGE% --build-arg GIT_COMMIT=${gitCommit} --build-arg SERVICE_VERSION=${env.IMAGE_VERSION} --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${latestImage} -t ${versionedImage} -t ${latestImage} . if %errorlevel% neq 0 exit /b 1 docker push ${versionedImage} if %errorlevel% neq 0 exit /b 1 diff --git a/Jenkinsfile.tenant-web b/Jenkinsfile.tenant-web index 8062aa0..fb3c34f 100644 --- a/Jenkinsfile.tenant-web +++ b/Jenkinsfile.tenant-web @@ -90,8 +90,9 @@ pipeline { bat """ docker login ${env.ACR_REGISTRY} -u ${env.ACR_USERNAME} -p %ACR_PASS% if %errorlevel% neq 0 exit /b 1 - docker pull --platform=linux/amd64 ${latestImage} || echo Pull failed, will build fresh - docker build --platform=linux/amd64 -f ${env.DOCKERFILE} ${env.BUILD_ARGS} --build-arg GIT_COMMIT=${gitCommit} --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${latestImage} -t ${versionedImage} -t ${latestImage} . + set "PREVIOUS_IMAGE=nginx:1.27-alpine" + docker pull --platform=linux/amd64 ${latestImage} && set "PREVIOUS_IMAGE=${latestImage}" || echo Previous image unavailable, first release will not retain assets + docker build --platform=linux/amd64 -f ${env.DOCKERFILE} ${env.BUILD_ARGS} --build-arg PREVIOUS_IMAGE=%PREVIOUS_IMAGE% --build-arg GIT_COMMIT=${gitCommit} --build-arg SERVICE_VERSION=${env.IMAGE_VERSION} --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from ${latestImage} -t ${versionedImage} -t ${latestImage} . if %errorlevel% neq 0 exit /b 1 docker push ${versionedImage} if %errorlevel% neq 0 exit /b 1 diff --git a/docs-site/docs/.vitepress/config.ts b/docs-site/docs/.vitepress/config.ts index d318e74..644442a 100644 --- a/docs-site/docs/.vitepress/config.ts +++ b/docs-site/docs/.vitepress/config.ts @@ -1,10 +1,16 @@ import { defineConfig } from 'vitepress' +const releaseId = process.env.VITE_RELEASE_ID?.trim() +if (releaseId && !/^[A-Za-z0-9._-]+$/.test(releaseId)) { + throw new Error('VITE_RELEASE_ID 只能包含字母、数字、点、下划线和短横线') +} + export default defineConfig({ title: 'XuqmGroup 开发者文档', description: 'XuqmGroup 多端 SDK 接入指南', base: '/docs/', lang: 'zh-CN', + assetsDir: releaseId ? `releases/${releaseId}/assets` : 'assets', themeConfig: { logo: '/logo.svg', diff --git a/docs/SDK_PLATFORM_V2_HANDOFF.md b/docs/SDK_PLATFORM_V2_HANDOFF.md index abe45e9..8daeea2 100644 --- a/docs/SDK_PLATFORM_V2_HANDOFF.md +++ b/docs/SDK_PLATFORM_V2_HANDOFF.md @@ -11,6 +11,12 @@ SDK 平台的完整服务端契约见 Server 仓库 `docs/SDK_PLATFORM_V2_HANDOF - 已删除废弃的离线 license 服务页面、路由、API、菜单和日志入口。 - 依赖管理统一为根 `package.json` 声明的 Yarn 1 与 `yarn.lock`。 - `Jenkinsfile.tenant-web` 默认 development,仅验证;production 需要人工确认才允许推送、部署和提交版本号。 +- Web 生产缓存使用版本目录协议:入口 HTML/manifest 禁止持久缓存,哈希资源使用 + `releases//assets/` 和一年 `immutable`。 +- Jenkins 构建会把上一 `latest` 镜像作为 `PREVIOUS_IMAGE`;新镜像只保留上一版本的 + 当前资源目录和本次资源目录。首次从旧布局迁移时保留一次旧 `/assets`,下一版本自动 + 淘汰,避免资源无限累积。 +- 哈希资源 location 使用 `try_files $uri =404`,不得回退到 SPA `index.html`。 验证: @@ -19,8 +25,11 @@ yarn install --frozen-lockfile yarn build:tenant yarn build:ops yarn workspace @xuqm/docs-site build +yarn test:web-cache +nginx -t -p "$PWD/" -c scripts/nginx-test.conf ``` -2026-07-26 三项构建均已通过。 +2026-07-27 缓存契约测试会使用固定 release ID 重新构建 tenant、ops 与 docs,并校验 +HTML 引用、版本目录、Nginx 缓存头及 Docker 上一版本保留门禁。 -未执行提交、推送、Jenkins 或部署。 +Jenkins 和线上部署需由发布流程另行执行;本地构建不能代替生产响应头验收。 diff --git a/nginx/default.conf b/nginx/default.conf index eff67e6..76e32f1 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -5,17 +5,37 @@ server { root /usr/share/nginx/html/tenant; index index.html; + location ~* ^/(?:releases/[^/]+/assets|assets)/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + location ~* ^/ops/(?:releases/[^/]+/assets|assets)/ { + root /usr/share/nginx/html; + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + location ~* ^/docs/(?:releases/[^/]+/assets|assets)/ { + root /usr/share/nginx/html; + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + location / { try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; } location /ops/ { alias /usr/share/nginx/html/ops/; try_files $uri $uri/ /ops/index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; } location /docs/ { alias /usr/share/nginx/html/docs/; try_files $uri $uri/ /docs/index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; } } diff --git a/nginx/ops.conf b/nginx/ops.conf index 3aa17e6..96ebf9a 100644 --- a/nginx/ops.conf +++ b/nginx/ops.conf @@ -5,7 +5,15 @@ server { root /usr/share/nginx/html; index index.html; + location ~* ^/(?:releases/[^/]+/assets|assets)/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + location / { try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; } } diff --git a/nginx/tenant.conf b/nginx/tenant.conf index bf15e1a..845d2a1 100644 --- a/nginx/tenant.conf +++ b/nginx/tenant.conf @@ -5,12 +5,31 @@ server { root /usr/share/nginx/html/tenant; index index.html; + # Vite 哈希资源属于不可变内容。独立 location 必须返回真实 404,禁止回退到 + # index.html,否则 chunk 丢失会被伪装成 MIME/语法错误。 + location ~* ^/(?:releases/[^/]+/assets|assets)/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + + location ~* ^/docs/(?:releases/[^/]+/assets|assets)/ { + root /usr/share/nginx/html; + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + } + location / { try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; } location /docs/ { alias /usr/share/nginx/html/docs/; try_files $uri $uri/ /docs/index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; } } diff --git a/ops-platform/vite.config.ts b/ops-platform/vite.config.ts index 4f6d892..b2f79c9 100644 --- a/ops-platform/vite.config.ts +++ b/ops-platform/vite.config.ts @@ -6,6 +6,10 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, new URL('.', import.meta.url).pathname, '') + const releaseId = env.VITE_RELEASE_ID?.trim() + if (releaseId && !/^[A-Za-z0-9._-]+$/.test(releaseId)) { + throw new Error('VITE_RELEASE_ID 只能包含字母、数字、点、下划线和短横线') + } return { base: env.VITE_APP_BASE || '/', @@ -18,6 +22,7 @@ export default defineConfig(({ mode }) => { alias: { '@': new URL('./src', import.meta.url).pathname }, }, build: { + assetsDir: releaseId ? `releases/${releaseId}/assets` : 'assets', chunkSizeWarningLimit: 1000, rollupOptions: { output: { diff --git a/package.json b/package.json index bc14d62..5010a1f 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "scripts": { "dev:tenant": "yarn workspace tenant-platform dev", "dev:ops": "yarn workspace ops-platform dev", - "dev:docs": "yarn workspace docs-site dev", + "dev:docs": "yarn workspace @xuqm/docs-site dev", "build:tenant": "yarn workspace tenant-platform build", "build:ops": "yarn workspace ops-platform build", + "build:docs": "yarn workspace @xuqm/docs-site build", + "test:web-cache": "node scripts/verify-web-cache-contract.mjs", "build": "yarn workspaces run build" }, "devDependencies": { diff --git a/scripts/nginx-test.conf b/scripts/nginx-test.conf new file mode 100644 index 0000000..5994170 --- /dev/null +++ b/scripts/nginx-test.conf @@ -0,0 +1,10 @@ +pid /tmp/xuqm-web-nginx.pid; +error_log stderr; + +events {} + +http { + include ../nginx/tenant.conf; + include ../nginx/ops.conf; + include ../nginx/default.conf; +} diff --git a/scripts/verify-web-cache-contract.mjs b/scripts/verify-web-cache-contract.mjs new file mode 100644 index 0000000..5ef9dd5 --- /dev/null +++ b/scripts/verify-web-cache-contract.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const root = path.resolve(import.meta.dirname, '..') +const releaseId = 'cache-contract-test' +const yarn = process.platform === 'win32' ? 'yarn.cmd' : 'yarn' + +function run(args) { + const result = spawnSync(yarn, args, { + cwd: root, + env: { ...process.env, VITE_RELEASE_ID: releaseId }, + stdio: 'inherit', + }) + if (result.status !== 0) process.exit(result.status ?? 1) +} + +function files(directory, suffix) { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const current = path.join(directory, entry.name) + if (entry.isDirectory()) return files(current, suffix) + return entry.name.endsWith(suffix) ? [current] : [] + }) +} + +function requireText(file, values) { + const content = readFileSync(file, 'utf8') + for (const value of values) { + if (!content.includes(value)) { + throw new Error(`${path.relative(root, file)} 缺少缓存契约:${value}`) + } + } +} + +function verifyBuild(output, publicPrefix) { + const assetDirectory = path.join(output, 'releases', releaseId, 'assets') + if (!existsSync(assetDirectory) || !statSync(assetDirectory).isDirectory()) { + throw new Error(`${path.relative(root, output)} 未生成版本化 assets 目录`) + } + const html = files(output, '.html').map(file => readFileSync(file, 'utf8')).join('\n') + const expected = `${publicPrefix}releases/${releaseId}/assets/` + if (!html.includes(expected)) { + throw new Error(`${path.relative(root, output)} 的 HTML 未引用 ${expected}`) + } +} + +run(['workspace', 'tenant-platform', 'build']) +run(['workspace', 'ops-platform', 'build']) +run(['workspace', '@xuqm/docs-site', 'build']) + +verifyBuild(path.join(root, 'tenant-platform', 'dist'), '/') +verifyBuild(path.join(root, 'ops-platform', 'dist'), '/') +verifyBuild(path.join(root, 'docs-site', 'docs', '.vitepress', 'dist'), '/docs/') + +for (const nginx of ['nginx/tenant.conf', 'nginx/ops.conf']) { + requireText(path.join(root, nginx), [ + 'no-cache, no-store, must-revalidate', + 'public, max-age=31536000, immutable', + 'try_files $uri =404', + ]) +} +for (const dockerfile of ['Dockerfile.tenant', 'Dockerfile.ops']) { + requireText(path.join(root, dockerfile), [ + 'ARG PREVIOUS_IMAGE=', + '/etc/xuqm_asset_layout_v2', + 'COPY --from=previous', + 'VITE_RELEASE_ID=${SERVICE_VERSION}', + ]) +} + +console.log('Web 缓存与上一版本资源保留契约验证通过') diff --git a/tenant-platform/vite.config.ts b/tenant-platform/vite.config.ts index 7411a48..77fa759 100644 --- a/tenant-platform/vite.config.ts +++ b/tenant-platform/vite.config.ts @@ -6,6 +6,10 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, new URL('.', import.meta.url).pathname, '') + const releaseId = env.VITE_RELEASE_ID?.trim() + if (releaseId && !/^[A-Za-z0-9._-]+$/.test(releaseId)) { + throw new Error('VITE_RELEASE_ID 只能包含字母、数字、点、下划线和短横线') + } return { base: env.VITE_APP_BASE || '/', @@ -24,6 +28,8 @@ export default defineConfig(({ mode }) => { }, }, build: { + // 版本目录让新旧 HTML 各自引用不可变资源;容器镜像会同时保留当前和上一版本目录。 + assetsDir: releaseId ? `releases/${releaseId}/assets` : 'assets', chunkSizeWarningLimit: 1000, rollupOptions: { output: {