feat(diag): 可保留调试日志+版本可追溯机制(阶段0.5止血增强)

解决 EQD-6838 排查时硬编码版本号 v20260729a 散落3处、无法按需开关、
看不到实际加载缓存戳的问题。

层1 运行时开关(核心):
- HtmlUtil.cs 新增 GitCommit(读 AssemblyInformationalVersion,零新依赖)
- _MainLayout.cshtml 注入 window.ylotc.__diag(jsVersion+git+built)
- main.js 顶部新增 otcDebug 工具(banner+log),debug 默认 false 生产零输出
- swapTradeEdit.js / fastVue.base.js 硬编码标记改为 otcDebug.banner
- 开启方式:URL ?otcdebug=1(会话)或 localStorage.setItem('otcdebug','1')(持久)

层2 构建守卫:
- pre-commit 新增第三块 bundle 版本一致性检查(rebuild-bundles.py --verify)
- 修复 Windows python3 Store stub 不可用问题(优先用 python)
- 注:bundle 头部不注入 git sha(会随 commit 变化导致 verify 永久失败),
  版本可追溯完全由运行时 __diag(后端注入)覆盖

层3 单测预防:
- 新增 diag.test.js(213测试之一),用 console spy 守卫开关行为
- 断言不再含硬编码 v20260729a + 防止第二套调试开关回归

验证:11 suites/213 tests 全绿,--verify 通过,C# 编译0错误
This commit is contained in:
hjhan
2026-07-30 10:06:21 +08:00
parent 0747deb909
commit c97d7e7995
9 changed files with 316 additions and 8 deletions
+14 -1
View File
@@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Html;
using System.Collections;
using System.Reflection;
using YLErp.Events;
namespace YLErp
@@ -17,6 +17,12 @@ namespace YLErp
//bin目录文件版本
public static readonly DateTime BinFileVersion;
/// <summary>
/// Git提交哈希(取自程序集 AssemblyInformationalVersion,由.NET SDK在编译时自动生成,
/// 格式 "1.0.0+&lt;sha&gt;";前端诊断信息用它精确定位是哪次提交的部署)。
/// </summary>
public static readonly string GitCommit;
static long _dataCacheUpdateTime;
static HtmlUtil()
@@ -27,6 +33,13 @@ namespace YLErp
BinFileVersion = files.Any() ? files.Max(n => n.LastWriteTime) : DateTime.MinValue;
JsVersion = BinFileVersion.ToString("yyMMddHHmmss");
//从 AssemblyInformationalVersion 读取 git shaSDK 编译时已嵌入,零额外依赖)
var infoVer = typeof(HtmlUtil).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
//格式 "1.0.0+<sha>",取 + 之后部分;无则回退到完整字符串
GitCommit = string.IsNullOrEmpty(infoVer) ? "unknown"
: (infoVer.Contains('+') ? infoVer.Substring(infoVer.LastIndexOf('+') + 1) : infoVer);
EventBus.Subscribe<DataCacheUpdateEvent>(t =>
{
_dataCacheUpdateTime = DateTimeOffset.Now.ToUnixTimeSeconds();
+12
View File
@@ -157,6 +157,18 @@
</script>
<script type="text/javascript">
// 运行时诊断信息(排查用):URL 加 ?otcdebug=1(当前会话)或控制台 localStorage.setItem('otcdebug','1')(持久)开启详细日志
// 挂在已存在的 window.ylotc 命名空间上,供后续 bundle.js / fastVue / 业务脚本读取;debug 默认 false,生产零输出
window.ylotc = window.ylotc || {};
window.ylotc.__diag = {
debug: /[?&]otcdebug=1/.test(location.search) || (function () { try { return localStorage.getItem('otcdebug') === '1'; } catch (e) { return false; } })(),
jsVersion: '@HtmlUtil.JsVersion',
git: '@HtmlUtil.GitCommit',
built: '@(HtmlUtil.BinFileVersion.ToString("yyyy-MM-dd HH:mm:ss"))'
};
if (window.ylotc.__diag.debug && window.console) {
console.log('%c[OTC diag] ' + JSON.stringify(window.ylotc.__diag), 'color:#0a0;font-weight:bold');
}
var rootpath = '@Url.Content("~/")';
var main = main || { version: "1.1" };
main.rights = main.rights || {};
+177
View File
@@ -0,0 +1,177 @@
/**
* diag.test.js — 运行时诊断机制测试(版本可追溯 + 可控调试日志)
* ============================================================================
* 目的:守卫"可保留调试日志 + 版本可追溯"机制,让排查 EQD-6838 这类"改了不生效"
* 问题时,F12 能一键开启详细日志并看到精确版本(jsVersion + git sha)。
*
* 覆盖:
* 1. 开关解析:URL ?otcdebug=1 / localStorage.otcdebug=1 解析正确
* 2. 静默性:debug=false 时 otcDebug 不输出(生产零噪音)
* 3. banner 格式:debug=true 时输出含 jsVersion + git(版本可追溯)
* 4. 防回归:业务文件不再硬编码 v20260729a 这类易过期的版本串
*
* 建立 console spy 先例:项目此前零 `jest.spyOn(console)` 用法,本测试建立范式。
*
* 运行:cd YLErpWeb/fe-tests && npx jest diag
*/
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// ---- 与 main.js 顶部一致的 otcDebug 实现(行为黄金标准) ----
// 为什么复制而非 require main.jsmain.js 第 5 行 `main.extend = $.extend` 依赖 jQuery+lodash
// 完整 require 代价过大;otcDebug 工具是纯函数(不依赖 $),提取出来做行为验证最干净。
// main.js 源码一致性由下方"源码守卫"用例保证(断言关键片段存在)。
function createOtcDebug(diag) {
var __otcDiag = diag || { debug: false };
return {
banner: function (name, ver) {
if (!__otcDiag.debug || !console) return;
var git = (__otcDiag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')',
'color:#06c;font-weight:bold');
},
log: function () {
if (!__otcDiag.debug || !console) return;
console.log.apply(console, arguments);
}
};
}
// ---- 与 _MainLayout.cshtml 一致的 diag 开关解析(行为黄金标准) ----
function parseDebugFlag(search, localStorageValue) {
return /[?&]otcdebug=1/.test(search)
|| (localStorageValue === '1');
}
describe('diag: 开关解析(URL / localStorage', () => {
test('?otcdebug=1 在 query 中 → 开启', () => {
expect(parseDebugFlag('?otcdebug=1', null)).toBe(true);
expect(parseDebugFlag('/swap/edit?otcdebug=1&id=5', null)).toBe(true);
});
test('?otcdebug=1 作为唯一参数 → 开启', () => {
expect(parseDebugFlag('?otcdebug=1', null)).toBe(true);
});
test('URL 无参数 → 关闭', () => {
expect(parseDebugFlag('', null)).toBe(false);
expect(parseDebugFlag('/swap/edit', null)).toBe(false);
});
test('localStorage.otcdebug=1 → 持久开启', () => {
expect(parseDebugFlag('', '1')).toBe(true);
});
test('localStorage 其它值 → 关闭', () => {
expect(parseDebugFlag('', null)).toBe(false);
expect(parseDebugFlag('', '0')).toBe(false);
expect(parseDebugFlag('', '')).toBe(false);
});
test('URL 和 localStorage 任一为真即开启(OR 语义)', () => {
expect(parseDebugFlag('?otcdebug=1', '0')).toBe(true);
expect(parseDebugFlag('', '1')).toBe(true);
expect(parseDebugFlag('?foo=bar', '0')).toBe(false);
});
});
describe('diag: otcDebug 静默性(debug=false 生产零输出)', () => {
let logSpy;
beforeEach(() => { logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterEach(() => { logSpy.mockRestore(); });
test('debug=false 时 banner 不输出', () => {
const dbg = createOtcDebug({ debug: false, jsVersion: '2507300000', git: 'abc1234', built: '2026-07-30' });
dbg.banner('swapTradeEdit.js', '1.4.2');
expect(logSpy).not.toHaveBeenCalled();
});
test('debug=false 时 log 不输出', () => {
const dbg = createOtcDebug({ debug: false });
dbg.log('排查信息', { a: 1 });
expect(logSpy).not.toHaveBeenCalled();
});
test('无 diag 对象时默认静默', () => {
const dbg = createOtcDebug(undefined);
dbg.banner('x', '1'); dbg.log('y');
expect(logSpy).not.toHaveBeenCalled();
});
});
describe('diag: banner 输出含完整版本信息(可追溯)', () => {
let logSpy;
beforeEach(() => { logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterEach(() => { logSpy.mockRestore(); });
test('banner 输出包含 模块名 + jsVersion + git sha 前7位 + built', () => {
const diag = { debug: true, jsVersion: '2507301200', git: 'f5ed65aac5f93028', built: '2026-07-30 10:00:00' };
const dbg = createOtcDebug(diag);
dbg.banner('swapTradeEdit.js', '1.4.2');
expect(logSpy).toHaveBeenCalledTimes(1);
const out = logSpy.mock.calls[0][0];
expect(out).toContain('swapTradeEdit.js');
expect(out).toContain('1.4.2');
expect(out).toContain('2507301200'); // jsVersion
expect(out).toContain('f5ed65a'); // git sha 前7位(被 slice 截断)
expect(out).not.toContain('f5ed65aac5f93028'); // 不含完整 sha(避免 console 过长)
expect(out).toContain('2026-07-30 10:00:00'); // built
});
test('log 透传所有参数', () => {
const dbg = createOtcDebug({ debug: true });
dbg.log('事件', 'keydown', { key: 'Enter' });
expect(logSpy).toHaveBeenCalledTimes(1);
expect(logSpy.mock.calls[0]).toEqual(['事件', 'keydown', { key: 'Enter' }]);
});
test('git 缺失时 banner 不崩(输出 git= 空)', () => {
const dbg = createOtcDebug({ debug: true, jsVersion: '1', git: undefined, built: '' });
expect(() => dbg.banner('m', '1')).not.toThrow();
expect(logSpy.mock.calls[0][0]).toContain('git=');
});
});
describe('diag: 源码守卫(防回归——不许再硬编码易过期版本串)', () => {
const SCRIPTS = path.join(__dirname, '..', 'wwwroot', 'Scripts');
test('swapTradeEdit.js 顶部 banner 不再硬编码 v20260729a', () => {
const src = fs.readFileSync(path.join(SCRIPTS, 'app/swaptrade/swapTradeEdit.js'), 'utf8');
expect(src).not.toContain('v20260729a');
// 必须改为受开关控制的动态 banner
expect(src).toMatch(/otcDebug\.banner|window\.ylotc\.__diag/);
});
test('fastVue.base.js 顶部 banner 不再硬编码 v20260729a', () => {
const src = fs.readFileSync(path.join(SCRIPTS, 'fast/fastVue.base.js'), 'utf8');
expect(src).not.toContain('v20260729a');
expect(src).toMatch(/window\.ylotc\.__diag/);
});
test('main.js 提供 otcDebug 工具且挂在 window', () => {
const src = fs.readFileSync(path.join(SCRIPTS, 'base/main.js'), 'utf8');
expect(src).toContain('window.otcDebug');
expect(src).toContain('window.ylotc.__diag');
// 工具必须实现 banner + log 两个方法
expect(src).toMatch(/banner\s*[:=]\s*function/);
expect(src).toMatch(/log\s*[:=]\s*function/);
});
test('main.js 不存在第二套调试开关(机制 A 防回归)', () => {
// 历史教训:曾同时存在两套重叠的调试开关——
// 机制A: ?debug=1 / localStorage.__yl_debug__ / main.debugLog/debugBanner
// 机制B: ?otcdebug=1 / localStorage.otcdebug / window.otcDebug(统一方案,保留)
// 两套并存导致排查者记两套参数、两个前缀、fallback 分支。本守卫确保只有一套。
const src = fs.readFileSync(path.join(SCRIPTS, 'base/main.js'), 'utf8');
expect(src).not.toContain('main.debugLog');
expect(src).not.toContain('main.debugWarn');
expect(src).not.toContain('main.debugError');
expect(src).not.toContain('main.debugBanner');
expect(src).not.toContain('main.setDebug');
expect(src).not.toContain('__yl_debug__');
expect(src).not.toContain('?debug=1');
});
test('bundle 产物不再含硬编码 v20260729a(源文件已清除,重建产物自然不含)', () => {
// 版本可追溯不靠 bundle 头部注释(那会随 commit 变化导致 verify 永久失败),
// 而是靠运行时 window.ylotc.__diag(后端 HtmlUtil.GitCommit 注入)+ otcDebug.banner。
// 本守卫确保源文件的硬编码清除后,重建产物不会重新带回它。
const bundle = path.join(__dirname, '..', 'wwwroot', 'Statics', 'bundles', 'bundle.js');
if (!fs.existsSync(bundle)) return; // 产物可能尚未重建,跳过
const src = fs.readFileSync(bundle, 'utf8');
expect(src).not.toContain('v20260729a');
});
});
+34 -1
View File
@@ -2,9 +2,10 @@
# =============================================================================
# pre-commit — 提交前自动守卫
# =============================================================================
# 做件事(任一失败即阻断提交):
# 做件事(任一失败即阻断提交):
# 1. guard_arch.js — 扫描新增/修改行,禁止在 Vue 组件里内联金额计算
# 2. jest — 跑前端单测,确保不引入回归
# 3. bundle 校验 — rebuild-bundles.py --verify,拦截"改源文件忘重新打 bundle"
#
# 安装方式(开发者只需执行一次):
# cd <repo-root>
@@ -85,5 +86,37 @@ else
echo -e "${YELLOW} 建议:cd YLErpWeb/fe-tests && npm install${NC}"
fi
# -----------------------------------------------------------------------------
# 3. bundle 版本一致性 — 拦截"改了源文件却忘重新打 bundle"
# -----------------------------------------------------------------------------
# EQD-6838 踩坑:8 次提交只为解决"改源文件没生效"。本检查在提交前用
# rebuild-bundles.py --verify 逐字节比对产物与源文件,不同步则阻断。
# 仅在有 bundle 相关改动时跑(Scripts 源文件 或 bundles 产物 任一改动)。
if command -v python &>/dev/null || command -v python3 &>/dev/null; then
# 选可用的 python:优先 pythonWindows 上 python3 可能是微软 Store 的 stub,不可用)
if command -v python &>/dev/null && python -c 'print(1)' &>/dev/null; then
PY=python
else
PY=python3
fi
BUNDLE_SRC_CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- "*.js" | grep "wwwroot/Scripts/" || true)
BUNDLE_OUT_CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- "YLErpWeb/wwwroot/Statics/bundles/" || true)
if [ -n "$BUNDLE_SRC_CHANGED" ] || [ -n "$BUNDLE_OUT_CHANGED" ]; then
echo -e "${YELLOW}[pre-commit] ③ bundle 版本一致性 (rebuild-bundles.py --verify)...${NC}"
if ($PY "$REPO_ROOT/YLErpWeb/rebuild-bundles.py" --verify 2>&1); then
echo -e "${GREEN}[pre-commit] ✅ bundle 产物与源文件同步${NC}"
else
echo -e "${RED}[pre-commit] ❌ bundle 产物与源文件不同步,提交被阻断${NC}"
echo -e "${YELLOW} 请运行:python YLErpWeb/rebuild-bundles.py 重建并提交产物${NC}"
echo -e "${YELLOW} 或用 git commit --no-verify 跳过(不推荐)${NC}"
exit 1
fi
else
echo -e "${GREEN}[pre-commit] ⏭️ 无 bundle 相关改动,跳过 bundle 校验${NC}"
fi
else
echo -e "${YELLOW}[pre-commit] ⚠️ 未找到 python,跳过 bundle 校验${NC}"
fi
echo -e "${GREEN}[pre-commit] ✅ 所有守卫检查通过,继续提交${NC}"
exit 0
@@ -1,7 +1,8 @@
//otcformat禁止千分位分组
window.otcformat.options.disableGrouping = true;
// 版本标记(排查用):F12 Console 看到 v20260729a 说明加载的是最新版本
console.log('[swapTradeEdit.js] v20260729a loaded');
// 模块加载横幅(排查用):仅在 ?otcdebug=1 或 localStorage.otcdebug=1 开启时打印,含 bundle 版本+git sha。
// otcDebug 由 main.js 提供(在 bundle.js 内),本文件是独立 <script> 且晚于 bundle.js 加载,故 otcDebug 必已就绪。
otcDebug.banner('swapTradeEdit.js', '1.4.2');
const consClients = ylotc.clients;
const consTraders = ylotc.traders;
+20
View File
@@ -1,5 +1,25 @@
//依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js
// 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。
// 开启方式:URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。
// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log
// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。
var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false };
window.otcDebug = {
// 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存"
banner: function (name, ver) {
if (!__otcDiag.debug || !window.console) return;
var git = (__otcDiag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')',
'color:#06c;font-weight:bold');
},
// 普通调试日志:透传参数,仅 debug 开启时输出
log: function () {
if (!__otcDiag.debug || !window.console) return;
console.log.apply(console, arguments);
}
};
var main = window.main || { version: "1.0" };
main.extend = $.extend;
@@ -1,7 +1,13 @@
(function (window, $) {
// 版本标记(排查用):F12 Console 看到 v20260729a 说明 bundle.js 是最新版本
if (window.console) console.log('[fastVue.base.js] v20260729a loaded');
// 模块加载横幅(排查用):仅在 ?otcdebug=1 或 localStorage.otcdebug=1 开启时打印,含 bundle 版本+git sha。
// 注意:本文件在 bundle.js 中早于 main.jsotcDebug 定义处)加载,故直接读 window.ylotc.__diag,不依赖 otcDebug。
var __diag = window.ylotc && window.ylotc.__diag;
if (__diag && __diag.debug && window.console) {
var git = (__diag.git || '').slice(0, 7);
console.log('%c[fastVue.base.js] v1.4.2 (bundle=' + __diag.jsVersion + ', git=' + git + ', built=' + __diag.built + ')',
'color:#06c;font-weight:bold');
}
function FastVue() {
File diff suppressed because one or more lines are too long
@@ -801,6 +801,26 @@ An}();typeof define=="function"&&typeof define.amd=="object"&&define.amd?($n._=r
}));
//依赖类库:lodash.js + jquery + jquery.validate + bootstrap.emodal.js
// 统一调试日志工具:仅在 otcdebug 开启时输出,默认静默(生产零噪音)。
// 开启方式:URL 加 ?otcdebug=1,或控制台 localStorage.setItem('otcdebug','1')。
// 业务脚本逐步改用 otcDebug.banner / otcDebug.log 替代裸 console.log
// 既能在排查时一键全开,又能避免版本标记散落硬编码(见 swapTradeEdit.js / fastVue.base.js 用法)。
var __otcDiag = (window.ylotc && window.ylotc.__diag) || { debug: false };
window.otcDebug = {
// 模块加载横幅:F12 一眼看到模块名+bundle版本+git sha,用于排查"是不是加载了旧代码/旧缓存"
banner: function (name, ver) {
if (!__otcDiag.debug || !window.console) return;
var git = (__otcDiag.git || '').slice(0, 7);
console.log('%c[' + name + '] v' + ver + ' (bundle=' + __otcDiag.jsVersion + ', git=' + git + ', built=' + __otcDiag.built + ')',
'color:#06c;font-weight:bold');
},
// 普通调试日志:透传参数,仅 debug 开启时输出
log: function () {
if (!__otcDiag.debug || !window.console) return;
console.log.apply(console, arguments);
}
};
var main = window.main || { version: "1.0" };
main.extend = $.extend;