mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 14:33:11 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 个已完成样本门槛展示命中、区间涨跌、无法评估和最大不利波动,并保持旧统计接口兼容。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user