#!/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();