- base/main.js __post/main.post:标注业务错误(success=false/errcode)与网络异常均走 deferred.reject, 调用方只挂 .done 会静默跳过失败分支(约定2补充曾因此长期不生效)。 - app/swaptrade/swapTradeEdit.js calcBondForItem:标注本文件为独立 <script>(不在 bundle.js)、须 dotnet publish 刷新 JsVersion 缓存键才生效;并展开 .fail 注释记录约定2补充一度不生效的历史根因。 - rebuild-bundles.py:标注 bundleconfig 输入路径在 Linux 大小写敏感(曾 MISSING 打挂 publish)、 缺失输入必须非致命(否则 exit 1 连带打挂 dotnet publish)。纯注释,无逻辑改动。
208 lines
9.5 KiB
Python
208 lines
9.5 KiB
Python
#!/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):
|
||
# ⚠️【CI 踩坑必读 / 大小写敏感】Windows 大小写不敏感,本地能找到 MyJs.js / isoWeek.js;
|
||
# 但 Jenkins 是 Linux,大小写敏感,bundleconfig.json 里写的输入路径必须与 git 实际跟踪的
|
||
# 文件名【大小写完全一致】,否则这里判 missing。曾经因此报 MISSING INPUT 并把整个 dotnet publish 打挂。
|
||
# 改 bundleconfig 的输入文件名时,务必先 `git ls-files` 确认真实大小写。
|
||
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 is_stale(out, input_files):
|
||
"""增量检查:任一源文件比 bundle 产物新(或产物不存在)则返回 True。"""
|
||
dest = os.path.join(ROOT, out)
|
||
if not os.path.exists(dest):
|
||
return True
|
||
out_mtime = os.path.getmtime(dest)
|
||
for f in input_files:
|
||
p = os.path.join(ROOT, f)
|
||
if os.path.exists(p) and os.path.getmtime(p) > out_mtime:
|
||
return True
|
||
return False
|
||
|
||
|
||
def is_ci_environment():
|
||
"""检测是否在 CI 环境运行。
|
||
CI 下 git checkout 后所有文件 mtime 几乎相同,增量检查不可靠,
|
||
因此 CI 环境强制全量重建。
|
||
"""
|
||
ci_vars = ('CI', 'JENKINS_HOME', 'BUILD_NUMBER', 'GITHUB_ACTIONS',
|
||
'GITLAB_CI', 'TF_BUILD')
|
||
return any(os.environ.get(v) for v in ci_vars)
|
||
|
||
|
||
def main():
|
||
verify = '--verify' in sys.argv[1:]
|
||
force_rebuild = is_ci_environment() # CI 环境强制全量重建
|
||
# 读取配置用于增量检查
|
||
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'))
|
||
|
||
# 增量过滤:非 verify、非 CI 时,跳过源文件未变更的 bundle
|
||
# CI 环境(force_rebuild=True)强制全量重建,因为 git checkout 后 mtime 不可靠
|
||
stale_bundles = []
|
||
for b in cfg:
|
||
out = b['outputFileName']
|
||
if verify or force_rebuild or is_stale(out, b['inputFiles']):
|
||
stale_bundles.append(b)
|
||
else:
|
||
print(f" [UP-TO-DATE] {out}")
|
||
|
||
# 只重建过期的 bundle
|
||
results = []
|
||
for b in stale_bundles:
|
||
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):
|
||
# ⚠️【CI 踩坑必读 / 大小写敏感】Windows 大小写不敏感,本地能找到 MyJs.js / isoWeek.js;
|
||
# 但 Jenkins 是 Linux,大小写敏感,bundleconfig.json 里写的输入路径必须与 git 实际跟踪的
|
||
# 文件名【大小写完全一致】,否则这里判 missing。曾经因此报 MISSING INPUT 并把整个 dotnet publish 打挂。
|
||
# 改 bundleconfig 的输入文件名时,务必先 `git ls-files` 确认真实大小写。
|
||
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)
|
||
results.append((out, joined, missing))
|
||
|
||
ok = True
|
||
for out, joined, missing in results:
|
||
dest = os.path.join(ROOT, out)
|
||
if missing:
|
||
# ⚠️【CI 踩坑必读 / 非致命】缺失输入文件绝不 fail:早期实现把 MISSING 当致命错误 exit 1,
|
||
# 会连带打挂整个 dotnet publish(yml 报错 "命令已退出,代码为 1")。
|
||
# 现改为:重建模式跳过该 bundle(保留已提交的产物即可,CI 仍能出包);只有 --verify 校验模式才 exit 1。
|
||
# 重建模式下警告但不 fail,避免阻塞 CI 主构建
|
||
if verify:
|
||
ok = False
|
||
print(f" [MISSING INPUT] {out}: {missing}")
|
||
else:
|
||
print(f" [SKIP-MISSING] {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重建完成(部分 bundle 输入缺失已跳过,请检查)")
|
||
# 重建模式永远返回 0,不阻塞 CI;Verify 模式才可能返回 1
|
||
return 0 if (ok or not verify) else 1
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|