本文整理G神网页费用换算工具中的计算逻辑,使用AI辅助编辑。示例金额完全虚构,本文讨论前端实现、输入约束和金额舍入。
先确定费用的运算顺序
比较同一种商品的两份报价时,输入可能包含四项:账单金额、原币附加费用、每单位原币折合的人民币成本、人民币端另收的费用。这里采用的模型是:
人民币总额 =(原币金额 + 原币费用)× 换算系数 + 人民币端费用
关键取舍是先把同币种费用合并,再换算,最后加本币费用。已经包含在账单金额里的费用应由使用者排除,计算器本身无法识别是否重复填写。商品、期限或交付条件不同,也不能仅凭计算结果比较价格。
虚构输入为金额10、原币费用2、换算系数3、人民币端费用1,结果应为37.00。这个例子专门用来暴露费用顺序写错的问题:把所有费用都放进括号,或先把部分费用按分舍入,可能得到另一结果。
为什么最后才舍入
JavaScript的Number使用二进制浮点表示十进制小数。金额展示可以使用两位小数,但把每一步中间结果都变成两位小数,会改变完整算式的结果。
这里直接解析用户输入的十进制字符串,用整数分子和10的幂分母表示数值。加法和乘法都保留这个表示,得到完整人民币总额后,才进行一次非负数的四舍五入。
完整计算核心
以下代码可直接放入现代浏览器的模块脚本中运行。它不请求网络、不获取汇率,也不读取支付信息。
const MAX_MONEY = 10000000n;
function decimal(value, label, maximum, positive = false) {
const raw = String(value ?? '').trim();
if (!/^\d{1,8}(?:\.\d{1,8})?$/.test(raw)) throw new Error(`${label}请填写普通非负数字,最多8位小数。`);
const [whole, fraction = ''] = raw.split('.');
const denominator = 10n ** BigInt(fraction.length);
const numerator = BigInt(whole + fraction);
if ((positive && numerator === 0n) || numerator > maximum * denominator) throw new Error(`${label}超出可计算范围。`);
return {n:numerator,d:denominator};
}
const add = (a,b) => ({n:a.n*b.d+b.n*a.d,d:a.d*b.d});
const multiply = (a,b) => ({n:a.n*b.n,d:a.d*b.d});
// Decimal arithmetic rounds the final CNY amount once, without binary-float drift.
function calculateQuote(input) {
const amount=decimal(input.amount,'账单金额',MAX_MONEY,true);
const sourceFee=decimal(input.sourceFee === '' || input.sourceFee == null ? '0' : input.sourceFee,'原币附加费用',MAX_MONEY);
const rate=decimal(input.rate,'换算汇率',1000n,true);
const localFee=decimal(input.localFee === '' || input.localFee == null ? '0' : input.localFee,'人民币附加费用',MAX_MONEY);
const total=add(multiply(add(amount,sourceFee),rate),localFee);
const cents=Number((total.n*200n+total.d)/(total.d*2n));
return {cents,totalCny:cents/100,formatted:(cents/100).toFixed(2)};
}
输入边界的考虑
输入只接受普通非负十进制字符串,拒绝负数、指数写法、Infinity及超长小数,避免把含糊的输入交给Number自动解释。金额和换算系数必须为正,空白手续费作为0处理。
MAX_MONEY和换算系数上限共同限制最终结果的范围。在这些约束下,最终转换成Number的整数分值仍处于安全整数范围内;如果扩大产品的输入上限,需要重新计算边界,或继续以BigInt保存分值。
用小例子验证关键取舍
const examples = [
[{amount:'10',sourceFee:'2',rate:'3',localFee:'1'}, '37.00'],
[{amount:'1.005',rate:'1'}, '1.01'],
[{amount:'0.10',sourceFee:'0.20',rate:'1',localFee:'0.005'}, '0.31'],
[{amount:'999',rate:'0.125'}, '124.88'],
];
for (const [input, expected] of examples) {
console.assert(calculateQuote(input).formatted === expected, input);
}
for (const input of [
{amount:'-1',rate:'1'},
{amount:'1e3',rate:'1'},
{amount:'1',rate:'0'},
{amount:'1',rate:'1001'},
]) {
let rejected = false;
try { calculateQuote(input); } catch { rejected = true; }
console.assert(rejected, input);
}
第一例检查费用先后顺序,第二例检查最终金额处在半分边界时的处理,第三例检查十进制加法与最后一次舍入,最后一例检查非整数换算系数。拒绝用例则防止界面约束失效后,把不符合模型定义的输入继续计算。
与表单结合时的两个细节
- 读取input.value字符串,把它直接交给十进制解析函数。若先执行Number(value),再转回字符串,原本想保留的十进制输入表达就可能已经改变。
- 生成结果使用textContent;不要把用户提供的币种名称或说明拼进innerHTML。表单只处理报价数值,计算过程不需要账户、银行卡、兑换码或Session。
可以在submit事件中读取FormData,将两份报价分别传入calculateQuote,最后比较返回的cents。这里计算的是同一组明确输入对应的总额,实际成交仍应与真实费用记录核对。
这个实现的范围
这个方案适合输入规模受限、运算只有加法与乘法的页面工具。复杂财务系统涉及不同币种精度、负数、退款、税务舍入或审计记录时,应另外设计金额模型和规则;这里的非负四舍五入不能直接覆盖那些场景。
实现上的重点是让运算顺序、输入边界与舍入时机都可以由读者检查,给出完整代码和能区分错误实现的用例。页面展示的两位小数是最后一步,前面的十进制输入始终保留为整数比例。