#!/usr/bin/env node
|
/**
|
* 金额精度检查(防复发门禁)
|
*
|
* 背景:金额浮点累加在本项目反复引发线上问题,典型症状是
|
* 「合计对不上」「出现 -0.00」「金额带一长串小数」。根因是 JS 的 Number
|
* 是 IEEE-754 双精度浮点,对 2 位小数金额做 + - * 会引入二进制舍入误差,
|
* 且误差会随明细条数累积(实测约 39% 的多明细组合会出现尾差)。
|
*
|
* 因此约定:金额运算一律使用 @/utils/money(内部基于 decimal.js,十进制精确运算)。
|
*
|
* 本脚本扫描 src 下的 .js / .vue,找出「疑似对金额做浮点累加」的写法并报错退出,
|
* 以便在提交前拦住回归。建议挂到 pre-commit 或 CI。
|
*
|
* 用法:
|
* node build/checkMoney.js
|
* 或 npm run check:money
|
*
|
* 若确认某处是误报(例如累加的不是金额,而是数量、天数等),
|
* 在该行上方加注释:// money-check-ignore
|
*/
|
const fs = require("fs");
|
const path = require("path");
|
|
const SRC_DIR = path.resolve(__dirname, "../src");
|
const IGNORE_COMMENT = "money-check-ignore";
|
|
// 危险写法:对 parseFloat 结果做累加,或以 parseFloat 相乘后参与累加
|
const RULES = [
|
{
|
id: "float-accumulate",
|
// 形如 totalFee += parseFloat(...)
|
pattern: /\+=\s*parseFloat\s*\(/,
|
message: "对 parseFloat 结果做累加,金额会累积浮点误差"
|
},
|
{
|
id: "float-subtract-assign",
|
// 形如 totalFee -= parseFloat(...)
|
pattern: /-=\s*parseFloat\s*\(/,
|
message: "对 parseFloat 结果做减法累加,金额会累积浮点误差"
|
},
|
{
|
id: "float-text-math",
|
// 形如 a = (parseFloat(x) - parseFloat(y)).toFixed(2)
|
pattern: /=\s*\(?\s*parseFloat\s*\([^)]*\)\s*[-+*]\s*parseFloat\s*\(/,
|
message: "parseFloat 之间直接做算术运算再格式化,结果可能带浮点尾差"
|
}
|
];
|
|
// 排除第三方库与构建产物,仅检查业务源码
|
const EXCLUDE_DIRS = [
|
path.join(SRC_DIR, "echarts"), // echarts 第三方库源码
|
path.join(SRC_DIR, "assets")
|
];
|
|
// 允许的写法:这些是对单个值的合法性/零值校验,不构成累加,不会放大误差
|
const ALLOW = [/parseFloat\([^)]*\)\s*==\s*0/, /parseFloat\([^)]*\)\s*!=\s*0/];
|
|
function walk(dir, out) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
const full = path.join(dir, entry.name);
|
if (entry.isDirectory()) {
|
if (entry.name === "node_modules") continue;
|
if (EXCLUDE_DIRS.indexOf(full) >= 0) continue;
|
walk(full, out);
|
} else if (/\.(js|vue)$/.test(entry.name)) {
|
out.push(full);
|
}
|
}
|
return out;
|
}
|
|
function checkFile(file) {
|
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
const hits = [];
|
let inBlockComment = false;
|
|
lines.forEach((line, idx) => {
|
const code = line.trim();
|
|
// 跟踪跨行块注释:块注释中的代码不参与检查(避免对已注释掉的代码误报)
|
if (inBlockComment) {
|
if (code.indexOf("*/") >= 0) inBlockComment = false;
|
return;
|
}
|
if (code.indexOf("/*") === 0) {
|
if (code.indexOf("*/") < 0) inBlockComment = true;
|
return;
|
}
|
// 行注释
|
if (code.indexOf("//") === 0 || code.indexOf("*") === 0) return;
|
// 允许的写法:单个值的合法性/零值校验
|
if (ALLOW.some((re) => re.test(line))) return;
|
// 显式误报标记
|
const prev = idx > 0 ? lines[idx - 1] : "";
|
if (prev.indexOf(IGNORE_COMMENT) >= 0) return;
|
|
RULES.forEach((rule) => {
|
if (rule.pattern.test(line)) {
|
hits.push({ line: idx + 1, id: rule.id, message: rule.message, code: code.slice(0, 120) });
|
}
|
});
|
});
|
|
return hits;
|
}
|
|
function main() {
|
if (!fs.existsSync(SRC_DIR)) {
|
console.error(`找不到源码目录:${SRC_DIR}`);
|
process.exit(1);
|
}
|
|
const files = walk(SRC_DIR, []);
|
let total = 0;
|
const report = [];
|
|
files.forEach((file) => {
|
const hits = checkFile(file);
|
if (hits.length) {
|
total += hits.length;
|
report.push({ file: path.relative(path.resolve(__dirname, ".."), file), hits });
|
}
|
});
|
|
if (total === 0) {
|
console.log(`金额精度检查通过(已扫描 ${files.length} 个文件)`);
|
process.exit(0);
|
}
|
|
console.error("金额精度检查未通过:发现疑似金额浮点累加\n");
|
report.forEach(({ file, hits }) => {
|
console.error(` ${file}`);
|
hits.forEach((h) => {
|
console.error(` L${h.line} [${h.id}] ${h.message}`);
|
console.error(` ${h.code}`);
|
});
|
console.error("");
|
});
|
console.error("请改用 @/utils/money 提供的方法,例如:");
|
console.error(' import { moneySum, moneyAdd, moneyMul, moneyRound } from "@/utils/money";');
|
console.error(" const total = moneyRound(moneySum(list, \"amount\"));");
|
console.error(" 金额相等比较用 moneyEq(),不要用 parseFloat(...) == 或 ===。");
|
console.error(" 确属误报时,在该行上方加注释 // money-check-ignore");
|
process.exit(1);
|
}
|
|
main();
|