yxh
2026-10-12 0e10e10157b90002b99996914eaf1e5574924ddf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#!/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();