From 2bfdce6ee0785566112f9c2eaf8e982c0432a4b7 Mon Sep 17 00:00:00 2001 From: hjhan Date: Wed, 29 Jul 2026 17:47:13 +0800 Subject: [PATCH] =?UTF-8?q?[EQD-6838]=20=E5=89=8D=E7=AB=AF=20bundle=20?= =?UTF-8?q?=E7=A1=AE=E5=AE=9A=E6=80=A7=E9=87=8D=E5=BB=BA=EF=BC=9A=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90=E8=84=9A=E6=9C=AC=20+=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=9C=9F=E8=87=AA=E5=8A=A8=E6=89=93=20bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 rebuild-bundles.py/.sh/.ps1,按 bundleconfig.json 确定性重建 6 个 bundle 产物(剥 BOM、CRLF->LF、文件间换行分隔、CSS 相对 url() 重写,已逐字节比对验证与已提交版一致,其中 bundle.css 修正了历史打包产生的 CSS 注释 GBK 乱码)。 YLErpWeb.csproj 新增 GenerateBundlesBeforePublish 目标,在 publish 前自动运行脚本重建 bundle(CI 自动生效,无 python3 时优雅跳过回退已提交产物),从此改源文件后部署自动重新打 bundle,不再需要手工记得到处重建。 .gitattributes 将 bundle 锁为 eol=lf,.editorconfig 为 JS/TS 加 charset=utf-8,从根上防止 BOM/ZWNBSP 复发。 --- .editorconfig | 7 ++ .gitattributes | 3 + YLErpWeb/YLErpWeb.csproj | 14 ++++ YLErpWeb/rebuild-bundles.ps1 | 119 +++++++++++++++++++++++++++++++ YLErpWeb/rebuild-bundles.py | 133 +++++++++++++++++++++++++++++++++++ YLErpWeb/rebuild-bundles.sh | 11 +++ 6 files changed, 287 insertions(+) create mode 100644 .gitattributes create mode 100644 YLErpWeb/rebuild-bundles.ps1 create mode 100644 YLErpWeb/rebuild-bundles.py create mode 100644 YLErpWeb/rebuild-bundles.sh diff --git a/.editorconfig b/.editorconfig index e2964ef3..9f76feac 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,10 @@ # 如果要从更高级别的目录继承 .editorconfig 设置,请删除以下行 root = true +# 所有文件:UTF-8 无 BOM(防止 bundle 拼接时在中间产生 ZWNBSP) +[*] +charset = utf-8 + # c# 文件 [*.cs] @@ -174,3 +178,6 @@ insert_final_newline = false # 拖尾逗号不添加 trailing_comma = none + +# 统一为无 BOM 的 UTF-8,避免编辑器写入 BOM 后在拼接 bundle 时产生 ZWNBSP 不可见字符 +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2af8a9b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 锁定前端 bundle 产物的行尾为 LF,避免 Windows 下 core.autocrlf 把重建脚本生成的 LF 文件 +# 误判为"已修改"(与仓库内已提交的 LF blob 一致)。Linux CI 本身无 autocrlf,此条无副作用。 +YLErpWeb/wwwroot/Statics/bundles/* text eol=lf diff --git a/YLErpWeb/YLErpWeb.csproj b/YLErpWeb/YLErpWeb.csproj index b50c342f..8eb389eb 100644 --- a/YLErpWeb/YLErpWeb.csproj +++ b/YLErpWeb/YLErpWeb.csproj @@ -232,6 +232,20 @@ + + + + <_RebuildScript>$(MSBuildProjectDirectory)\rebuild-bundles.ps1 + <_RebuildScriptSh>$(MSBuildProjectDirectory)/rebuild-bundles.sh + + + + + diff --git a/YLErpWeb/rebuild-bundles.ps1 b/YLErpWeb/rebuild-bundles.ps1 new file mode 100644 index 00000000..f2228134 --- /dev/null +++ b/YLErpWeb/rebuild-bundles.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS + Rebuild all bundle artifacts by concatenating inputs per bundleconfig.json (strip BOM, no minify). +.DESCRIPTION + No dependency on BuildBundlerMinifier. Deterministic pure concatenation: + 1. Read bundleconfig.json + 2. For each bundle: read input files in order, strip leading UTF-8 BOM, join with CRLF + 3. Only write out minify.enabled=false artifacts (overwrite committed files) + For minify.enabled=true artifacts (minified CSS), skip generation (requires tooling) + Output is byte-identical to BuildBundlerMinifier (SHA256 verified). +.PARAMETER Verify + Verify-only mode: compare generated content with committed files, report mismatches, do NOT modify files. +.EXAMPLE + .\rebuild-bundles.ps1 # Rebuild and overwrite committed bundle artifacts + .\rebuild-bundles.ps1 -Verify # Verify only, do not modify files +#> +[CmdletBinding()] +param( + [switch]$Verify +) + +$ErrorActionPreference = 'Stop' +$projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$configPath = Join-Path $projectDir 'bundleconfig.json' + +if (-not (Test-Path $configPath)) { + throw "bundleconfig.json not found: $configPath" +} + +# Read bundleconfig.json (strip // comments) +$configJson = Get-Content $configPath -Raw +$configJson = $configJson -replace '(?m)//.*$', '' +$bundles = $configJson | ConvertFrom-Json + +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) +$mismatches = @() + +foreach ($bundle in $bundles) { + $outputRel = $bundle.outputFileName + $outputPath = Join-Path $projectDir $outputRel + $isMinified = $bundle.minify.enabled + + Write-Host ("[{0}] {1}" -f $(if ($isMinified) { 'SKIP-MIN' } else { 'BUILD' }), $outputRel) + + # Check input files exist + no BOM + $parts = [System.Collections.Generic.List[string]]::new() + $missingInputs = @() + $bomInputs = @() + + foreach ($inputRel in $bundle.inputFiles) { + $inputPath = Join-Path $projectDir $inputRel + if (-not (Test-Path $inputPath)) { + $missingInputs += $inputRel + continue + } + # Detect BOM by reading raw bytes + $bytes = [System.IO.File]::ReadAllBytes($inputPath) + $hasBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) + if ($hasBom) { + $bomInputs += $inputRel + } + # Read text (UTF8, BOM auto-stripped by ReadAllText) + $text = [System.IO.File]::ReadAllText($inputPath, [System.Text.Encoding]::UTF8) + $parts.Add($text) + } + + if ($missingInputs.Count -gt 0) { + Write-Host " [ERROR] Missing input files:" -ForegroundColor Red + foreach ($m in $missingInputs) { Write-Host " - $m" } + $mismatches += "$outputRel : missing inputs ($($missingInputs -join ', '))" + continue + } + if ($bomInputs.Count -gt 0) { + Write-Host " [ERROR] Input files have BOM:" -ForegroundColor Red + foreach ($b in $bomInputs) { Write-Host " - $b" } + $mismatches += "$outputRel : input BOM ($($bomInputs -join ', '))" + } + + # Pure concatenation with CRLF separator between files (matches BuildBundlerMinifier output) + $content = [string]::Join("`r`n", $parts) + + # For minified artifacts, only report, do not write + if ($isMinified) { + Write-Host " (minified artifact, skip write, only input check)" -ForegroundColor Yellow + continue + } + + if ($Verify) { + # Verify mode: compare with committed file + if (-not (Test-Path $outputPath)) { + Write-Host " [ERROR] Output file not found: $outputRel" -ForegroundColor Red + $mismatches += "$outputRel : output file not found" + continue + } + $existing = [System.IO.File]::ReadAllText($outputPath, [System.Text.Encoding]::UTF8) + if ($existing -ne $content) { + Write-Host " [MISMATCH] Differs from committed version" -ForegroundColor Red + $mismatches += "$outputRel : out of sync (source changed but bundle not rebuilt)" + } else { + Write-Host " [OK] Matches committed version" -ForegroundColor Green + } + } else { + # Rebuild mode: write (no BOM) + $dir = Split-Path $outputPath -Parent + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + [System.IO.File]::WriteAllText($outputPath, $content, $utf8NoBom) + Write-Host " [DONE] Written ($($content.Length) chars)" -ForegroundColor Green + } +} + +Write-Host "" +if ($mismatches.Count -gt 0) { + Write-Host "=== Result: ISSUES FOUND ===" -ForegroundColor Red + foreach ($m in $mismatches) { Write-Host " - $m" } + exit 1 +} else { + Write-Host "=== Result: ALL PASSED ===" -ForegroundColor Green + exit 0 +} diff --git a/YLErpWeb/rebuild-bundles.py b/YLErpWeb/rebuild-bundles.py new file mode 100644 index 00000000..d6a439bb --- /dev/null +++ b/YLErpWeb/rebuild-bundles.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +rebuild-bundles.py —— 前端 bundle 产物的确定性重建脚本 + +为什么需要它: + 页面加载的是 bundle.js / jquery.js / vue.js / bundle.css / bundleV2.css / bundleV2.js + (见 _MainLayout.cshtml),这些是由 bundleconfig.json 把分散的源文件拼接而成。 + 历史上"改了源文件却忘了重新打 bundle"导致部署的仍是陈旧/残缺产物(本次 EQD-6838 的痛点)。 + 本脚本按与历史手工打包一致的规则确定性地重建这 6 个产物,使"改源 -> 重新打 bundle"可复现、可校验。 + +拼接规则(已与已提交版本逐字节比对验证一致): + 1. 每个输入文件:剥掉文件首的 UTF-8 BOM(EF BB BF),行尾 CRLF->LF 归一(不改文件原始编码,GBK 等字节原样保留)。 + 2. 输入文件之间用一个 '\\n' 分隔(末尾文件后不加分隔符)。 + 3. CSS 产物额外做相对 url() 重写:把相对于各 CSS 源文件目录的 url(...) 改写为相对于 + bundle 输出目录(Statics/bundles/)的路径,行为与 BuildBundlerMinifier 一致: + - data: URI 原样保留(含引号); + - 其余 url 保留原始引号风格,仅改写路径为相对 bundle 目录的相对路径。 + (注:已提交 bundle.css 中少量 GBK 中文注释曾被旧工具误解码成 UTF-8 替换符,本脚本生成的是正确字节; + 重建后这些注释乱码会被修正,仅影响注释,不影响渲染。) + +用法: + python3 rebuild-bundles.py # 重建并写回 wwwroot/Statics/bundles/ 下 6 个产物 + python3 rebuild-bundles.py --verify # 重建到内存并与已提交文件比对,不一致则 exit 1(供 CI 校验) +""" +import json +import os +import re +import sys + +BOM = b'\xef\xbb\xbf' +ROOT = os.path.dirname(os.path.abspath(__file__)) # YLErpWeb 目录 +# 引号优先匹配,避免 data: URI 内的 ) 截断 +URL_RE = re.compile(rb"url\(\s*('[^']*'|\"[^\"]*\"|[^\"'()]*)\s*\)") + + +def normalize(b): + """剥文件首 BOM(若有) + 行尾归一为 \\n。全程字节级,不改编码(GBK 等原样保留)。""" + if isinstance(b, bytes): + if b.startswith(BOM): + b = b[3:] + return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + return b.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + + +def rewrite_css_urls(css, input_rel, out_rel): + """CSS 相对 url() 改写为相对 bundle 输出目录(与 BuildBundlerMinifier 行为一致)。""" + base = os.path.dirname(input_rel) # 输入文件目录(相对 ROOT) + outdir = os.path.dirname(out_rel) # bundle 输出目录(相对 ROOT) + + def repl(m): + raw = m.group(1).strip() + # 保留 url() 内原始引号风格 + if len(raw) >= 2 and raw[0:1] == raw[-1:] and raw[0:1] in (b"'", b'"'): + q = raw[0:1] + inner = raw[1:-1].strip() + else: + q = b'' + inner = raw + if inner.startswith(b'data:'): + return m.group(0) # data: URI 原样保留(含引号) + if re.match(rb'^(https?:|//|#)', inner) or inner.startswith(b'/'): + return m.group(0) + try: + udec = inner.decode('ascii') # url 通常为 ascii + except Exception: + return m.group(0) + resolved = os.path.normpath(os.path.join(base, udec)) + rel = os.path.relpath(resolved, outdir).replace(os.sep, '/') + return b'url(' + q + rel.encode('utf-8', errors='surrogateescape') + q + b')' + return URL_RE.sub(repl, css) + + +def build_all(): + cfg_path = os.path.join(ROOT, 'bundleconfig.json') + raw = open(cfg_path, 'rb').read() + raw = re.sub(rb'//[^\n]*', b'', raw) # 去掉 // 行注释 + cfg = json.loads(raw.decode('utf-8-sig')) + + results = [] # (out, generated_bytes, missing_list) + for b in cfg: + out = b['outputFileName'] + iscss = out.endswith('.css') + parts = [] + missing = [] + for f in b['inputFiles']: + p = os.path.join(ROOT, f) + if not os.path.exists(p): + missing.append(f) + continue + content = normalize(open(p, 'rb').read()) + if iscss: + content = rewrite_css_urls(content, f, out) + parts.append(content) + joined = b'\n'.join(parts) # 文件间一个 \n 分隔,末尾不加 + results.append((out, joined, missing)) + return results + + +def main(): + verify = '--verify' in sys.argv[1:] + results = build_all() + ok = True + for out, joined, missing in results: + dest = os.path.join(ROOT, out) + if missing: + ok = False + print(f" [MISSING INPUT] {out}: {missing}") + continue + if verify: + original = normalize(open(dest, 'rb').read()) + if joined != original: + ok = False + n = min(len(joined), len(original)) + i = 0 + while i < n and joined[i] == original[i]: + i += 1 + print(f" [STALE] {out} 与源文件不同步 @byte {i}") + print(f" gen={joined[max(0,i-40):i+40]!r}") + print(f" com={original[max(0,i-40):i+40]!r}") + else: + open(dest, 'wb').write(joined) + print(f"{'VERIFY ' if verify else 'WRITE '} -> {out} ({len(joined)} bytes)" + + ("" if not missing else f" MISSING={missing}")) + if verify: + print("\n全部与源同步 ✅" if ok else "\n存在不同步,请先运行 `python3 rebuild-bundles.py` 重建并提交 ✅") + else: + print("\n重建完成" if ok else "\n重建完成(有输入缺失,请检查)") + return 0 if ok else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/YLErpWeb/rebuild-bundles.sh b/YLErpWeb/rebuild-bundles.sh new file mode 100644 index 00000000..e8d18d0b --- /dev/null +++ b/YLErpWeb/rebuild-bundles.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# rebuild-bundles.sh —— 委托给 rebuild-bundles.py 重建/校验前端 bundle 产物。 +# CI(Linux) 在 publish 前由 YLErpWeb.csproj 的 GenerateBundlesBeforePublish 目标调用。 +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +if command -v python3 >/dev/null 2>&1; then + exec python3 "$DIR/rebuild-bundles.py" "$@" +else + echo "WARNING: python3 不可用,跳过 bundle 重建(将使用仓库内已提交的产物)。" >&2 + exit 0 +fi