fix: 修复 WebUI 版本与静态资源识别 (#2099)

* fix: make WebUI build identity reliable

* fix: address WebUI build metadata review

* fix: track WebUI dependency content state
This commit is contained in:
zhulinsen
2026-07-25 22:26:39 +08:00
committed by GitHub
parent 68fc575f15
commit e1e042096d
13 changed files with 446 additions and 28 deletions
+6
View File
@@ -30,12 +30,15 @@ jobs:
- name: Resolve release ref
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
[[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
git fetch --tags --force
git rev-parse "${RELEASE_TAG}" >/dev/null
git checkout "${RELEASE_TAG}"
fi
echo "DSA_WEB_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
echo "DSA_WEB_REVISION=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_ENV"
- uses: actions/setup-python@v6
with:
@@ -145,12 +148,15 @@ jobs:
- name: Resolve release ref
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
[[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
git fetch --tags --force
git rev-parse "${RELEASE_TAG}" >/dev/null
git checkout "${RELEASE_TAG}"
fi
echo "DSA_WEB_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
echo "DSA_WEB_REVISION=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_ENV"
- uses: actions/setup-python@v6
with:
+15 -2
View File
@@ -37,7 +37,9 @@ jobs:
fetch-depth: 0
- name: Resolve release ref
id: release_ref
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
RELEASE_TAG="${{ inputs.release_tag }}"
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
@@ -46,8 +48,12 @@ jobs:
git checkout "$RELEASE_TAG"
echo "Using manual release tag: $RELEASE_TAG"
else
echo "Using event ref: ${GITHUB_REF_NAME}"
RELEASE_TAG="${GITHUB_REF_NAME}"
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
echo "Using event ref: $RELEASE_TAG"
fi
echo "version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
echo "revision=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@v6
with:
@@ -129,7 +135,11 @@ jobs:
- name: Pre-publish docker smoke
run: |
docker build -t stock-analysis:release -f docker/Dockerfile .
docker build \
--build-arg DSA_WEB_VERSION="${{ steps.release_ref.outputs.version }}" \
--build-arg DSA_WEB_REVISION="${{ steps.release_ref.outputs.revision }}" \
-t stock-analysis:release \
-f docker/Dockerfile .
docker run --rm stock-analysis:release python -c "
from src.config import get_config; print('ok-config')
from src.storage import DatabaseManager; print('ok-storage')
@@ -151,6 +161,9 @@ jobs:
${{ steps.meta-ghcr.outputs.tags }}
${{ steps.meta-dockerhub.outputs.tags }}
labels: ${{ steps.meta-ghcr.outputs.labels }}
build-args: |
DSA_WEB_VERSION=${{ steps.release_ref.outputs.version }}
DSA_WEB_REVISION=${{ steps.release_ref.outputs.revision }}
cache-from: type=gha
cache-to: type=gha,mode=max
+6 -6
View File
@@ -779,7 +779,7 @@ const zh = {
'settings.envImportedRefreshFailedTitle': '配置已导入但刷新失败',
'settings.exportEnv': '导出 .env',
'settings.exportingEnv': '导出中...',
'settings.fallbackVersionWarning': '当前 package.json 仍为占位版本 0.0.0,页面已自动回退展示构建标识,避免误判旧资源仍在生效。',
'settings.fallbackVersionWarning': '当前构建未提供发布版本,页面显示为 development;可结合代码版本和构建时间确认静态资源是否已更新。',
'settings.fieldAddKey': '添加 Key',
'settings.fieldDelete': '删除',
'settings.fieldSensitiveHint': '敏感内容默认隐藏,可点击眼睛图标查看明文。',
@@ -922,9 +922,9 @@ const zh = {
'settings.saveConfigWithCount': '保存配置 ({count})',
'settings.saveRetry': '重试保存',
'settings.saving': '保存中...',
'settings.updateBuildDescription': '重新执行前端构建或 Docker 镜像构建后,此处的构建标识和构建时间会更新,可用来确认当前页面资源是否已切换。',
'settings.versionBuildId': '构建标识',
'settings.updateBuildDescription': '重新执行前端构建或 Docker 镜像构建后,此处的代码版本和构建时间会更新,可用来确认当前页面资源是否已切换。',
'settings.versionBuildTime': '构建时间',
'settings.versionRevision': '代码版本',
'settings.versionDesktop': '桌面端版本',
'settings.versionInfo': '版本信息',
'settings.versionInfoDescription': '用于确认当前 WebUI 静态资源是否已经切换到最新构建。',
@@ -1715,7 +1715,7 @@ const en: Record<UiTextKey, string> = {
'settings.envImportedRefreshFailedTitle': 'Config imported but refresh failed',
'settings.exportEnv': 'Export .env',
'settings.exportingEnv': 'Exporting...',
'settings.fallbackVersionWarning': 'package.json is still the placeholder version 0.0.0. The page falls back to the build ID so old static assets are not misread as current.',
'settings.fallbackVersionWarning': 'This build has no release version and is shown as development. Use the revision and build time to verify whether the static assets are current.',
'settings.fieldAddKey': 'Add key',
'settings.fieldDelete': 'Delete',
'settings.fieldSensitiveHint': 'Sensitive content is hidden by default. Click the eye icon to reveal plaintext.',
@@ -1858,9 +1858,9 @@ const en: Record<UiTextKey, string> = {
'settings.saveConfigWithCount': 'Save configuration ({count})',
'settings.saveRetry': 'Retry save',
'settings.saving': 'Saving...',
'settings.updateBuildDescription': 'After rebuilding the frontend or Docker image, the build ID and build time here update. Use them to confirm the current page assets are active.',
'settings.versionBuildId': 'Build ID',
'settings.updateBuildDescription': 'After rebuilding the frontend or Docker image, the revision and build time here update. Use them to confirm the current page assets are active.',
'settings.versionBuildTime': 'Build time',
'settings.versionRevision': 'Revision',
'settings.versionDesktop': 'Desktop version',
'settings.versionInfo': 'Version info',
'settings.versionInfoDescription': 'Use this to confirm the current WebUI static assets are on the latest build.',
+2 -2
View File
@@ -1644,10 +1644,10 @@ const SettingsPage: React.FC = () => {
</div>
<div className="rounded-2xl border settings-border bg-background/40 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-text">
{t('settings.versionBuildId')}
{t('settings.versionRevision')}
</p>
<p className="mt-2 break-all font-mono text-sm text-foreground">
{WEB_BUILD_INFO.buildId}
{WEB_BUILD_INFO.revision}
</p>
</div>
<div className="rounded-2xl border settings-border bg-background/40 px-4 py-3">
@@ -69,6 +69,7 @@ const {
webBuildInfoMock: {
version: '3.11.0',
rawVersion: '3.11.0',
revision: 'abc123def456',
buildId: 'build-20260329-021530Z',
buildTime: '2026-03-29T02:15:30.000Z',
isFallbackVersion: false,
@@ -509,6 +510,7 @@ describe('SettingsPage', () => {
Object.assign(webBuildInfoMock, {
version: '3.11.0',
rawVersion: '3.11.0',
revision: 'abc123def456',
buildId: 'build-20260329-021530Z',
buildTime: '2026-03-29T02:15:30.000Z',
isFallbackVersion: false,
@@ -874,7 +876,7 @@ describe('SettingsPage', () => {
expect(await screen.findByRole('heading', { name: '版本信息' })).toBeInTheDocument();
expect(screen.getByText('3.11.0')).toBeInTheDocument();
expect(screen.getByText('build-20260329-021530Z')).toBeInTheDocument();
expect(screen.getByText('abc123def456')).toBeInTheDocument();
expect(screen.getByText('2026-03-29T02:15:30.000Z')).toBeInTheDocument();
});
@@ -936,13 +938,15 @@ describe('SettingsPage', () => {
expect(screen.queryByText('发现新版本')).not.toBeInTheDocument();
});
it('falls back to build identifier when package version is still placeholder', () => {
it('uses an explicit development label instead of presenting a build ID as the version', () => {
expect(resolveWebBuildInfo({
packageVersion: '0.0.0',
revision: 'abc123def456',
buildTimestamp: '2026-03-29T02:15:30.000Z',
})).toEqual({
version: 'build-20260329-021530Z',
version: 'development',
rawVersion: '0.0.0',
revision: 'abc123def456',
buildId: 'build-20260329-021530Z',
buildTime: '2026-03-29T02:15:30.000Z',
isFallbackVersion: true,
@@ -951,8 +955,9 @@ describe('SettingsPage', () => {
it('renders fallback version hint when package version is placeholder', async () => {
Object.assign(webBuildInfoMock, {
version: 'build-20260329-021530Z',
version: 'development',
rawVersion: '0.0.0',
revision: 'abc123def456',
buildId: 'build-20260329-021530Z',
buildTime: '2026-03-29T02:15:30.000Z',
isFallbackVersion: true,
@@ -961,8 +966,9 @@ describe('SettingsPage', () => {
render(<SettingsPage />);
expect(await screen.findByRole('heading', { name: '版本信息' })).toBeInTheDocument();
expect(screen.getByText(/当前 package\.json 仍为占位版本 0\.0\.0/)).toBeInTheDocument();
expect(screen.getAllByText('build-20260329-021530Z')).toHaveLength(2);
expect(screen.getByText(/当前构建未提供发布版本/)).toBeInTheDocument();
expect(screen.getByText('development')).toBeInTheDocument();
expect(screen.getByText('abc123def456')).toBeInTheDocument();
});
it('resets local drafts from the page header button', () => {
+13 -1
View File
@@ -1,9 +1,12 @@
const configuredApiBaseUrl = import.meta.env.VITE_API_URL?.trim();
declare const __APP_PACKAGE_VERSION__: string | undefined;
declare const __APP_REVISION__: string | undefined;
declare const __APP_BUILD_TIME__: string | undefined;
const PLACEHOLDER_WEB_VERSION = '0.0.0';
const DEVELOPMENT_WEB_VERSION = 'development';
const UNKNOWN_REVISION = 'unknown';
const UNKNOWN_BUILD_TIME = '未提供';
// 默认保持同源 API,避免生产/静态部署时把请求错误打到用户本机 localhost。
@@ -13,6 +16,7 @@ export const API_BASE_URL = configuredApiBaseUrl || '';
export type WebBuildInfo = {
version: string;
rawVersion: string;
revision: string;
buildId: string;
buildTime: string;
isFallbackVersion: boolean;
@@ -55,19 +59,23 @@ export function createBuildIdentifier(buildTimestamp?: string) {
export function resolveWebBuildInfo({
packageVersion,
revision,
buildTimestamp,
}: {
packageVersion?: string;
revision?: string;
buildTimestamp?: string;
}): WebBuildInfo {
const rawVersion = packageVersion?.trim() || PLACEHOLDER_WEB_VERSION;
const normalizedRevision = revision?.trim() || UNKNOWN_REVISION;
const buildTime = normalizeBuildTimestamp(buildTimestamp);
const buildId = createBuildIdentifier(buildTime);
const isFallbackVersion = rawVersion === PLACEHOLDER_WEB_VERSION;
return {
version: isFallbackVersion ? buildId : rawVersion,
version: isFallbackVersion ? DEVELOPMENT_WEB_VERSION : rawVersion,
rawVersion,
revision: normalizedRevision,
buildId,
buildTime,
isFallbackVersion,
@@ -77,11 +85,15 @@ export function resolveWebBuildInfo({
const runtimePackageVersion = typeof __APP_PACKAGE_VERSION__ === 'string'
? __APP_PACKAGE_VERSION__.trim()
: PLACEHOLDER_WEB_VERSION;
const runtimeRevision = typeof __APP_REVISION__ === 'string'
? __APP_REVISION__.trim()
: UNKNOWN_REVISION;
const runtimeBuildTime = typeof __APP_BUILD_TIME__ === 'string'
? __APP_BUILD_TIME__.trim()
: '';
export const WEB_BUILD_INFO = resolveWebBuildInfo({
packageVersion: runtimePackageVersion,
revision: runtimeRevision,
buildTimestamp: runtimeBuildTime,
});
+29
View File
@@ -0,0 +1,29 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { resolveAppRevision } from './vite.config'
describe('resolveAppRevision', () => {
it('prefers the explicitly injected release revision', () => {
expect(resolveAppRevision({
explicitRevision: 'release123456',
checkedOutRevision: 'checkedout123',
workflowRevision: 'workflow12345',
})).toBe('release123456')
})
it('uses the checked-out revision before the workflow trigger revision', () => {
expect(resolveAppRevision({
checkedOutRevision: 'oldtag123456',
workflowRevision: 'defaultbranch',
})).toBe('oldtag123456')
})
it('falls back when the build has no Git checkout', () => {
expect(resolveAppRevision({
workflowRevision: 'workflow12345',
})).toBe('workflow12345')
expect(resolveAppRevision({})).toBe('unknown')
})
})
+126 -3
View File
@@ -1,13 +1,134 @@
import { readFileSync } from 'node:fs'
import { defineConfig } from 'vite'
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
const frontendDir = __dirname
const repoRoot = path.resolve(frontendDir, '../..')
const packageJson = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf-8'),
) as { version?: string }
const buildTime = new Date().toISOString()
const placeholderVersion = '0.0.0'
const buildInputFiles = [
'package.json',
'package-lock.json',
'vite.config.ts',
'tsconfig.json',
'tsconfig.app.json',
'tsconfig.node.json',
'eslint.config.js',
'postcss.config.js',
'tailwind.config.js',
'index.html',
]
const buildInputDirectories = ['src', 'public']
const collectFiles = (root: string): string[] => {
if (!existsSync(root)) {
return []
}
return readdirSync(root, { withFileTypes: true })
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
.flatMap((entry) => {
const entryPath = path.join(root, entry.name)
return entry.isDirectory() ? collectFiles(entryPath) : [entryPath]
})
}
const createSourceFingerprint = () => {
const inputPaths = [
...buildInputFiles.map((fileName) => path.join(frontendDir, fileName)),
...buildInputDirectories.flatMap((directoryName) => collectFiles(path.join(frontendDir, directoryName))),
]
.filter((inputPath) => existsSync(inputPath))
.sort((left, right) => {
const leftRelative = path.relative(frontendDir, left).replace(/\\/g, '/')
const rightRelative = path.relative(frontendDir, right).replace(/\\/g, '/')
return leftRelative < rightRelative ? -1 : leftRelative > rightRelative ? 1 : 0
})
const hash = createHash('sha256')
inputPaths.forEach((inputPath) => {
hash.update(path.relative(frontendDir, inputPath).replace(/\\/g, '/'))
hash.update('\0')
hash.update(readFileSync(inputPath))
hash.update('\0')
})
return hash.digest('hex')
}
const runGit = (args: string[]) => {
try {
return execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
} catch {
return ''
}
}
const normalizeVersion = (value?: string) => {
const normalized = value?.trim() ?? ''
return normalized.replace(/^v(?=\d)/, '')
}
export const resolveAppRevision = ({
explicitRevision,
checkedOutRevision,
workflowRevision,
}: {
explicitRevision?: string
checkedOutRevision?: string
workflowRevision?: string
}) => (
explicitRevision?.trim()
|| checkedOutRevision?.trim()
|| workflowRevision?.trim()
|| 'unknown'
)
const releaseVersion = normalizeVersion(
process.env.DSA_WEB_VERSION
|| process.env.RELEASE_TAG
|| (process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : ''),
)
const packageVersion = normalizeVersion(packageJson.version)
const gitDescription = normalizeVersion(runGit(['describe', '--tags', '--always', '--dirty']))
const appVersion = releaseVersion
|| (packageVersion !== placeholderVersion ? packageVersion : '')
|| gitDescription
|| placeholderVersion
const appRevision = resolveAppRevision({
explicitRevision: process.env.DSA_WEB_REVISION,
checkedOutRevision: runGit(['rev-parse', '--short=12', 'HEAD']),
workflowRevision: process.env.GITHUB_SHA,
})
const sourceFingerprint = createSourceFingerprint()
const buildMetadataPlugin = (): Plugin => ({
name: 'dsa-build-metadata',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'build-info.json',
source: `${JSON.stringify({
schemaVersion: 1,
version: appVersion,
revision: appRevision,
buildTime,
sourceFingerprint,
}, null, 2)}\n`,
})
},
})
const vendorChunkByPackage: Record<string, string> = {
react: 'vendor-react',
@@ -93,10 +214,12 @@ const getVendorChunkName = (id: string): string | undefined => {
// https://vite.dev/config/
export default defineConfig({
define: {
__APP_PACKAGE_VERSION__: JSON.stringify(packageJson.version ?? '0.0.0'),
__APP_PACKAGE_VERSION__: JSON.stringify(appVersion),
__APP_REVISION__: JSON.stringify(appRevision),
__APP_BUILD_TIME__: JSON.stringify(buildTime),
},
plugins: [
buildMetadataPlugin(),
tailwindcss(),
react({
babel: {
+3 -1
View File
@@ -8,12 +8,14 @@
FROM node:20-slim AS web-builder
WORKDIR /app/apps/dsa-web
ARG DSA_WEB_VERSION=0.0.0
ARG DSA_WEB_REVISION=unknown
COPY apps/dsa-web/package.json apps/dsa-web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY apps/dsa-web/ ./
RUN npm run build
RUN DSA_WEB_VERSION="${DSA_WEB_VERSION}" DSA_WEB_REVISION="${DSA_WEB_REVISION}" npm run build
# Pin to bookworm: wkhtmltopdf was removed from Debian testing (2025)
FROM python:3.11-slim-bookworm
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
## [Unreleased]
- [修复] WebUI 分开展示发布版本、代码版本与构建时间,并通过构建输入摘要识别 `rsync -a` 保留时间戳造成的旧静态资源复用(fixes #2093)。
- [chore] 暂停 PR Review 的自动触发,仅保留 `workflow_dispatch` 手动入口,避免辅助评审重复运行及评论权限失败产生误导性红灯;正式 CI 检查保持不变。
- [新功能] Multi-Agent specialist 运行在分析历史保存成功后,按独立 skill 持久化版本化、低敏且幂等的有效 opinion 样本,为后续后验评估提供真实数据;本阶段不计算 outcome、不统计表现、不调整权重。
- [新功能] AI 建议页在既有后验统计中增加决策风格历史表现,按每个分组独立的 30 个已完成样本门槛展示命中、区间涨跌、无法评估和最大不利波动,并保持旧统计接口兼容。
+10 -4
View File
@@ -163,12 +163,15 @@ http://your-domain.com:8000
WebUI 现在会在“系统设置”页展示只读的“版本信息”卡片,包含:
- `WebUI 版本`
- `构建标识`
- `代码版本`
- `构建时间`
如果 `apps/dsa-web/package.json` 里的版本号仍是占位值 `0.0.0`,页面会自动回退展示本次前端构建生成的 `构建标识`,避免你误把占位版本当成真实发布版本。
正式 Docker / Desktop 发布会把 release tag 注入为 `WebUI 版本`,并把对应 commit
显示为 `代码版本`。直接从 Git clone 构建时,WebUI 会使用 `git describe` 和当前
commit;如果构建环境既没有发布信息也没有 Git 元数据,版本会明确显示为
`development`,不会再用构建时间冒充发布版本。
当你重新执行 `docker-compose -f ./docker/docker-compose.yml up -d --build`,或者单独重新执行前端 `npm run build` 后,可以刷新浏览器并进入“系统设置”,优先确认“构建时间”是否已经变化;若变化,通常就说明当前加载的静态资源已经切换到最新构建。
当你重新执行 `docker-compose -f ./docker/docker-compose.yml up -d --build`,或者单独重新执行前端 `npm run build` 后,可以刷新浏览器并进入“系统设置”,确认“代码版本”和“构建时间”是否已经变化;两者能共同确认浏览器当前加载的静态资源来自哪次代码和构建。
如果你想确认“我现在到底部署的是哪个正式版本”,优先用下面这些方式:
@@ -193,7 +196,10 @@ npm run lint
npm run build
```
其中 `build` 成功后,`static` 下生成的 `index.html`/JS/CSS 资源会包含本次构建时间与构建版本信息;刷新后在“版本信息”卡片中应能见到变化。
其中 `build` 成功后,`static` 下生成的 `index.html`/JS/CSS 资源会包含本次版本、
commit、构建时间,并生成 `build-info.json`。启动时会比较该文件中的源码摘要,
因此即使 `rsync -a` 保留了旧时间戳,也能识别源码与静态产物不一致并重新构建。
刷新后在“版本信息”卡片中应能见到变化。
---
+121 -3
View File
@@ -8,6 +8,8 @@ Set WEBUI_AUTO_BUILD=false to disable auto build and only verify artifacts.
from __future__ import annotations
import hashlib
import json
import logging
import os
import shutil
@@ -31,6 +33,9 @@ _BUILD_INPUT_FILES = (
"index.html",
)
_BUILD_INPUT_DIRS = ("src", "public")
_BUILD_METADATA_FILE = "build-info.json"
_DEPENDENCY_INPUT_FILES = ("package.json", "package-lock.json")
_DEPENDENCY_FINGERPRINT_FILE = ".dsa-dependency-fingerprint"
def _is_truthy_env(var_name: str, default: str = "true") -> bool:
@@ -81,12 +86,60 @@ def _resolve_artifact_index(frontend_dir: Path) -> Path:
return max(fallback_candidates, key=_safe_mtime)
def _calculate_dependency_fingerprint(frontend_dir: Path) -> str | None:
digest = hashlib.sha256()
found_input = False
try:
for filename in _DEPENDENCY_INPUT_FILES:
input_path = frontend_dir / filename
if not input_path.is_file():
continue
found_input = True
digest.update(filename.encode("utf-8"))
digest.update(b"\0")
digest.update(input_path.read_bytes())
digest.update(b"\0")
except OSError as exc:
logger.warning("读取 WebUI 依赖输入失败,将回退到文件时间检查: %s", exc)
return None
return digest.hexdigest() if found_input else None
def _read_installed_dependency_fingerprint(frontend_dir: Path) -> str | None:
marker_path = frontend_dir / "node_modules" / _DEPENDENCY_FINGERPRINT_FILE
try:
fingerprint = marker_path.read_text(encoding="ascii").strip()
except (OSError, UnicodeDecodeError):
return None
return fingerprint or None
def _write_installed_dependency_fingerprint(frontend_dir: Path) -> None:
fingerprint = _calculate_dependency_fingerprint(frontend_dir)
if fingerprint is None:
return
marker_path = frontend_dir / "node_modules" / _DEPENDENCY_FINGERPRINT_FILE
try:
marker_path.write_text(f"{fingerprint}\n", encoding="ascii")
except OSError as exc:
logger.warning("无法记录 WebUI 依赖摘要,下次构建将重新安装依赖: %s", exc)
def _needs_dependency_install(frontend_dir: Path, package_json: Path, lock_file: Path, force_build: bool) -> bool:
node_modules_dir = frontend_dir / "node_modules"
if force_build or not node_modules_dir.exists():
return True
dependency_fingerprint = _calculate_dependency_fingerprint(frontend_dir)
if dependency_fingerprint is not None:
installed_fingerprint = _read_installed_dependency_fingerprint(frontend_dir)
return dependency_fingerprint != installed_fingerprint
install_marker = node_modules_dir / ".package-lock.json"
deps_marker_mtime = _safe_mtime(install_marker) if install_marker.exists() else _safe_mtime(node_modules_dir)
deps_input_mtime = _max_mtime((package_json, lock_file))
return force_build or (not node_modules_dir.exists()) or (deps_marker_mtime < deps_input_mtime)
return deps_marker_mtime < deps_input_mtime
def _collect_build_inputs_latest_mtime(frontend_dir: Path) -> float:
@@ -96,11 +149,69 @@ def _collect_build_inputs_latest_mtime(frontend_dir: Path) -> float:
return latest
def _collect_build_input_files(frontend_dir: Path) -> list[Path]:
input_files = [
frontend_dir / filename
for filename in _BUILD_INPUT_FILES
if (frontend_dir / filename).is_file()
]
for dirname in _BUILD_INPUT_DIRS:
root = frontend_dir / dirname
if root.exists():
input_files.extend(path for path in root.rglob("*") if path.is_file())
return sorted(input_files, key=lambda path: path.relative_to(frontend_dir).as_posix())
def _calculate_source_fingerprint(frontend_dir: Path) -> str | None:
digest = hashlib.sha256()
try:
input_files = _collect_build_input_files(frontend_dir)
if not input_files:
return None
for input_path in input_files:
relative_path = input_path.relative_to(frontend_dir).as_posix()
digest.update(relative_path.encode("utf-8"))
digest.update(b"\0")
digest.update(input_path.read_bytes())
digest.update(b"\0")
except OSError as exc:
logger.warning("读取 WebUI 构建输入失败,将回退到文件时间检查: %s", exc)
return None
return digest.hexdigest()
def _read_artifact_source_fingerprint(artifact_index: Path) -> str | None:
metadata_path = artifact_index.parent / _BUILD_METADATA_FILE
try:
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
return None
fingerprint = payload.get("sourceFingerprint") if isinstance(payload, dict) else None
return fingerprint.strip() if isinstance(fingerprint, str) and fingerprint.strip() else None
def _needs_frontend_build(frontend_dir: Path, force_build: bool) -> tuple[bool, Path]:
artifact_index = _resolve_artifact_index(frontend_dir)
if force_build or not artifact_index.exists():
return True, artifact_index
# Prebuilt Docker/desktop artifacts do not include the frontend source tree.
# In that runtime layout there is nothing local to compare, so reuse the
# artifact and trust the build-time validation.
if not (frontend_dir / "package.json").exists():
return False, artifact_index
source_fingerprint = _calculate_source_fingerprint(frontend_dir)
artifact_fingerprint = _read_artifact_source_fingerprint(artifact_index)
if source_fingerprint is not None:
# Old builds have no metadata and are rebuilt once. Unlike mtime, the
# fingerprint remains correct when rsync preserves historical timestamps.
return source_fingerprint != artifact_fingerprint, artifact_index
inputs_latest_mtime = _collect_build_inputs_latest_mtime(frontend_dir)
artifact_mtime = _safe_mtime(artifact_index)
needs_build = force_build or (not artifact_index.exists()) or (artifact_mtime < inputs_latest_mtime)
needs_build = artifact_mtime < inputs_latest_mtime
return needs_build, artifact_index
@@ -185,6 +296,10 @@ def prepare_webui_frontend_assets() -> bool:
if artifact_index.exists():
logger.info("WEBUI_AUTO_BUILD=false,检测到前端静态产物: %s", artifact_index)
_warn_if_assets_missing(artifact_index, frontend_dir)
needs_build, _ = _needs_frontend_build(frontend_dir=frontend_dir, force_build=False)
if needs_build:
logger.warning("检测到 WebUI 源码与现有静态产物不一致,但自动构建已关闭")
logger.warning("请重新构建前端: %s", _manual_build_command(frontend_dir))
return True
logger.warning("未检测到 WebUI 前端静态产物: %s", artifact_index)
logger.warning("当前配置 WEBUI_AUTO_BUILD=false,不会在后端启动时自动编译前端")
@@ -233,4 +348,7 @@ def prepare_webui_frontend_assets() -> bool:
needs_build,
artifact_index,
)
return _run_frontend_commands(commands=commands, frontend_dir=frontend_dir)
succeeded = _run_frontend_commands(commands=commands, frontend_dir=frontend_dir)
if succeeded and needs_install:
_write_installed_dependency_fingerprint(frontend_dir)
return succeeded
+102
View File
@@ -1,4 +1,6 @@
import json
import logging
import os
import src.webui_frontend as webui_frontend
@@ -23,6 +25,22 @@ def _create_full_static(repo_root):
return static_dir
def _create_frontend_source(repo_root):
frontend_dir = repo_root / "apps" / "dsa-web"
source_dir = frontend_dir / "src"
source_dir.mkdir(parents=True)
(frontend_dir / "package.json").write_text('{"version":"0.0.0"}', encoding="utf-8")
(source_dir / "main.ts").write_text("export const value = 1;\n", encoding="utf-8")
return frontend_dir
def _write_build_metadata(static_dir, source_fingerprint):
(static_dir / "build-info.json").write_text(
json.dumps({"schemaVersion": 1, "sourceFingerprint": source_fingerprint}),
encoding="utf-8",
)
def test_prepare_webui_frontend_assets_reuses_prebuilt_static_without_source(tmp_path, monkeypatch, caplog):
repo_root = _prepare_fake_repo(tmp_path, monkeypatch)
_create_full_static(repo_root)
@@ -90,6 +108,90 @@ def test_prepare_webui_frontend_assets_auto_build_disabled_warns_when_assets_mis
assert "目录不存在或无 CSS/JS 文件" in caplog.text
def test_needs_frontend_build_uses_content_fingerprint_when_mtime_is_preserved(tmp_path):
repo_root = tmp_path / "repo"
frontend_dir = _create_frontend_source(repo_root)
static_dir = _create_full_static(repo_root)
source_file = frontend_dir / "src" / "main.ts"
source_fingerprint = webui_frontend._calculate_source_fingerprint(frontend_dir)
_write_build_metadata(static_dir, source_fingerprint)
needs_build, _ = webui_frontend._needs_frontend_build(frontend_dir, force_build=False)
assert needs_build is False
source_file.write_text("export const value = 2;\n", encoding="utf-8")
# Simulate rsync -a preserving a timestamp older than the existing bundle.
os.utime(source_file, (1, 1))
needs_build, _ = webui_frontend._needs_frontend_build(frontend_dir, force_build=False)
assert needs_build is True
def test_needs_frontend_build_rebuilds_legacy_artifact_once(tmp_path):
repo_root = tmp_path / "repo"
frontend_dir = _create_frontend_source(repo_root)
_create_full_static(repo_root)
needs_build, _ = webui_frontend._needs_frontend_build(frontend_dir, force_build=False)
assert needs_build is True
def test_dependency_install_uses_content_fingerprint_when_mtime_is_preserved(tmp_path):
frontend_dir = _create_frontend_source(tmp_path / "repo")
lock_file = frontend_dir / "package-lock.json"
lock_file.write_text('{"lockfileVersion":3,"packages":{}}', encoding="utf-8")
node_modules = frontend_dir / "node_modules"
node_modules.mkdir()
webui_frontend._write_installed_dependency_fingerprint(frontend_dir)
assert webui_frontend._needs_dependency_install(
frontend_dir,
frontend_dir / "package.json",
lock_file,
force_build=False,
) is False
lock_file.write_text('{"lockfileVersion":3,"packages":{"node_modules/new":{}}}', encoding="utf-8")
os.utime(lock_file, (1, 1))
assert webui_frontend._needs_dependency_install(
frontend_dir,
frontend_dir / "package.json",
lock_file,
force_build=False,
) is True
def test_source_input_discovery_errors_fall_back_without_aborting(tmp_path, monkeypatch, caplog):
frontend_dir = _create_frontend_source(tmp_path / "repo")
def raise_permission_error(_frontend_dir):
raise PermissionError("unreadable source directory")
monkeypatch.setattr(webui_frontend, "_collect_build_input_files", raise_permission_error)
with caplog.at_level(logging.WARNING):
assert webui_frontend._calculate_source_fingerprint(frontend_dir) is None
assert "回退到文件时间检查" in caplog.text
def test_undecodable_build_metadata_is_treated_as_stale(tmp_path, monkeypatch, caplog):
repo_root = _prepare_fake_repo(tmp_path, monkeypatch)
frontend_dir = _create_frontend_source(repo_root)
static_dir = _create_full_static(repo_root)
(static_dir / "build-info.json").write_bytes(b"\xff\xfe\x00broken")
needs_build, _ = webui_frontend._needs_frontend_build(frontend_dir, force_build=False)
assert needs_build is True
monkeypatch.setenv("WEBUI_AUTO_BUILD", "false")
with caplog.at_level(logging.WARNING):
assert webui_frontend.prepare_webui_frontend_assets() is True
assert "源码与现有静态产物不一致" in caplog.text
def test_has_static_assets_returns_false_for_missing_dir(tmp_path):
assert webui_frontend._has_static_assets(tmp_path / "nonexistent") is False